Initial
This commit is contained in:
314
resources/app/client/pixi/core/interaction/canvas-animation.js
Normal file
314
resources/app/client/pixi/core/interaction/canvas-animation.js
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @typedef {Object} CanvasAnimationAttribute
|
||||
* @property {string} attribute The attribute name being animated
|
||||
* @property {Object} parent The object within which the attribute is stored
|
||||
* @property {number} to The destination value of the attribute
|
||||
* @property {number} [from] An initial value of the attribute, otherwise parent[attribute] is used
|
||||
* @property {number} [delta] The computed delta between to and from
|
||||
* @property {number} [done] The amount of the total delta which has been animated
|
||||
* @property {boolean} [color] Is this a color animation that applies to RGB channels
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CanvasAnimationOptions
|
||||
* @property {PIXI.DisplayObject} [context] A DisplayObject which defines context to the PIXI.Ticker function
|
||||
* @property {string|symbol} [name] A unique name which can be used to reference the in-progress animation
|
||||
* @property {number} [duration] A duration in milliseconds over which the animation should occur
|
||||
* @property {number} [priority] A priority in PIXI.UPDATE_PRIORITY which defines when the animation
|
||||
* should be evaluated related to others
|
||||
* @property {Function|string} [easing] An easing function used to translate animation time or the string name
|
||||
* of a static member of the CanvasAnimation class
|
||||
* @property {function(number, CanvasAnimationData)} [ontick] A callback function which fires after every frame
|
||||
* @property {Promise} [wait] The animation isn't started until this promise resolves
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} _CanvasAnimationData
|
||||
* @property {Function} fn The animation function being executed each frame
|
||||
* @property {number} time The current time of the animation, in milliseconds
|
||||
* @property {CanvasAnimationAttribute[]} attributes The attributes being animated
|
||||
* @property {number} state The current state of the animation (see {@link CanvasAnimation.STATES})
|
||||
* @property {Promise} promise A Promise which resolves once the animation is complete
|
||||
* @property {Function} resolve The resolution function, allowing animation to be ended early
|
||||
* @property {Function} reject The rejection function, allowing animation to be ended early
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {_CanvasAnimationData & CanvasAnimationOptions} CanvasAnimationData
|
||||
*/
|
||||
|
||||
/**
|
||||
* A helper class providing utility methods for PIXI Canvas animation
|
||||
*/
|
||||
class CanvasAnimation {
|
||||
|
||||
/**
|
||||
* The possible states of an animation.
|
||||
* @enum {number}
|
||||
*/
|
||||
static get STATES() {
|
||||
return this.#STATES;
|
||||
}
|
||||
|
||||
static #STATES = Object.freeze({
|
||||
|
||||
/**
|
||||
* An error occurred during waiting or running the animation.
|
||||
*/
|
||||
FAILED: -2,
|
||||
|
||||
/**
|
||||
* The animation was terminated before it could complete.
|
||||
*/
|
||||
TERMINATED: -1,
|
||||
|
||||
/**
|
||||
* Waiting for the wait promise before the animation is started.
|
||||
*/
|
||||
WAITING: 0,
|
||||
|
||||
/**
|
||||
* The animation has been started and is running.
|
||||
*/
|
||||
RUNNING: 1,
|
||||
|
||||
/**
|
||||
* The animation was completed without errors and without being terminated.
|
||||
*/
|
||||
COMPLETED: 2
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The ticker used for animations.
|
||||
* @type {PIXI.Ticker}
|
||||
*/
|
||||
static get ticker() {
|
||||
return canvas.app.ticker;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Track an object of active animations by name, context, and function
|
||||
* This allows a currently playing animation to be referenced and terminated
|
||||
* @type {Record<string, CanvasAnimationData>}
|
||||
*/
|
||||
static animations = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply an animation from the current value of some attribute to a new value
|
||||
* Resolve a Promise once the animation has concluded and the attributes have reached their new target
|
||||
*
|
||||
* @param {CanvasAnimationAttribute[]} attributes An array of attributes to animate
|
||||
* @param {CanvasAnimationOptions} options Additional options which customize the animation
|
||||
*
|
||||
* @returns {Promise<boolean>} A Promise which resolves to true once the animation has concluded
|
||||
* or false if the animation was prematurely terminated
|
||||
*
|
||||
* @example Animate Token Position
|
||||
* ```js
|
||||
* let animation = [
|
||||
* {
|
||||
* parent: token,
|
||||
* attribute: "x",
|
||||
* to: 1000
|
||||
* },
|
||||
* {
|
||||
* parent: token,
|
||||
* attribute: "y",
|
||||
* to: 2000
|
||||
* }
|
||||
* ];
|
||||
* CanvasAnimation.animate(attributes, {duration:500});
|
||||
* ```
|
||||
*/
|
||||
static async animate(attributes, {context=canvas.stage, name, duration=1000, easing, ontick, priority, wait}={}) {
|
||||
priority ??= PIXI.UPDATE_PRIORITY.LOW + 1;
|
||||
if ( typeof easing === "string" ) easing = this[easing];
|
||||
|
||||
// If an animation with this name already exists, terminate it
|
||||
if ( name ) this.terminateAnimation(name);
|
||||
|
||||
// Define the animation and its animation function
|
||||
attributes = attributes.map(a => {
|
||||
a.from = a.from ?? a.parent[a.attribute];
|
||||
a.delta = a.to - a.from;
|
||||
a.done = 0;
|
||||
|
||||
// Special handling for color transitions
|
||||
if ( a.to instanceof Color ) {
|
||||
a.color = true;
|
||||
a.from = Color.from(a.from);
|
||||
}
|
||||
return a;
|
||||
});
|
||||
if ( attributes.length && attributes.every(a => a.delta === 0) ) return;
|
||||
const animation = {attributes, context, duration, easing, name, ontick, time: 0, wait,
|
||||
state: CanvasAnimation.STATES.WAITING};
|
||||
animation.fn = dt => CanvasAnimation.#animateFrame(dt, animation);
|
||||
|
||||
// Create a promise which manages the animation lifecycle
|
||||
const promise = new Promise(async (resolve, reject) => {
|
||||
animation.resolve = completed => {
|
||||
if ( (animation.state === CanvasAnimation.STATES.WAITING)
|
||||
|| (animation.state === CanvasAnimation.STATES.RUNNING) ) {
|
||||
animation.state = completed ? CanvasAnimation.STATES.COMPLETED : CanvasAnimation.STATES.TERMINATED;
|
||||
resolve(completed);
|
||||
}
|
||||
};
|
||||
animation.reject = error => {
|
||||
if ( (animation.state === CanvasAnimation.STATES.WAITING)
|
||||
|| (animation.state === CanvasAnimation.STATES.RUNNING) ) {
|
||||
animation.state = CanvasAnimation.STATES.FAILED;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if ( wait instanceof Promise ) await wait;
|
||||
if ( animation.state === CanvasAnimation.STATES.WAITING ) {
|
||||
animation.state = CanvasAnimation.STATES.RUNNING;
|
||||
this.ticker.add(animation.fn, context, priority);
|
||||
}
|
||||
} catch(err) {
|
||||
animation.reject(err);
|
||||
}
|
||||
})
|
||||
|
||||
// Log any errors
|
||||
.catch(err => console.error(err))
|
||||
|
||||
// Remove the animation once completed
|
||||
.finally(() => {
|
||||
this.ticker.remove(animation.fn, context);
|
||||
if ( name && (this.animations[name] === animation) ) delete this.animations[name];
|
||||
});
|
||||
|
||||
// Record the animation and return
|
||||
if ( name ) {
|
||||
animation.promise = promise;
|
||||
this.animations[name] = animation;
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve an animation currently in progress by its name
|
||||
* @param {string} name The animation name to retrieve
|
||||
* @returns {CanvasAnimationData} The animation data, or undefined
|
||||
*/
|
||||
static getAnimation(name) {
|
||||
return this.animations[name];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If an animation using a certain name already exists, terminate it
|
||||
* @param {string} name The animation name to terminate
|
||||
*/
|
||||
static terminateAnimation(name) {
|
||||
let animation = this.animations[name];
|
||||
if (animation) animation.resolve(false);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Cosine based easing with smooth in-out.
|
||||
* @param {number} pt The proportional animation timing on [0,1]
|
||||
* @returns {number} The eased animation progress on [0,1]
|
||||
*/
|
||||
static easeInOutCosine(pt) {
|
||||
return (1 - Math.cos(Math.PI * pt)) * 0.5;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shallow ease out.
|
||||
* @param {number} pt The proportional animation timing on [0,1]
|
||||
* @returns {number} The eased animation progress on [0,1]
|
||||
*/
|
||||
static easeOutCircle(pt) {
|
||||
return Math.sqrt(1 - Math.pow(pt - 1, 2));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Shallow ease in.
|
||||
* @param {number} pt The proportional animation timing on [0,1]
|
||||
* @returns {number} The eased animation progress on [0,1]
|
||||
*/
|
||||
static easeInCircle(pt) {
|
||||
return 1 - Math.sqrt(1 - Math.pow(pt, 2));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generic ticker function to implement the animation.
|
||||
* This animation wrapper executes once per frame for the duration of the animation event.
|
||||
* Once the animated attributes have converged to their targets, it resolves the original Promise.
|
||||
* The user-provided ontick function runs each frame update to apply additional behaviors.
|
||||
*
|
||||
* @param {number} deltaTime The incremental time which has elapsed
|
||||
* @param {CanvasAnimationData} animation The animation which is being performed
|
||||
*/
|
||||
static #animateFrame(deltaTime, animation) {
|
||||
const {attributes, duration, ontick} = animation;
|
||||
|
||||
// Compute animation timing and progress
|
||||
const dt = this.ticker.elapsedMS; // Delta time in MS
|
||||
animation.time += dt; // Total time which has elapsed
|
||||
const complete = animation.time >= duration;
|
||||
const pt = complete ? 1 : animation.time / duration; // Proportion of total duration
|
||||
const pa = animation.easing ? animation.easing(pt) : pt;
|
||||
|
||||
// Update each attribute
|
||||
try {
|
||||
for ( let a of attributes ) CanvasAnimation.#updateAttribute(a, pa);
|
||||
if ( ontick ) ontick(dt, animation);
|
||||
}
|
||||
|
||||
// Terminate the animation if any errors occur
|
||||
catch(err) {
|
||||
animation.reject(err);
|
||||
}
|
||||
|
||||
// Resolve the original promise once the animation is complete
|
||||
if ( complete ) animation.resolve(true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Update a single attribute according to its animation completion percentage
|
||||
* @param {CanvasAnimationAttribute} attribute The attribute being animated
|
||||
* @param {number} percentage The animation completion percentage
|
||||
*/
|
||||
static #updateAttribute(attribute, percentage) {
|
||||
attribute.done = attribute.delta * percentage;
|
||||
|
||||
// Complete animation
|
||||
if ( percentage === 1 ) {
|
||||
attribute.parent[attribute.attribute] = attribute.to;
|
||||
return;
|
||||
}
|
||||
|
||||
// Color animation
|
||||
if ( attribute.color ) {
|
||||
attribute.parent[attribute.attribute] = attribute.from.mix(attribute.to, percentage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric attribute
|
||||
attribute.parent[attribute.attribute] = attribute.from + attribute.done;
|
||||
}
|
||||
}
|
||||
105
resources/app/client/pixi/core/interaction/control-icon.js
Normal file
105
resources/app/client/pixi/core/interaction/control-icon.js
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* A generic helper for drawing a standard Control Icon
|
||||
* @type {PIXI.Container}
|
||||
*/
|
||||
class ControlIcon extends PIXI.Container {
|
||||
constructor({texture, size=40, borderColor=0xFF5500, tint=null, elevation=0}={}, ...args) {
|
||||
super(...args);
|
||||
|
||||
// Define arguments
|
||||
this.iconSrc = texture;
|
||||
this.size = size;
|
||||
this.rect = [-2, -2, size+4, size+4];
|
||||
this.borderColor = borderColor;
|
||||
|
||||
/**
|
||||
* The color of the icon tint, if any
|
||||
* @type {number|null}
|
||||
*/
|
||||
this.tintColor = tint;
|
||||
|
||||
// Define hit area
|
||||
this.eventMode = "static";
|
||||
this.interactiveChildren = false;
|
||||
this.hitArea = new PIXI.Rectangle(...this.rect);
|
||||
this.cursor = "pointer";
|
||||
|
||||
// Background
|
||||
this.bg = this.addChild(new PIXI.Graphics());
|
||||
this.bg.clear().beginFill(0x000000, 0.4).lineStyle(2, 0x000000, 1.0).drawRoundedRect(...this.rect, 5).endFill();
|
||||
|
||||
// Icon
|
||||
this.icon = this.addChild(new PIXI.Sprite());
|
||||
|
||||
// Border
|
||||
this.border = this.addChild(new PIXI.Graphics());
|
||||
this.border.visible = false;
|
||||
|
||||
// Elevation
|
||||
this.tooltip = this.addChild(new PreciseText());
|
||||
this.tooltip.visible = false;
|
||||
|
||||
// Set the initial elevation
|
||||
this.elevation = elevation;
|
||||
|
||||
// Draw asynchronously
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The elevation of the ControlIcon, which is displayed in its tooltip text.
|
||||
* @type {number}
|
||||
*/
|
||||
get elevation() {
|
||||
return this.#elevation;
|
||||
}
|
||||
|
||||
set elevation(value) {
|
||||
if ( (typeof value !== "number") || !Number.isFinite(value) ) {
|
||||
throw new Error("ControlIcon#elevation must be a finite numeric value.");
|
||||
}
|
||||
if ( value === this.#elevation ) return;
|
||||
this.#elevation = value;
|
||||
this.tooltip.text = `${value > 0 ? "+" : ""}${value} ${canvas.grid.units}`.trim();
|
||||
this.tooltip.visible = value !== 0;
|
||||
}
|
||||
|
||||
#elevation = 0;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initial drawing of the ControlIcon
|
||||
* @returns {Promise<ControlIcon>}
|
||||
*/
|
||||
async draw() {
|
||||
if ( this.destroyed ) return this;
|
||||
this.texture = this.texture ?? await loadTexture(this.iconSrc);
|
||||
this.icon.texture = this.texture;
|
||||
this.icon.width = this.icon.height = this.size;
|
||||
this.tooltip.style = CONFIG.canvasTextStyle;
|
||||
this.tooltip.anchor.set(0.5, 1);
|
||||
this.tooltip.position.set(this.size / 2, -12);
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Incremental refresh for ControlIcon appearance.
|
||||
*/
|
||||
refresh({visible, iconColor, borderColor, borderVisible}={}) {
|
||||
if ( iconColor !== undefined ) this.tintColor = iconColor;
|
||||
this.icon.tint = this.tintColor ?? 0xFFFFFF;
|
||||
if ( borderColor !== undefined ) this.borderColor = borderColor;
|
||||
this.border.clear().lineStyle(2, this.borderColor, 1.0).drawRoundedRect(...this.rect, 5).endFill();
|
||||
if ( borderVisible !== undefined ) this.border.visible = borderVisible;
|
||||
if ( visible !== undefined && (this.visible !== visible) ) {
|
||||
this.visible = visible;
|
||||
MouseInteractionManager.emulateMoveEvent();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
885
resources/app/client/pixi/core/interaction/mouse-handler.js
Normal file
885
resources/app/client/pixi/core/interaction/mouse-handler.js
Normal file
@@ -0,0 +1,885 @@
|
||||
/**
|
||||
* Handle mouse interaction events for a Canvas object.
|
||||
* There are three phases of events: hover, click, and drag
|
||||
*
|
||||
* Hover Events:
|
||||
* _handlePointerOver
|
||||
* action: hoverIn
|
||||
* _handlePointerOut
|
||||
* action: hoverOut
|
||||
*
|
||||
* Left Click and Double-Click
|
||||
* _handlePointerDown
|
||||
* action: clickLeft
|
||||
* action: clickLeft2
|
||||
* action: unclickLeft
|
||||
*
|
||||
* Right Click and Double-Click
|
||||
* _handleRightDown
|
||||
* action: clickRight
|
||||
* action: clickRight2
|
||||
* action: unclickRight
|
||||
*
|
||||
* Drag and Drop
|
||||
* _handlePointerMove
|
||||
* action: dragLeftStart
|
||||
* action: dragRightStart
|
||||
* action: dragLeftMove
|
||||
* action: dragRightMove
|
||||
* _handlePointerUp
|
||||
* action: dragLeftDrop
|
||||
* action: dragRightDrop
|
||||
* _handleDragCancel
|
||||
* action: dragLeftCancel
|
||||
* action: dragRightCancel
|
||||
*/
|
||||
class MouseInteractionManager {
|
||||
constructor(object, layer, permissions={}, callbacks={}, options={}) {
|
||||
this.object = object;
|
||||
this.layer = layer;
|
||||
this.permissions = permissions;
|
||||
this.callbacks = callbacks;
|
||||
|
||||
/**
|
||||
* Interaction options which configure handling workflows
|
||||
* @type {{target: PIXI.DisplayObject, dragResistance: number}}
|
||||
*/
|
||||
this.options = options;
|
||||
|
||||
/**
|
||||
* The current interaction state
|
||||
* @type {number}
|
||||
*/
|
||||
this.state = this.states.NONE;
|
||||
|
||||
/**
|
||||
* Bound interaction data object to populate with custom data.
|
||||
* @type {Record<string, any>}
|
||||
*/
|
||||
this.interactionData = {};
|
||||
|
||||
/**
|
||||
* The drag handling time
|
||||
* @type {number}
|
||||
*/
|
||||
this.dragTime = 0;
|
||||
|
||||
/**
|
||||
* The time of the last left-click event
|
||||
* @type {number}
|
||||
*/
|
||||
this.lcTime = 0;
|
||||
|
||||
/**
|
||||
* The time of the last right-click event
|
||||
* @type {number}
|
||||
*/
|
||||
this.rcTime = 0;
|
||||
|
||||
/**
|
||||
* A flag for whether we are right-click dragging
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._dragRight = false;
|
||||
|
||||
/**
|
||||
* An optional ControlIcon instance for the object
|
||||
* @type {ControlIcon|null}
|
||||
*/
|
||||
this.controlIcon = this.options.target ? this.object[this.options.target] : null;
|
||||
|
||||
/**
|
||||
* The view id pertaining to the PIXI Application.
|
||||
* If not provided, default to canvas.app.view.id
|
||||
* @type {string}
|
||||
*/
|
||||
this.viewId = (this.options.application ?? canvas.app).view.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client position of the last left/right-click.
|
||||
* @type {PIXI.Point}
|
||||
*/
|
||||
lastClick = new PIXI.Point();
|
||||
|
||||
/**
|
||||
* Bound handlers which can be added and removed
|
||||
* @type {Record<string, Function>}
|
||||
*/
|
||||
#handlers = {};
|
||||
|
||||
/**
|
||||
* Enumerate the states of a mouse interaction workflow.
|
||||
* 0: NONE - the object is inactive
|
||||
* 1: HOVER - the mouse is hovered over the object
|
||||
* 2: CLICKED - the object is clicked
|
||||
* 3: GRABBED - the object is grabbed
|
||||
* 4: DRAG - the object is being dragged
|
||||
* 5: DROP - the object is being dropped
|
||||
* @enum {number}
|
||||
*/
|
||||
static INTERACTION_STATES = {
|
||||
NONE: 0,
|
||||
HOVER: 1,
|
||||
CLICKED: 2,
|
||||
GRABBED: 3,
|
||||
DRAG: 4,
|
||||
DROP: 5
|
||||
};
|
||||
|
||||
/**
|
||||
* Enumerate the states of handle outcome.
|
||||
* -2: SKIPPED - the handler has been skipped by previous logic
|
||||
* -1: DISALLOWED - the handler has dissallowed further process
|
||||
* 1: REFUSED - the handler callback has been processed and is refusing further process
|
||||
* 2: ACCEPTED - the handler callback has been processed and is accepting further process
|
||||
* @enum {number}
|
||||
*/
|
||||
static #HANDLER_OUTCOME = {
|
||||
SKIPPED: -2,
|
||||
DISALLOWED: -1,
|
||||
REFUSED: 1,
|
||||
ACCEPTED: 2
|
||||
};
|
||||
|
||||
/**
|
||||
* The maximum number of milliseconds between two clicks to be considered a double-click.
|
||||
* @type {number}
|
||||
*/
|
||||
static DOUBLE_CLICK_TIME_MS = 250;
|
||||
|
||||
/**
|
||||
* The maximum number of pixels between two clicks to be considered a double-click.
|
||||
* @type {number}
|
||||
*/
|
||||
static DOUBLE_CLICK_DISTANCE_PX = 5;
|
||||
|
||||
/**
|
||||
* The number of milliseconds of mouse click depression to consider it a long press.
|
||||
* @type {number}
|
||||
*/
|
||||
static LONG_PRESS_DURATION_MS = 500;
|
||||
|
||||
/**
|
||||
* Global timeout for the long-press event.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static longPressTimeout = null;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Emulate a pointermove event. Needs to be called when an object with the static event mode
|
||||
* or any of its parents is transformed or its visibility is changed.
|
||||
*/
|
||||
static emulateMoveEvent() {
|
||||
MouseInteractionManager.#emulateMoveEvent();
|
||||
}
|
||||
|
||||
static #emulateMoveEvent = foundry.utils.throttle(() => {
|
||||
const events = canvas.app.renderer.events;
|
||||
const rootPointerEvent = events.rootPointerEvent;
|
||||
if ( !events.supportsPointerEvents ) return;
|
||||
if ( events.supportsTouchEvents && (rootPointerEvent.pointerType === "touch") ) return;
|
||||
events.domElement.dispatchEvent(new PointerEvent("pointermove", {
|
||||
pointerId: rootPointerEvent.pointerId,
|
||||
pointerType: rootPointerEvent.pointerType,
|
||||
isPrimary: rootPointerEvent.isPrimary,
|
||||
clientX: rootPointerEvent.clientX,
|
||||
clientY: rootPointerEvent.clientY,
|
||||
pageX: rootPointerEvent.pageX,
|
||||
pageY: rootPointerEvent.pageY,
|
||||
altKey: rootPointerEvent.altKey,
|
||||
ctrlKey: rootPointerEvent.ctrlKey,
|
||||
metaKey: rootPointerEvent.metaKey,
|
||||
shiftKey: rootPointerEvent.shiftKey
|
||||
}));
|
||||
}, 10);
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the target.
|
||||
* @type {PIXI.DisplayObject}
|
||||
*/
|
||||
get target() {
|
||||
return this.options.target ? this.object[this.options.target] : this.object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this mouse manager in a dragging state?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get isDragging() {
|
||||
return this.state >= this.states.DRAG;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate interactivity for the handled object
|
||||
*/
|
||||
activate() {
|
||||
|
||||
// Remove existing listeners
|
||||
this.state = this.states.NONE;
|
||||
this.target.removeAllListeners();
|
||||
|
||||
// Create bindings for all handler functions
|
||||
this.#handlers = {
|
||||
pointerover: this.#handlePointerOver.bind(this),
|
||||
pointerout: this.#handlePointerOut.bind(this),
|
||||
pointerdown: this.#handlePointerDown.bind(this),
|
||||
pointermove: this.#handlePointerMove.bind(this),
|
||||
pointerup: this.#handlePointerUp.bind(this),
|
||||
contextmenu: this.#handleDragCancel.bind(this)
|
||||
};
|
||||
|
||||
// Activate hover events to start the workflow
|
||||
this.#activateHoverEvents();
|
||||
|
||||
// Set the target as interactive
|
||||
this.target.eventMode = "static";
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether the current user has permission to perform a step of the workflow
|
||||
* @param {string} action The action being attempted
|
||||
* @param {Event|PIXI.FederatedEvent} event The event being handled
|
||||
* @returns {boolean} Can the action be performed?
|
||||
*/
|
||||
can(action, event) {
|
||||
const fn = this.permissions[action];
|
||||
if ( typeof fn === "boolean" ) return fn;
|
||||
if ( fn instanceof Function ) return fn.call(this.object, game.user, event);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Execute a callback function associated with a certain action in the workflow
|
||||
* @param {string} action The action being attempted
|
||||
* @param {Event|PIXI.FederatedEvent} event The event being handled
|
||||
* @param {...*} args Additional callback arguments.
|
||||
* @returns {boolean} A boolean which may indicate that the event was handled by the callback.
|
||||
* Events which do not specify a callback are assumed to have been handled as no-op.
|
||||
*/
|
||||
callback(action, event, ...args) {
|
||||
const fn = this.callbacks[action];
|
||||
if ( fn instanceof Function ) {
|
||||
this.#assignInteractionData(event);
|
||||
return fn.call(this.object, event, ...args) ?? true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the possible interaction states which can be observed
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
get states() {
|
||||
return this.constructor.INTERACTION_STATES;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A reference to the possible interaction states which can be observed
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
get handlerOutcomes() {
|
||||
return MouseInteractionManager.#HANDLER_OUTCOME;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Listener Activation and Deactivation */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate a set of listeners which handle hover events on the target object
|
||||
*/
|
||||
#activateHoverEvents() {
|
||||
// Disable and re-register mouseover and mouseout handlers
|
||||
this.target.off("pointerover", this.#handlers.pointerover).on("pointerover", this.#handlers.pointerover);
|
||||
this.target.off("pointerout", this.#handlers.pointerout).on("pointerout", this.#handlers.pointerout);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate a new set of listeners for click events on the target object.
|
||||
*/
|
||||
#activateClickEvents() {
|
||||
this.#deactivateClickEvents();
|
||||
this.target.on("pointerdown", this.#handlers.pointerdown);
|
||||
this.target.on("pointerup", this.#handlers.pointerup);
|
||||
this.target.on("pointerupoutside", this.#handlers.pointerup);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Deactivate event listeners for click events on the target object.
|
||||
*/
|
||||
#deactivateClickEvents() {
|
||||
this.target.off("pointerdown", this.#handlers.pointerdown);
|
||||
this.target.off("pointerup", this.#handlers.pointerup);
|
||||
this.target.off("pointerupoutside", this.#handlers.pointerup);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate events required for handling a drag-and-drop workflow
|
||||
*/
|
||||
#activateDragEvents() {
|
||||
this.#deactivateDragEvents();
|
||||
this.layer.on("pointermove", this.#handlers.pointermove);
|
||||
if ( !this._dragRight ) {
|
||||
canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Deactivate events required for handling drag-and-drop workflow.
|
||||
* @param {boolean} [silent] Set to true to activate the silent mode.
|
||||
*/
|
||||
#deactivateDragEvents(silent) {
|
||||
this.layer.off("pointermove", this.#handlers.pointermove);
|
||||
canvas.app.view.removeEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Hover In and Hover Out */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-over events which activate downstream listeners and do not stop propagation.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handlePointerOver(event) {
|
||||
const action = "hoverIn";
|
||||
if ( (this.state !== this.states.NONE) || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId)) ) {
|
||||
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
|
||||
}
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
|
||||
// Invoke the callback function
|
||||
this.state = this.states.HOVER;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = this.states.NONE;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Activate click events
|
||||
this.#activateClickEvents();
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-out events which terminate hover workflows and do not stop propagation.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handlePointerOut(event) {
|
||||
if ( event.pointerType === "touch" ) return; // Ignore Touch events
|
||||
const action = "hoverOut";
|
||||
if ( !this.state.between(this.states.HOVER, this.states.CLICKED)
|
||||
|| (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId) ) ) {
|
||||
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
|
||||
}
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
|
||||
// Was the mouse-out event handled by the callback?
|
||||
const priorState = this.state;
|
||||
this.state = this.states.NONE;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = priorState;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Deactivate click events
|
||||
this.#deactivateClickEvents();
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-down events which activate downstream listeners.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handlePointerDown(event) {
|
||||
if ( event.button === 0 ) return this.#handleLeftDown(event);
|
||||
if ( event.button === 2 ) return this.#handleRightDown(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Left Click and Double Click */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle left-click mouse-down events.
|
||||
* Stop further propagation only if the event is allowed by either single or double-click.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleLeftDown(event) {
|
||||
if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;
|
||||
|
||||
// Determine double vs single click
|
||||
const isDouble = ((event.timeStamp - this.lcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
|
||||
&& (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
|
||||
<= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
|
||||
this.lcTime = isDouble ? 0 : event.timeStamp;
|
||||
this.lastClick.set(event.clientX, event.clientY);
|
||||
|
||||
// Set the origin point from layer local position
|
||||
this.interactionData.origin = event.getLocalPosition(this.layer);
|
||||
|
||||
// Activate a timeout to detect long presses
|
||||
if ( !isDouble ) {
|
||||
clearTimeout(this.constructor.longPressTimeout);
|
||||
this.constructor.longPressTimeout = setTimeout(() => {
|
||||
this.#handleLongPress(event, this.interactionData.origin);
|
||||
}, MouseInteractionManager.LONG_PRESS_DURATION_MS);
|
||||
}
|
||||
|
||||
// Dispatch to double and single-click handlers
|
||||
if ( isDouble && this.can("clickLeft2", event) ) return this.#handleClickLeft2(event);
|
||||
else return this.#handleClickLeft(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-down which trigger a single left-click workflow.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleClickLeft(event) {
|
||||
const action = "clickLeft";
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
this._dragRight = false;
|
||||
|
||||
// Was the left-click event handled by the callback?
|
||||
const priorState = this.state;
|
||||
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
|
||||
canvas.currentMouseManager = this;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = priorState;
|
||||
canvas.currentMouseManager = null;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Activate drag event handlers
|
||||
if ( (this.state === this.states.CLICKED) && this.can("dragStart", event) ) {
|
||||
this.state = this.states.GRABBED;
|
||||
this.#activateDragEvents();
|
||||
}
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-down which trigger a single left-click workflow.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleClickLeft2(event) {
|
||||
const action = "clickLeft2";
|
||||
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a long mouse depression to trigger a long-press workflow.
|
||||
* @param {PIXI.FederatedEvent} event The mousedown event.
|
||||
* @param {PIXI.Point} origin The original canvas coordinates of the mouse click
|
||||
*/
|
||||
#handleLongPress(event, origin) {
|
||||
const action = "longPress";
|
||||
if ( this.callback(action, event, origin) === false ) {
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Right Click and Double Click */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle right-click mouse-down events.
|
||||
* Stop further propagation only if the event is allowed by either single or double-click.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleRightDown(event) {
|
||||
if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;
|
||||
|
||||
// Determine double vs single click
|
||||
const isDouble = ((event.timeStamp - this.rcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
|
||||
&& (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
|
||||
<= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
|
||||
this.rcTime = isDouble ? 0 : event.timeStamp;
|
||||
this.lastClick.set(event.clientX, event.clientY);
|
||||
|
||||
// Update event data
|
||||
this.interactionData.origin = event.getLocalPosition(this.layer);
|
||||
|
||||
// Dispatch to double and single-click handlers
|
||||
if ( isDouble && this.can("clickRight2", event) ) return this.#handleClickRight2(event);
|
||||
else return this.#handleClickRight(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle single right-click actions.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleClickRight(event) {
|
||||
const action = "clickRight";
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
this._dragRight = true;
|
||||
|
||||
// Was the right-click event handled by the callback?
|
||||
const priorState = this.state;
|
||||
if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
|
||||
canvas.currentMouseManager = this;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = priorState;
|
||||
canvas.currentMouseManager = null;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Activate drag event handlers
|
||||
if ( (this.state === this.states.CLICKED) && this.can("dragRight", event) ) {
|
||||
this.state = this.states.GRABBED;
|
||||
this.#activateDragEvents();
|
||||
}
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle double right-click actions.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleClickRight2(event) {
|
||||
const action = "clickRight2";
|
||||
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Drag and Drop */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse movement during a drag workflow
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handlePointerMove(event) {
|
||||
if ( !this.state.between(this.states.GRABBED, this.states.DRAG) ) return;
|
||||
|
||||
// Limit dragging to 60 updates per second
|
||||
const now = Date.now();
|
||||
if ( (now - this.dragTime) < canvas.app.ticker.elapsedMS ) return;
|
||||
this.dragTime = now;
|
||||
|
||||
// Update interaction data
|
||||
const data = this.interactionData;
|
||||
data.destination = event.getLocalPosition(this.layer, data.destination);
|
||||
|
||||
// Handling rare case when origin is not defined
|
||||
// FIXME: The root cause should be identified and this code removed
|
||||
if ( data.origin === undefined ) data.origin = new PIXI.Point().copyFrom(data.destination);
|
||||
|
||||
// Begin a new drag event
|
||||
if ( this.state !== this.states.DRAG ) {
|
||||
const dx = data.destination.x - data.origin.x;
|
||||
const dy = data.destination.y - data.origin.y;
|
||||
const dz = Math.hypot(dx, dy);
|
||||
const r = this.options.dragResistance || (canvas.dimensions.size / 4);
|
||||
if ( dz >= r ) this.#handleDragStart(event);
|
||||
}
|
||||
|
||||
// Continue a drag event
|
||||
if ( this.state === this.states.DRAG ) this.#handleDragMove(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the beginning of a new drag start workflow, moving all controlled objects on the layer
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleDragStart(event) {
|
||||
clearTimeout(this.constructor.longPressTimeout);
|
||||
const action = this._dragRight ? "dragRightStart" : "dragLeftStart";
|
||||
if ( !this.can(action, event) ) {
|
||||
this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
this.cancel(event);
|
||||
return;
|
||||
}
|
||||
this.state = this.states.DRAG;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = this.states.GRABBED;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
return this.#debug(action, event, this.handlerOutcomes.ACCEPTED);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the continuation of a drag workflow, moving all controlled objects on the layer
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleDragMove(event) {
|
||||
clearTimeout(this.constructor.longPressTimeout);
|
||||
const action = this._dragRight ? "dragRightMove" : "dragLeftMove";
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
const handled = this.callback(action, event);
|
||||
return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse up events which may optionally conclude a drag workflow
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handlePointerUp(event) {
|
||||
clearTimeout(this.constructor.longPressTimeout);
|
||||
// If this is a touch hover event, treat it as a drag
|
||||
if ( (this.state === this.states.HOVER) && (event.pointerType === "touch") ) {
|
||||
this.state = this.states.DRAG;
|
||||
}
|
||||
|
||||
// Save prior state
|
||||
const priorState = this.state;
|
||||
|
||||
// Update event data
|
||||
this.interactionData.destination = event.getLocalPosition(this.layer, this.interactionData.destination);
|
||||
|
||||
if ( this.state >= this.states.DRAG ) {
|
||||
event.stopPropagation();
|
||||
if ( event.type.startsWith("right") && !this._dragRight ) return;
|
||||
if ( this.state === this.states.DRAG ) this.#handleDragDrop(event);
|
||||
}
|
||||
|
||||
// Continue a multi-click drag workflow
|
||||
if ( event.defaultPrevented ) {
|
||||
this.state = priorState;
|
||||
return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED);
|
||||
}
|
||||
|
||||
// Handle the unclick event
|
||||
this.#handleUnclick(event);
|
||||
|
||||
// Cancel the drag workflow
|
||||
this.#handleDragCancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the conclusion of a drag workflow, placing all dragged objects back on the layer
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleDragDrop(event) {
|
||||
const action = this._dragRight ? "dragRightDrop" : "dragLeftDrop";
|
||||
if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
|
||||
|
||||
// Was the drag-drop event handled by the callback?
|
||||
this.state = this.states.DROP;
|
||||
if ( this.callback(action, event) === false ) {
|
||||
this.state = this.states.DRAG;
|
||||
return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Update the workflow state
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the cancellation of a drag workflow, resetting back to the original state
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleDragCancel(event) {
|
||||
this.cancel(event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle the unclick event
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#handleUnclick(event) {
|
||||
const action = event.button === 0 ? "unclickLeft" : "unclickRight";
|
||||
if ( !this.state.between(this.states.CLICKED, this.states.GRABBED) ) {
|
||||
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
|
||||
}
|
||||
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
return this.#debug(action, event);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A public method to handle directly an event into this manager, according to its type.
|
||||
* Note: drag events are not handled.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
* @returns {boolean} Has the event been processed?
|
||||
*/
|
||||
handleEvent(event) {
|
||||
switch ( event.type ) {
|
||||
case "pointerover":
|
||||
this.#handlePointerOver(event);
|
||||
break;
|
||||
case "pointerout":
|
||||
this.#handlePointerOut(event);
|
||||
break;
|
||||
case "pointerup":
|
||||
this.#handlePointerUp(event);
|
||||
break;
|
||||
case "pointerdown":
|
||||
this.#handlePointerDown(event);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A public method to cancel a current interaction workflow from this manager.
|
||||
* @param {PIXI.FederatedEvent} [event] The event that initiates the cancellation
|
||||
*/
|
||||
cancel(event) {
|
||||
const eventSystem = canvas.app.renderer.events;
|
||||
const rootBoundary = eventSystem.rootBoundary;
|
||||
const createEvent = !event;
|
||||
if ( createEvent ) {
|
||||
event = rootBoundary.createPointerEvent(eventSystem.pointer, "pointermove", this.target);
|
||||
event.defaultPrevented = false;
|
||||
event.path = null;
|
||||
}
|
||||
try {
|
||||
const action = this._dragRight ? "dragRightCancel" : "dragLeftCancel";
|
||||
const endState = this.state;
|
||||
if ( endState <= this.states.HOVER ) return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
|
||||
|
||||
// Dispatch a cancellation callback
|
||||
if ( endState >= this.states.DRAG ) {
|
||||
if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
|
||||
}
|
||||
|
||||
// Continue a multi-click drag workflow if the default event was prevented in the callback
|
||||
if ( event.defaultPrevented ) {
|
||||
this.state = this.states.DRAG;
|
||||
return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
|
||||
}
|
||||
|
||||
// Reset the interaction data and state and deactivate drag events
|
||||
this.interactionData = {};
|
||||
this.state = this.states.HOVER;
|
||||
canvas.currentMouseManager = null;
|
||||
clearTimeout(this.constructor.longPressTimeout);
|
||||
this.#deactivateDragEvents();
|
||||
this.#debug(action, event);
|
||||
|
||||
// Check hover state and hover out if necessary
|
||||
if ( !rootBoundary.trackingData(event.pointerId).overTargets?.includes(this.target) ) {
|
||||
this.#handlePointerOut(event);
|
||||
}
|
||||
} finally {
|
||||
if ( createEvent ) rootBoundary.freeEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a debug message in the console (if mouse interaction debug is activated).
|
||||
* @param {string} action Which action to display?
|
||||
* @param {Event|PIXI.FederatedEvent} event Which event to display?
|
||||
* @param {number} [outcome=this.handlerOutcomes.ACCEPTED] The handler outcome.
|
||||
*/
|
||||
#debug(action, event, outcome=this.handlerOutcomes.ACCEPTED) {
|
||||
if ( CONFIG.debug.mouseInteraction ) {
|
||||
const name = this.object.constructor.name;
|
||||
const targetName = event.target?.constructor.name;
|
||||
const {eventPhase, type, button} = event;
|
||||
const state = Object.keys(this.states)[this.state.toString()];
|
||||
let msg = `${name} | ${action} | state:${state} | target:${targetName} | phase:${eventPhase} | type:${type} | `
|
||||
+ `btn:${button} | skipped:${outcome <= -2} | allowed:${outcome > -1} | handled:${outcome > 1}`;
|
||||
console.debug(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Reset the mouse manager.
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.interactionData=true] Reset the interaction data?
|
||||
* @param {boolean} [options.state=true] Reset the state?
|
||||
*/
|
||||
reset({interactionData=true, state=true}={}) {
|
||||
if ( CONFIG.debug.mouseInteraction ) {
|
||||
console.debug(`${this.object.constructor.name} | Reset | interactionData:${interactionData} | state:${state}`);
|
||||
}
|
||||
if ( interactionData ) this.interactionData = {};
|
||||
if ( state ) this.state = MouseInteractionManager.INTERACTION_STATES.NONE;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign the interaction data to the event.
|
||||
* @param {PIXI.FederatedEvent} event
|
||||
*/
|
||||
#assignInteractionData(event) {
|
||||
this.interactionData.object = this.object;
|
||||
event.interactionData = this.interactionData;
|
||||
|
||||
// Add deprecated event data references
|
||||
for ( const k of Object.keys(this.interactionData) ) {
|
||||
if ( event.hasOwnProperty(k) ) continue;
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
Object.defineProperty(event, k, {
|
||||
get() {
|
||||
const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
return this.interactionData[k];
|
||||
},
|
||||
set(value) {
|
||||
const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
|
||||
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
|
||||
this.interactionData[k] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
59
resources/app/client/pixi/core/interaction/ping.js
Normal file
59
resources/app/client/pixi/core/interaction/ping.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @typedef {object} PingOptions
|
||||
* @property {number} [duration=900] The duration of the animation in milliseconds.
|
||||
* @property {number} [size=128] The size of the ping graphic.
|
||||
* @property {string} [color=#ff6400] The color of the ping graphic.
|
||||
* @property {string} [name] The name for the ping animation to pass to {@link CanvasAnimation.animate}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A class to manage a user ping on the canvas.
|
||||
* @param {Point} origin The canvas coordinates of the origin of the ping.
|
||||
* @param {PingOptions} [options] Additional options to configure the ping animation.
|
||||
*/
|
||||
class Ping extends PIXI.Container {
|
||||
constructor(origin, options={}) {
|
||||
super();
|
||||
this.x = origin.x;
|
||||
this.y = origin.y;
|
||||
this.options = foundry.utils.mergeObject({duration: 900, size: 128, color: "#ff6400"}, options);
|
||||
this._color = Color.from(this.options.color);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
destroy(options={}) {
|
||||
options.children = true;
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Start the ping animation.
|
||||
* @returns {Promise<boolean>} Returns true if the animation ran to completion, false otherwise.
|
||||
*/
|
||||
async animate() {
|
||||
const completed = await CanvasAnimation.animate([], {
|
||||
context: this,
|
||||
name: this.options.name,
|
||||
duration: this.options.duration,
|
||||
ontick: this._animateFrame.bind(this)
|
||||
});
|
||||
this.destroy();
|
||||
return completed;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* On each tick, advance the animation.
|
||||
* @param {number} dt The number of ms that elapsed since the previous frame.
|
||||
* @param {CanvasAnimationData} animation The animation state.
|
||||
* @protected
|
||||
*/
|
||||
_animateFrame(dt, animation) {
|
||||
throw new Error("Subclasses of Ping must implement the _animateFrame method.");
|
||||
}
|
||||
}
|
||||
122
resources/app/client/pixi/core/interaction/pings/chevron.js
Normal file
122
resources/app/client/pixi/core/interaction/pings/chevron.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* A type of ping that points to a specific location.
|
||||
* @param {Point} origin The canvas coordinates of the origin of the ping.
|
||||
* @param {PingOptions} [options] Additional options to configure the ping animation.
|
||||
* @extends Ping
|
||||
*/
|
||||
class ChevronPing extends Ping {
|
||||
constructor(origin, options={}) {
|
||||
super(origin, options);
|
||||
this._r = (this.options.size / 2) * .75;
|
||||
|
||||
// The inner ring is 3/4s the size of the outer.
|
||||
this._rInner = this._r * .75;
|
||||
|
||||
// The animation is split into three stages. First, the chevron fades in and moves downwards, then the rings fade
|
||||
// in, then everything fades out as the chevron moves back up.
|
||||
// Store the 1/4 time slice.
|
||||
this._t14 = this.options.duration * .25;
|
||||
|
||||
// Store the 1/2 time slice.
|
||||
this._t12 = this.options.duration * .5;
|
||||
|
||||
// Store the 3/4s time slice.
|
||||
this._t34 = this._t14 * 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path to the chevron texture.
|
||||
* @type {string}
|
||||
* @private
|
||||
*/
|
||||
static _CHEVRON_PATH = "icons/pings/chevron.webp";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async animate() {
|
||||
this.removeChildren();
|
||||
this.addChild(...this._createRings());
|
||||
this._chevron = await this._loadChevron();
|
||||
this.addChild(this._chevron);
|
||||
return super.animate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_animateFrame(dt, animation) {
|
||||
const { time } = animation;
|
||||
if ( time < this._t14 ) {
|
||||
// Normalise t between 0 and 1.
|
||||
const t = time / this._t14;
|
||||
// Apply easing function.
|
||||
const dy = CanvasAnimation.easeOutCircle(t);
|
||||
this._chevron.y = this._y + (this._h2 * dy);
|
||||
this._chevron.alpha = time / this._t14;
|
||||
} else if ( time < this._t34 ) {
|
||||
const t = time - this._t14;
|
||||
const a = t / this._t12;
|
||||
this._drawRings(a);
|
||||
} else {
|
||||
const t = (time - this._t34) / this._t14;
|
||||
const a = 1 - t;
|
||||
const dy = CanvasAnimation.easeInCircle(t);
|
||||
this._chevron.y = this._y + ((1 - dy) * this._h2);
|
||||
this._chevron.alpha = a;
|
||||
this._drawRings(a);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the outer and inner rings.
|
||||
* @param {number} a The alpha.
|
||||
* @private
|
||||
*/
|
||||
_drawRings(a) {
|
||||
this._outer.clear();
|
||||
this._inner.clear();
|
||||
this._outer.lineStyle(6, this._color, a).drawCircle(0, 0, this._r);
|
||||
this._inner.lineStyle(3, this._color, a).arc(0, 0, this._rInner, 0, Math.PI * 1.5);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load the chevron texture.
|
||||
* @returns {Promise<PIXI.Sprite>}
|
||||
* @private
|
||||
*/
|
||||
async _loadChevron() {
|
||||
const texture = await TextureLoader.loader.loadTexture(ChevronPing._CHEVRON_PATH);
|
||||
const chevron = PIXI.Sprite.from(texture);
|
||||
chevron.tint = this._color;
|
||||
|
||||
const w = this.options.size;
|
||||
const h = (texture.height / texture.width) * w;
|
||||
chevron.width = w;
|
||||
chevron.height = h;
|
||||
|
||||
// The chevron begins the animation slightly above the pinged point.
|
||||
this._h2 = h / 2;
|
||||
chevron.x = -(w / 2);
|
||||
chevron.y = this._y = -h - this._h2;
|
||||
|
||||
return chevron;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the two rings that are used as part of the ping animation.
|
||||
* @returns {PIXI.Graphics[]}
|
||||
* @private
|
||||
*/
|
||||
_createRings() {
|
||||
this._outer = new PIXI.Graphics();
|
||||
this._inner = new PIXI.Graphics();
|
||||
return [this._outer, this._inner];
|
||||
}
|
||||
}
|
||||
216
resources/app/client/pixi/core/interaction/pings/pulse.js
Normal file
216
resources/app/client/pixi/core/interaction/pings/pulse.js
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @typedef {PingOptions} PulsePingOptions
|
||||
* @property {number} [rings=3] The number of rings used in the animation.
|
||||
* @property {string} [color2=#ffffff] The alternate color that the rings begin at. Use white for a 'flashing' effect.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A type of ping that produces a pulsing animation.
|
||||
* @param {Point} origin The canvas coordinates of the origin of the ping.
|
||||
* @param {PulsePingOptions} [options] Additional options to configure the ping animation.
|
||||
* @extends Ping
|
||||
*/
|
||||
class PulsePing extends Ping {
|
||||
constructor(origin, {rings=3, color2="#ffffff", ...options}={}) {
|
||||
super(origin, {rings, color2, ...options});
|
||||
this._color2 = game.settings.get("core", "photosensitiveMode") ? this._color : Color.from(color2);
|
||||
|
||||
// The radius is half the diameter.
|
||||
this._r = this.options.size / 2;
|
||||
|
||||
// This is the radius that the rings initially begin at. It's set to 1/5th of the maximum radius.
|
||||
this._r0 = this._r / 5;
|
||||
|
||||
this._computeTimeSlices();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize some time slice variables that will be used to control the animation.
|
||||
*
|
||||
* The animation for each ring can be separated into two consecutive stages.
|
||||
* Stage 1: Fade in a white ring with radius r0.
|
||||
* Stage 2: Expand radius outward. While the radius is expanding outward, we have two additional, consecutive
|
||||
* animations:
|
||||
* Stage 2.1: Transition color from white to the configured color.
|
||||
* Stage 2.2: Fade out.
|
||||
* 1/5th of the animation time is allocated to Stage 1. 4/5ths are allocated to Stage 2. Of those 4/5ths, 2/5ths
|
||||
* are allocated to Stage 2.1, and 2/5ths are allocated to Stage 2.2.
|
||||
* @private
|
||||
*/
|
||||
_computeTimeSlices() {
|
||||
// We divide up the total duration of the animation into rings + 1 time slices. Ring animations are staggered by 1
|
||||
// slice, and last for a total of 2 slices each. This uses up the full duration and creates the ripple effect.
|
||||
this._timeSlice = this.options.duration / (this.options.rings + 1);
|
||||
this._timeSlice2 = this._timeSlice * 2;
|
||||
|
||||
// Store the 1/5th time slice for Stage 1.
|
||||
this._timeSlice15 = this._timeSlice2 / 5;
|
||||
|
||||
// Store the 2/5ths time slice for the subdivisions of Stage 2.
|
||||
this._timeSlice25 = this._timeSlice15 * 2;
|
||||
|
||||
// Store the 4/5ths time slice for Stage 2.
|
||||
this._timeSlice45 = this._timeSlice25 * 2;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async animate() {
|
||||
// Draw rings.
|
||||
this.removeChildren();
|
||||
for ( let i = 0; i < this.options.rings; i++ ) {
|
||||
this.addChild(new PIXI.Graphics());
|
||||
}
|
||||
|
||||
// Add a blur filter to soften the sharp edges of the shape.
|
||||
const f = new PIXI.BlurFilter(2);
|
||||
f.padding = this.options.size;
|
||||
this.filters = [f];
|
||||
|
||||
return super.animate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_animateFrame(dt, animation) {
|
||||
const { time } = animation;
|
||||
for ( let i = 0; i < this.options.rings; i++ ) {
|
||||
const ring = this.children[i];
|
||||
|
||||
// Offset each ring by 1 time slice.
|
||||
const tMin = this._timeSlice * i;
|
||||
|
||||
// Each ring gets 2 time slices to complete its full animation.
|
||||
const tMax = tMin + this._timeSlice2;
|
||||
|
||||
// If it's not time for this ring to animate, do nothing.
|
||||
if ( (time < tMin) || (time >= tMax) ) continue;
|
||||
|
||||
// Normalise our t.
|
||||
let t = time - tMin;
|
||||
|
||||
ring.clear();
|
||||
if ( t < this._timeSlice15 ) {
|
||||
// Stage 1. Fade in a white ring of radius r0.
|
||||
const a = t / this._timeSlice15;
|
||||
this._drawShape(ring, this._color2, a, this._r0);
|
||||
} else {
|
||||
// Stage 2. Expand radius, transition color, and fade out. Re-normalize t for Stage 2.
|
||||
t -= this._timeSlice15;
|
||||
const dr = this._r / this._timeSlice45;
|
||||
const r = this._r0 + (t * dr);
|
||||
|
||||
const c0 = this._color;
|
||||
const c1 = this._color2;
|
||||
const c = t <= this._timeSlice25 ? this._colorTransition(c0, c1, this._timeSlice25, t) : c0;
|
||||
|
||||
const ta = Math.max(0, t - this._timeSlice25);
|
||||
const a = 1 - (ta / this._timeSlice25);
|
||||
this._drawShape(ring, c, a, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Transition linearly from one color to another.
|
||||
* @param {Color} from The color to transition from.
|
||||
* @param {Color} to The color to transition to.
|
||||
* @param {number} duration The length of the transition in milliseconds.
|
||||
* @param {number} t The current time along the duration.
|
||||
* @returns {number} The incremental color between from and to.
|
||||
* @private
|
||||
*/
|
||||
_colorTransition(from, to, duration, t) {
|
||||
const d = t / duration;
|
||||
const rgbFrom = from.rgb;
|
||||
const rgbTo = to.rgb;
|
||||
return Color.fromRGB(rgbFrom.map((c, i) => {
|
||||
const diff = rgbTo[i] - c;
|
||||
return c + (d * diff);
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Draw the shape for this ping.
|
||||
* @param {PIXI.Graphics} g The graphics object to draw to.
|
||||
* @param {number} color The color of the shape.
|
||||
* @param {number} alpha The alpha of the shape.
|
||||
* @param {number} size The size of the shape to draw.
|
||||
* @protected
|
||||
*/
|
||||
_drawShape(g, color, alpha, size) {
|
||||
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
|
||||
g.drawCircle(0, 0, size);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of ping that produces an arrow pointing in a given direction.
|
||||
* @property {PIXI.Point} origin The canvas coordinates of the origin of the ping. This becomes the arrow's
|
||||
* tip.
|
||||
* @property {PulsePingOptions} [options] Additional options to configure the ping animation.
|
||||
* @property {number} [options.rotation=0] The angle of the arrow in radians.
|
||||
* @extends PulsePing
|
||||
*/
|
||||
class ArrowPing extends PulsePing {
|
||||
constructor(origin, {rotation=0, ...options}={}) {
|
||||
super(origin, options);
|
||||
this.rotation = Math.normalizeRadians(rotation + (Math.PI * 1.5));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_drawShape(g, color, alpha, size) {
|
||||
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
|
||||
const half = size / 2;
|
||||
const x = -half;
|
||||
const y = -size;
|
||||
g.moveTo(x, y)
|
||||
.lineTo(0, 0)
|
||||
.lineTo(half, y)
|
||||
.lineTo(0, -half)
|
||||
.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of ping that produces a pulse warning sign animation.
|
||||
* @param {PIXI.Point} origin The canvas coordinates of the origin of the ping.
|
||||
* @param {PulsePingOptions} [options] Additional options to configure the ping animation.
|
||||
* @extends PulsePing
|
||||
*/
|
||||
class AlertPing extends PulsePing {
|
||||
constructor(origin, {color="#ff0000", ...options}={}) {
|
||||
super(origin, {color, ...options});
|
||||
this._r = this.options.size;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
_drawShape(g, color, alpha, size) {
|
||||
// Draw a chamfered triangle.
|
||||
g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
|
||||
const half = size / 2;
|
||||
const chamfer = size / 10;
|
||||
const chamfer2 = chamfer / 2;
|
||||
const x = -half;
|
||||
const y = -(size / 3);
|
||||
g.moveTo(x+chamfer, y)
|
||||
.lineTo(x+size-chamfer, y)
|
||||
.lineTo(x+size, y+chamfer)
|
||||
.lineTo(x+half+chamfer2, y+size-chamfer)
|
||||
.lineTo(x+half-chamfer2, y+size-chamfer)
|
||||
.lineTo(x, y+chamfer)
|
||||
.lineTo(x+chamfer, y);
|
||||
}
|
||||
}
|
||||
189
resources/app/client/pixi/core/interaction/render-flags.js
Normal file
189
resources/app/client/pixi/core/interaction/render-flags.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @typedef {Object} RenderFlag
|
||||
* @property {string[]} propagate Activating this flag also sets these flags to true
|
||||
* @property {string[]} reset Activating this flag resets these flags to false
|
||||
* @property {object} [deprecated] Is this flag deprecated? The deprecation options are passed to
|
||||
* logCompatibilityWarning. The deprectation message is auto-generated
|
||||
* unless message is passed with the options.
|
||||
* By default the message is logged only once.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A data structure for tracking a set of boolean status flags.
|
||||
* This is a restricted set which can only accept flag values which are pre-defined.
|
||||
* @param {Record<string, RenderFlag>} flags An object which defines the flags which are supported for tracking
|
||||
* @param {object} [config] Optional configuration
|
||||
* @param {RenderFlagObject} [config.object] The object which owns this RenderFlags instance
|
||||
* @param {number} [config.priority] The ticker priority at which these render flags are handled
|
||||
*/
|
||||
class RenderFlags extends Set {
|
||||
constructor(flags={}, {object, priority=PIXI.UPDATE_PRIORITY.OBJECTS}={}) {
|
||||
super([]);
|
||||
for ( const cfg of Object.values(flags) ) {
|
||||
cfg.propagate ||= [];
|
||||
cfg.reset ||= [];
|
||||
}
|
||||
Object.defineProperties(this, {
|
||||
/**
|
||||
* The flags tracked by this data structure.
|
||||
* @type {Record<string, RenderFlag>}
|
||||
*/
|
||||
flags: {value: Object.freeze(flags), enumerable: false, writable: false},
|
||||
|
||||
/**
|
||||
* The RenderFlagObject instance which owns this set of RenderFlags
|
||||
* @type {RenderFlagObject}
|
||||
*/
|
||||
object: {value: object, enumerable: false, writable: false},
|
||||
|
||||
/**
|
||||
* The update priority when these render flags are applied.
|
||||
* Valid options are OBJECTS or PERCEPTION.
|
||||
* @type {string}
|
||||
*/
|
||||
priority: {value: priority, enumerable: false, writable: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @returns {Record<string, boolean>} The flags which were previously set that have been cleared.
|
||||
*/
|
||||
clear() {
|
||||
|
||||
// Record which flags were previously active
|
||||
const flags = {};
|
||||
for ( const flag of this ) {
|
||||
flags[flag] = true;
|
||||
}
|
||||
|
||||
// Empty the set
|
||||
super.clear();
|
||||
|
||||
// Remove the object from the pending queue
|
||||
if ( this.object ) canvas.pendingRenderFlags[this.priority].delete(this.object);
|
||||
return flags;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Allow for handling one single flag at a time.
|
||||
* This function returns whether the flag needs to be handled and removes it from the pending set.
|
||||
* @param {string} flag
|
||||
* @returns {boolean}
|
||||
*/
|
||||
handle(flag) {
|
||||
const active = this.has(flag);
|
||||
this.delete(flag);
|
||||
return active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate certain flags, also toggling propagation and reset behaviors
|
||||
* @param {Record<string, boolean>} changes
|
||||
*/
|
||||
set(changes) {
|
||||
const seen = new Set();
|
||||
for ( const [flag, value] of Object.entries(changes) ) {
|
||||
this.#set(flag, value, seen);
|
||||
}
|
||||
if ( this.object ) canvas.pendingRenderFlags[this.priority].add(this.object);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Recursively set a flag.
|
||||
* This method applies propagation or reset behaviors when flags are assigned.
|
||||
* @param {string} flag
|
||||
* @param {boolean} value
|
||||
* @param {Set<string>} seen
|
||||
*/
|
||||
#set(flag, value, seen) {
|
||||
if ( seen.has(flag) || !value ) return;
|
||||
seen.add(flag);
|
||||
const cfg = this.flags[flag];
|
||||
if ( !cfg ) throw new Error(`"${flag}" is not defined as a supported RenderFlag option.`);
|
||||
if ( cfg.deprecated ) this.#logDreprecationWarning(flag);
|
||||
if ( !cfg.alias ) this.add(flag);
|
||||
for ( const r of cfg.reset ) this.delete(r);
|
||||
for ( const p of cfg.propagate ) this.#set(p, true, seen);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log the deprecation warning of the flag.
|
||||
* @param {string} flag
|
||||
*/
|
||||
#logDreprecationWarning(flag) {
|
||||
const cfg = this.flags[flag];
|
||||
if ( !cfg.deprecated ) throw new Error(`The RenderFlag "${flag}" is not deprecated`);
|
||||
let {message, ...options} = cfg.deprecated;
|
||||
if ( !message ) {
|
||||
message = `The RenderFlag "${flag}"`;
|
||||
if ( this.object ) message += ` of ${this.object.constructor.name}`;
|
||||
message += " is deprecated";
|
||||
if ( cfg.propagate.length === 0 ) message += " without replacement.";
|
||||
else if ( cfg.propagate.length === 1 ) message += ` in favor of ${cfg.propagate[0]}.`;
|
||||
else message += `. Use ${cfg.propagate.slice(0, -1).join(", ")} and/or ${cfg.propagate.at(-1)} instead.`;
|
||||
}
|
||||
options.once ??= true;
|
||||
foundry.utils.logCompatibilityWarning(message, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add RenderFlags functionality to some other object.
|
||||
* This mixin standardizes the interface for such functionality.
|
||||
* @param {typeof PIXI.DisplayObject|typeof Object} Base The base class being mixed. Normally a PIXI.DisplayObject
|
||||
* @returns {typeof RenderFlagObject} The mixed class definition
|
||||
*/
|
||||
function RenderFlagsMixin(Base) {
|
||||
return class RenderFlagObject extends Base {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.renderFlags = new RenderFlags(this.constructor.RENDER_FLAGS, {
|
||||
object: this,
|
||||
priority: this.constructor.RENDER_FLAG_PRIORITY
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the render flags used for this class.
|
||||
* @type {Record<string, RenderFlag>}
|
||||
*/
|
||||
static RENDER_FLAGS = {};
|
||||
|
||||
/**
|
||||
* The ticker priority when RenderFlags of this class are handled.
|
||||
* Valid values are OBJECTS or PERCEPTION.
|
||||
* @type {string}
|
||||
*/
|
||||
static RENDER_FLAG_PRIORITY = "OBJECTS";
|
||||
|
||||
/**
|
||||
* Status flags which are applied at render-time to update the PlaceableObject.
|
||||
* If an object defines RenderFlags, it should at least include flags for "redraw" and "refresh".
|
||||
* @type {RenderFlags}
|
||||
*/
|
||||
renderFlags;
|
||||
|
||||
/**
|
||||
* Apply any current render flags, clearing the renderFlags set.
|
||||
* Subclasses should override this method to define behavior.
|
||||
*/
|
||||
applyRenderFlags() {
|
||||
this.renderFlags.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
97
resources/app/client/pixi/core/interaction/resize-handle.js
Normal file
97
resources/app/client/pixi/core/interaction/resize-handle.js
Normal file
@@ -0,0 +1,97 @@
|
||||
class ResizeHandle extends PIXI.Graphics {
|
||||
constructor(offset, handlers={}) {
|
||||
super();
|
||||
this.offset = offset;
|
||||
this.handlers = handlers;
|
||||
this.lineStyle(4, 0x000000, 1.0).beginFill(0xFF9829, 1.0).drawCircle(0, 0, 10).endFill();
|
||||
this.cursor = "pointer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Track whether the handle is being actively used for a drag workflow
|
||||
* @type {boolean}
|
||||
*/
|
||||
active = false;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
refresh(bounds) {
|
||||
this.position.set(bounds.x + (bounds.width * this.offset[0]), bounds.y + (bounds.height * this.offset[1]));
|
||||
this.hitArea = new PIXI.Rectangle(-16, -16, 32, 32); // Make the handle easier to grab
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
updateDimensions(current, origin, destination, {aspectRatio=null}={}) {
|
||||
|
||||
// Identify the change in dimensions
|
||||
const dx = destination.x - origin.x;
|
||||
const dy = destination.y - origin.y;
|
||||
|
||||
// Determine the new width and the new height
|
||||
let width = Math.max(origin.width + dx, 24);
|
||||
let height = Math.max(origin.height + dy, 24);
|
||||
|
||||
// Constrain the aspect ratio
|
||||
if ( aspectRatio ) {
|
||||
if ( width >= height ) width = height * aspectRatio;
|
||||
else height = width / aspectRatio;
|
||||
}
|
||||
|
||||
// Adjust the final points
|
||||
return {
|
||||
x: current.x,
|
||||
y: current.y,
|
||||
width: width * Math.sign(current.width),
|
||||
height: height * Math.sign(current.height)
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interactivity */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
activateListeners() {
|
||||
this.off("pointerover").off("pointerout").off("pointerdown")
|
||||
.on("pointerover", this._onHoverIn.bind(this))
|
||||
.on("pointerout", this._onHoverOut.bind(this))
|
||||
.on("pointerdown", this._onMouseDown.bind(this));
|
||||
this.eventMode = "static";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-over event on a control handle
|
||||
* @param {PIXI.FederatedEvent} event The mouseover event
|
||||
* @protected
|
||||
*/
|
||||
_onHoverIn(event) {
|
||||
const handle = event.target;
|
||||
handle.scale.set(1.5, 1.5);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle mouse-out event on a control handle
|
||||
* @param {PIXI.FederatedEvent} event The mouseout event
|
||||
* @protected
|
||||
*/
|
||||
_onHoverOut(event) {
|
||||
const handle = event.target;
|
||||
handle.scale.set(1.0, 1.0);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When we start a drag event - create a preview copy of the Tile for re-positioning
|
||||
* @param {PIXI.FederatedEvent} event The mousedown event
|
||||
* @protected
|
||||
*/
|
||||
_onMouseDown(event) {
|
||||
if ( this.handlers.canDrag && !this.handlers.canDrag() ) return;
|
||||
this.active = true;
|
||||
}
|
||||
}
|
||||
52
resources/app/client/pixi/core/interaction/targets.js
Normal file
52
resources/app/client/pixi/core/interaction/targets.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* A subclass of Set which manages the Token ids which the User has targeted.
|
||||
* @extends {Set}
|
||||
* @see User#targets
|
||||
*/
|
||||
class UserTargets extends Set {
|
||||
constructor(user) {
|
||||
super();
|
||||
if ( user.targets ) throw new Error(`User ${user.id} already has a targets set defined`);
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Token IDs which are user targets
|
||||
* @type {string[]}
|
||||
*/
|
||||
get ids() {
|
||||
return Array.from(this).map(t => t.id);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
add(token) {
|
||||
if ( this.has(token) ) return this;
|
||||
super.add(token);
|
||||
this.#hook(token, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
clear() {
|
||||
const tokens = Array.from(this);
|
||||
super.clear();
|
||||
tokens.forEach(t => this.#hook(t, false));
|
||||
}
|
||||
|
||||
/** @override */
|
||||
delete(token) {
|
||||
if ( !this.has(token) ) return false;
|
||||
super.delete(token);
|
||||
this.#hook(token, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the targetToken hook whenever the user's target set changes.
|
||||
* @param {Token} token The targeted Token
|
||||
* @param {boolean} targeted Whether the Token has been targeted or untargeted
|
||||
*/
|
||||
#hook(token, targeted) {
|
||||
Hooks.callAll("targetToken", this.user, token, targeted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user