1035 lines
32 KiB
JavaScript
1035 lines
32 KiB
JavaScript
|
|
/**
|
||
|
|
* The Drawing object is an implementation of the PlaceableObject container.
|
||
|
|
* Each Drawing is a placeable object in the DrawingsLayer.
|
||
|
|
*
|
||
|
|
* @category - Canvas
|
||
|
|
* @property {DrawingsLayer} layer Each Drawing object belongs to the DrawingsLayer
|
||
|
|
* @property {DrawingDocument} document Each Drawing object provides an interface for a DrawingDocument
|
||
|
|
*/
|
||
|
|
class Drawing extends PlaceableObject {
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The texture that is used to fill this Drawing, if any.
|
||
|
|
* @type {PIXI.Texture}
|
||
|
|
*/
|
||
|
|
texture;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The border frame and resizing handles for the drawing.
|
||
|
|
* @type {PIXI.Container}
|
||
|
|
*/
|
||
|
|
frame;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A text label that may be displayed as part of the interface layer for the Drawing.
|
||
|
|
* @type {PreciseText|null}
|
||
|
|
*/
|
||
|
|
text = null;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The drawing shape which is rendered as a PIXI.Graphics in the interface or a PrimaryGraphics in the Primary Group.
|
||
|
|
* @type {PrimaryGraphics|PIXI.Graphics}
|
||
|
|
*/
|
||
|
|
shape;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An internal timestamp for the previous freehand draw time, to limit sampling.
|
||
|
|
* @type {number}
|
||
|
|
*/
|
||
|
|
#drawTime = 0;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* An internal flag for the permanent points of the polygon.
|
||
|
|
* @type {number[]}
|
||
|
|
*/
|
||
|
|
#fixedPoints = foundry.utils.deepClone(this.document.shape.points);
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritdoc */
|
||
|
|
static embeddedName = "Drawing";
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
static RENDER_FLAGS = {
|
||
|
|
redraw: {propagate: ["refresh"]},
|
||
|
|
refresh: {propagate: ["refreshState", "refreshTransform", "refreshText", "refreshElevation"], alias: true},
|
||
|
|
refreshState: {},
|
||
|
|
refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
|
||
|
|
refreshPosition: {},
|
||
|
|
refreshRotation: {propagate: ["refreshFrame"]},
|
||
|
|
refreshSize: {propagate: ["refreshPosition", "refreshFrame", "refreshShape", "refreshText"]},
|
||
|
|
refreshShape: {},
|
||
|
|
refreshText: {},
|
||
|
|
refreshFrame: {},
|
||
|
|
refreshElevation: {},
|
||
|
|
/** @deprecated since v12 */
|
||
|
|
refreshMesh: {
|
||
|
|
propagate: ["refreshTransform", "refreshShape", "refreshElevation"],
|
||
|
|
deprecated: {since: 12, until: 14, alias: true}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The rate at which points are sampled (in milliseconds) during a freehand drawing workflow
|
||
|
|
* @type {number}
|
||
|
|
*/
|
||
|
|
static FREEHAND_SAMPLE_RATE = 75;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A convenience reference to the possible shape types.
|
||
|
|
* @enum {string}
|
||
|
|
*/
|
||
|
|
static SHAPE_TYPES = foundry.data.ShapeData.TYPES;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Properties */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A convenient reference for whether the current User is the author of the Drawing document.
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get isAuthor() {
|
||
|
|
return this.document.isAuthor;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Is this Drawing currently visible on the Canvas?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get isVisible() {
|
||
|
|
return !this.document.hidden || this.isAuthor || game.user.isGM || this.isPreview;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
get bounds() {
|
||
|
|
const {x, y, shape, rotation} = this.document;
|
||
|
|
return rotation === 0
|
||
|
|
? new PIXI.Rectangle(x, y, shape.width, shape.height).normalize()
|
||
|
|
: PIXI.Rectangle.fromRotation(x, y, shape.width, shape.height, Math.toRadians(rotation)).normalize();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
get center() {
|
||
|
|
const {x, y, shape} = this.document;
|
||
|
|
return new PIXI.Point(x + (shape.width / 2), y + (shape.height / 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A Boolean flag for whether the Drawing utilizes a tiled texture background?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get isTiled() {
|
||
|
|
return this.document.fillType === CONST.DRAWING_FILL_TYPES.PATTERN;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A Boolean flag for whether the Drawing is a Polygon type (either linear or freehand)?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get isPolygon() {
|
||
|
|
return this.type === Drawing.SHAPE_TYPES.POLYGON;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Does the Drawing have text that is displayed?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
get hasText() {
|
||
|
|
return ((this._pendingText !== undefined) || !!this.document.text) && (this.document.fontSize > 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The shape type that this Drawing represents. A value in Drawing.SHAPE_TYPES.
|
||
|
|
* @see {@link Drawing.SHAPE_TYPES}
|
||
|
|
* @type {string}
|
||
|
|
*/
|
||
|
|
get type() {
|
||
|
|
return this.document.shape.type;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The pending text.
|
||
|
|
* @type {string}
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_pendingText;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The registered keydown listener.
|
||
|
|
* @type {Function|null}
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_onkeydown = null;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Delete the Drawing if the text is empty once text editing ends?
|
||
|
|
* @type {boolean}
|
||
|
|
*/
|
||
|
|
#deleteIfEmptyText = false;
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Initial Rendering */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_destroy(options) {
|
||
|
|
this.#removeDrawing(this);
|
||
|
|
this.texture?.destroy();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
async _draw(options) {
|
||
|
|
// Load the background texture, if one is defined
|
||
|
|
const texture = this.document.texture;
|
||
|
|
if ( this._original ) this.texture = this._original.texture?.clone();
|
||
|
|
else this.texture = texture ? await loadTexture(texture, {fallback: "icons/svg/hazard.svg"}) : null;
|
||
|
|
|
||
|
|
// Create the drawing container in the primary group or in the interface group
|
||
|
|
this.shape = this.#addDrawing();
|
||
|
|
this.shape.visible = true;
|
||
|
|
|
||
|
|
// Control Border
|
||
|
|
this.frame = this.addChild(this.#drawFrame());
|
||
|
|
|
||
|
|
// Drawing text
|
||
|
|
this.text = this.hasText ? this.shape.addChild(this.#drawText()) : null;
|
||
|
|
|
||
|
|
// Interactivity
|
||
|
|
this.cursor = this.document.isOwner ? "pointer" : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add a drawing object according to interface configuration.
|
||
|
|
* @returns {PIXI.Graphics|PrimaryGraphics}
|
||
|
|
*/
|
||
|
|
#addDrawing() {
|
||
|
|
const targetGroup = this.document.interface ? canvas.interface : canvas.primary;
|
||
|
|
const removeGroup = this.document.interface ? canvas.primary : canvas.interface;
|
||
|
|
removeGroup.removeDrawing(this);
|
||
|
|
return targetGroup.addDrawing(this);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove a drawing object.
|
||
|
|
*/
|
||
|
|
#removeDrawing() {
|
||
|
|
canvas.interface.removeDrawing(this);
|
||
|
|
canvas.primary.removeDrawing(this);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create elements for the Drawing border and handles
|
||
|
|
* @returns {PIXI.Container}
|
||
|
|
*/
|
||
|
|
#drawFrame() {
|
||
|
|
const frame = new PIXI.Container();
|
||
|
|
frame.eventMode = "passive";
|
||
|
|
frame.bounds = new PIXI.Rectangle();
|
||
|
|
frame.interaction = frame.addChild(new PIXI.Container());
|
||
|
|
frame.interaction.hitArea = frame.bounds;
|
||
|
|
frame.interaction.eventMode = "auto";
|
||
|
|
frame.border = frame.addChild(new PIXI.Graphics());
|
||
|
|
frame.border.eventMode = "none";
|
||
|
|
frame.handle = frame.addChild(new ResizeHandle([1, 1]));
|
||
|
|
frame.handle.eventMode = "static";
|
||
|
|
return frame;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a PreciseText element to be displayed as part of this drawing.
|
||
|
|
* @returns {PreciseText}
|
||
|
|
*/
|
||
|
|
#drawText() {
|
||
|
|
const text = new PreciseText(this.document.text || "", this._getTextStyle());
|
||
|
|
text.eventMode = "none";
|
||
|
|
return text;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the line style used for drawing the shape of this Drawing.
|
||
|
|
* @returns {object} The line style options (`PIXI.ILineStyleOptions`).
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getLineStyle() {
|
||
|
|
const {strokeWidth, strokeColor, strokeAlpha} = this.document;
|
||
|
|
return {width: strokeWidth, color: strokeColor, alpha: strokeAlpha};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the fill style used for drawing the shape of this Drawing.
|
||
|
|
* @returns {object} The fill style options (`PIXI.IFillStyleOptions`).
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getFillStyle() {
|
||
|
|
const {fillType, fillColor, fillAlpha} = this.document;
|
||
|
|
const style = {color: fillColor, alpha: fillAlpha};
|
||
|
|
if ( (fillType === CONST.DRAWING_FILL_TYPES.PATTERN) && this.texture?.valid ) style.texture = this.texture;
|
||
|
|
else if ( !fillType ) style.alpha = 0;
|
||
|
|
return style;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Prepare the text style used to instantiate a PIXI.Text or PreciseText instance for this Drawing document.
|
||
|
|
* @returns {PIXI.TextStyle}
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_getTextStyle() {
|
||
|
|
const {fontSize, fontFamily, textColor, shape} = this.document;
|
||
|
|
const stroke = Math.max(Math.round(fontSize / 32), 2);
|
||
|
|
return PreciseText.getTextStyle({
|
||
|
|
fontFamily: fontFamily,
|
||
|
|
fontSize: fontSize,
|
||
|
|
fill: textColor,
|
||
|
|
strokeThickness: stroke,
|
||
|
|
dropShadowBlur: Math.max(Math.round(fontSize / 16), 2),
|
||
|
|
align: "center",
|
||
|
|
wordWrap: true,
|
||
|
|
wordWrapWidth: shape.width,
|
||
|
|
padding: stroke * 4
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
clone() {
|
||
|
|
const c = super.clone();
|
||
|
|
c._pendingText = this._pendingText;
|
||
|
|
return c;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Incremental Refresh */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_applyRenderFlags(flags) {
|
||
|
|
if ( flags.refreshState ) this._refreshState();
|
||
|
|
if ( flags.refreshPosition ) this._refreshPosition();
|
||
|
|
if ( flags.refreshRotation ) this._refreshRotation();
|
||
|
|
if ( flags.refreshShape ) this._refreshShape();
|
||
|
|
if ( flags.refreshText ) this._refreshText();
|
||
|
|
if ( flags.refreshFrame ) this._refreshFrame();
|
||
|
|
if ( flags.refreshElevation ) this._refreshElevation();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh the position.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshPosition() {
|
||
|
|
const {x, y, shape: {width, height}} = this.document;
|
||
|
|
if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
|
||
|
|
this.position.set(x, y);
|
||
|
|
this.shape.position.set(x + (width / 2), y + (height / 2));
|
||
|
|
this.shape.pivot.set(width / 2, height / 2);
|
||
|
|
if ( !this.text ) return;
|
||
|
|
this.text.position.set(width / 2, height / 2);
|
||
|
|
this.text.anchor.set(0.5, 0.5);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh the rotation.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshRotation() {
|
||
|
|
const rotation = Math.toRadians(this.document.rotation);
|
||
|
|
this.shape.rotation = rotation;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh the displayed state of the Drawing.
|
||
|
|
* Used to update aspects of the Drawing which change based on the user interaction state.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshState() {
|
||
|
|
const {hidden, locked, sort} = this.document;
|
||
|
|
const wasVisible = this.visible;
|
||
|
|
this.visible = this.isVisible;
|
||
|
|
if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
|
||
|
|
this.alpha = this._getTargetAlpha();
|
||
|
|
const colors = CONFIG.Canvas.dispositionColors;
|
||
|
|
this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE;
|
||
|
|
this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
|
||
|
|
this.frame.handle.visible = this.controlled && !locked;
|
||
|
|
this.zIndex = this.shape.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
|
||
|
|
const oldEventMode = this.eventMode;
|
||
|
|
this.eventMode = this.layer.active && (this.controlled || ["select", "text"].includes(game.activeTool)) ? "static" : "none";
|
||
|
|
if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
|
||
|
|
this.shape.visible = this.visible;
|
||
|
|
this.shape.sort = sort;
|
||
|
|
this.shape.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.DRAWINGS;
|
||
|
|
this.shape.alpha = this.alpha * (hidden ? 0.5 : 1);
|
||
|
|
this.shape.hidden = hidden;
|
||
|
|
if ( !this.text ) return;
|
||
|
|
this.text.alpha = this.document.textAlpha;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Clear and then draw the shape.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshShape() {
|
||
|
|
this.shape.clear();
|
||
|
|
this.shape.lineStyle(this._getLineStyle());
|
||
|
|
this.shape.beginTextureFill(this._getFillStyle());
|
||
|
|
const lineWidth = this.shape.line.width;
|
||
|
|
const shape = this.document.shape;
|
||
|
|
switch ( shape.type ) {
|
||
|
|
case Drawing.SHAPE_TYPES.RECTANGLE:
|
||
|
|
this.shape.drawRect(
|
||
|
|
lineWidth / 2,
|
||
|
|
lineWidth / 2,
|
||
|
|
Math.max(shape.width - lineWidth, 0),
|
||
|
|
Math.max(shape.height - lineWidth, 0)
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
case Drawing.SHAPE_TYPES.ELLIPSE:
|
||
|
|
this.shape.drawEllipse(
|
||
|
|
shape.width / 2,
|
||
|
|
shape.height / 2,
|
||
|
|
Math.max(shape.width - lineWidth, 0) / 2,
|
||
|
|
Math.max(shape.height - lineWidth, 0) / 2
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
case Drawing.SHAPE_TYPES.POLYGON:
|
||
|
|
const isClosed = this.document.fillType || (shape.points.slice(0, 2).equals(shape.points.slice(-2)));
|
||
|
|
if ( isClosed ) this.shape.drawSmoothedPolygon(shape.points, this.document.bezierFactor * 2);
|
||
|
|
else this.shape.drawSmoothedPath(shape.points, this.document.bezierFactor * 2);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
this.shape.endFill();
|
||
|
|
this.shape.line.reset();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update sorting of this Drawing relative to other PrimaryCanvasGroup siblings.
|
||
|
|
* Called when the elevation or sort order for the Drawing changes.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshElevation() {
|
||
|
|
this.shape.elevation = this.document.elevation;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh the border frame that encloses the Drawing.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshFrame() {
|
||
|
|
const thickness = CONFIG.Canvas.objectBorderThickness;
|
||
|
|
|
||
|
|
// Update the frame bounds
|
||
|
|
const {shape: {width, height}, rotation} = this.document;
|
||
|
|
const bounds = this.frame.bounds;
|
||
|
|
bounds.x = 0;
|
||
|
|
bounds.y = 0;
|
||
|
|
bounds.width = width;
|
||
|
|
bounds.height = height;
|
||
|
|
bounds.rotate(Math.toRadians(rotation));
|
||
|
|
const minSize = thickness * 0.25;
|
||
|
|
if ( bounds.width < minSize ) {
|
||
|
|
bounds.x -= ((minSize - bounds.width) / 2);
|
||
|
|
bounds.width = minSize;
|
||
|
|
}
|
||
|
|
if ( bounds.height < minSize ) {
|
||
|
|
bounds.y -= ((minSize - bounds.height) / 2);
|
||
|
|
bounds.height = minSize;
|
||
|
|
}
|
||
|
|
MouseInteractionManager.emulateMoveEvent();
|
||
|
|
|
||
|
|
// Draw the border
|
||
|
|
const border = this.frame.border;
|
||
|
|
border.clear();
|
||
|
|
border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75})
|
||
|
|
.drawShape(bounds);
|
||
|
|
border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1})
|
||
|
|
.drawShape(bounds);
|
||
|
|
|
||
|
|
// Draw the handle
|
||
|
|
this.frame.handle.refresh(bounds);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh the content and appearance of text.
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_refreshText() {
|
||
|
|
if ( !this.text ) return;
|
||
|
|
const {text, textAlpha} = this.document;
|
||
|
|
this.text.text = this._pendingText ?? text ?? "";
|
||
|
|
this.text.alpha = textAlpha;
|
||
|
|
this.text.style = this._getTextStyle();
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Interactivity */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Add a new polygon point to the drawing, ensuring it differs from the last one
|
||
|
|
* @param {Point} position The drawing point to add
|
||
|
|
* @param {object} [options] Options which configure how the point is added
|
||
|
|
* @param {boolean} [options.round=false] Should the point be rounded to integer coordinates?
|
||
|
|
* @param {boolean} [options.snap=false] Should the point be snapped to grid precision?
|
||
|
|
* @param {boolean} [options.temporary=false] Is this a temporary control point?
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_addPoint(position, {round=false, snap=false, temporary=false}={}) {
|
||
|
|
if ( snap ) position = this.layer.getSnappedPoint(position);
|
||
|
|
if ( round ) {
|
||
|
|
position.x = Math.round(position.x);
|
||
|
|
position.y = Math.round(position.y);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Avoid adding duplicate points
|
||
|
|
const last = this.#fixedPoints.slice(-2);
|
||
|
|
const next = [position.x - this.document.x, position.y - this.document.y];
|
||
|
|
if ( next.equals(last) ) return;
|
||
|
|
|
||
|
|
// Append the new point and update the shape
|
||
|
|
const points = this.#fixedPoints.concat(next);
|
||
|
|
this.document.shape.updateSource({points});
|
||
|
|
if ( !temporary ) {
|
||
|
|
this.#fixedPoints = points;
|
||
|
|
this.#drawTime = Date.now();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Remove the last fixed point from the polygon
|
||
|
|
* @internal
|
||
|
|
*/
|
||
|
|
_removePoint() {
|
||
|
|
this.#fixedPoints.splice(-2);
|
||
|
|
this.document.shape.updateSource({points: this.#fixedPoints});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onControl(options) {
|
||
|
|
super._onControl(options);
|
||
|
|
this.enableTextEditing(options);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onRelease(options) {
|
||
|
|
super._onRelease(options);
|
||
|
|
if ( this._onkeydown ) {
|
||
|
|
document.removeEventListener("keydown", this._onkeydown);
|
||
|
|
this._onkeydown = null;
|
||
|
|
}
|
||
|
|
if ( canvas.scene.drawings.has(this.id) ) {
|
||
|
|
if ( (this._pendingText === "") && this.#deleteIfEmptyText ) this.document.delete();
|
||
|
|
else if ( this._pendingText !== undefined ) { // Submit pending text
|
||
|
|
this.#deleteIfEmptyText = false;
|
||
|
|
this.document.update({text: this._pendingText}).then(() => {
|
||
|
|
this._pendingText = undefined;
|
||
|
|
this.renderFlags.set({redraw: this.hasText === !this.text, refreshText: true});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_overlapsSelection(rectangle) {
|
||
|
|
if ( !this.frame ) return false;
|
||
|
|
const localRectangle = new PIXI.Rectangle(
|
||
|
|
rectangle.x - this.document.x,
|
||
|
|
rectangle.y - this.document.y,
|
||
|
|
rectangle.width,
|
||
|
|
rectangle.height
|
||
|
|
);
|
||
|
|
return localRectangle.overlaps(this.frame.bounds);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Enable text editing for this drawing.
|
||
|
|
* @param {object} [options]
|
||
|
|
*/
|
||
|
|
enableTextEditing(options={}) {
|
||
|
|
if ( (game.activeTool === "text") || options.forceTextEditing ) {
|
||
|
|
this._pendingText = this.document.text || "";
|
||
|
|
this._onkeydown = this.#onDrawingTextKeydown.bind(this);
|
||
|
|
document.addEventListener("keydown", this._onkeydown);
|
||
|
|
if ( options.isNew ) this.#deleteIfEmptyText = true;
|
||
|
|
this.renderFlags.set({refreshPosition: !this.text, refreshText: true});
|
||
|
|
this.text ??= this.shape.addChild(this.#drawText());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle text entry in an active text tool
|
||
|
|
* @param {KeyboardEvent} event
|
||
|
|
*/
|
||
|
|
#onDrawingTextKeydown(event) {
|
||
|
|
|
||
|
|
// Ignore events when an input is focused, or when ALT or CTRL modifiers are applied
|
||
|
|
if ( event.altKey || event.ctrlKey || event.metaKey ) return;
|
||
|
|
if ( game.keyboard.hasFocus ) return;
|
||
|
|
|
||
|
|
// Track refresh or conclusion conditions
|
||
|
|
let conclude = false;
|
||
|
|
let refresh = false;
|
||
|
|
|
||
|
|
// Enter (submit) or Escape (cancel)
|
||
|
|
if ( ["Escape", "Enter"].includes(event.key) ) {
|
||
|
|
conclude = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deleting a character
|
||
|
|
else if ( event.key === "Backspace" ) {
|
||
|
|
this._pendingText = this._pendingText.slice(0, -1);
|
||
|
|
refresh = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Typing text (any single char)
|
||
|
|
else if ( /^.$/.test(event.key) ) {
|
||
|
|
this._pendingText += event.key;
|
||
|
|
refresh = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stop propagation if the event was handled
|
||
|
|
if ( refresh || conclude ) {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Conclude the workflow
|
||
|
|
if ( conclude ) {
|
||
|
|
this.release();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh the display
|
||
|
|
else if ( refresh ) {
|
||
|
|
this.renderFlags.set({refreshText: true});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Document Event Handlers */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onUpdate(changed, options, userId) {
|
||
|
|
super._onUpdate(changed, options, userId);
|
||
|
|
|
||
|
|
// Update pending text
|
||
|
|
if ( ("text" in changed) && (this._pendingText !== undefined) ) this._pendingText = this.document.text || "";
|
||
|
|
|
||
|
|
// Sort the interface drawings container if necessary
|
||
|
|
if ( this.shape?.parent && (("elevation" in changed) || ("sort" in changed)) ) this.shape.parent.sortDirty = true;
|
||
|
|
|
||
|
|
// Refresh the Tile
|
||
|
|
this.renderFlags.set({
|
||
|
|
redraw: ("interface" in changed) || ("texture" in changed) || (("text" in changed) && (this.hasText === !this.text)),
|
||
|
|
refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed),
|
||
|
|
refreshPosition: ("x" in changed) || ("y" in changed),
|
||
|
|
refreshRotation: "rotation" in changed,
|
||
|
|
refreshSize: ("shape" in changed) && (("width" in changed.shape) || ("height" in changed.shape)),
|
||
|
|
refreshElevation: "elevation" in changed,
|
||
|
|
refreshShape: ["shape", "bezierFactor", "strokeWidth", "strokeColor", "strokeAlpha",
|
||
|
|
"fillType", "fillColor", "fillAlpha"].some(k => k in changed),
|
||
|
|
refreshText: ["text", "fontFamily", "fontSize", "textColor", "textAlpha"].some(k => k in changed)
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onDelete(options, userId) {
|
||
|
|
super._onDelete(options, userId);
|
||
|
|
if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Interactivity */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
activateListeners() {
|
||
|
|
super.activateListeners();
|
||
|
|
this.frame.handle.off("pointerover").off("pointerout")
|
||
|
|
.on("pointerover", this._onHandleHoverIn.bind(this))
|
||
|
|
.on("pointerout", this._onHandleHoverOut.bind(this));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_canControl(user, event) {
|
||
|
|
if ( !this.layer.active || this.isPreview ) return false;
|
||
|
|
if ( this._creating ) { // Allow one-time control immediately following creation
|
||
|
|
delete this._creating;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
if ( this.controlled ) return true;
|
||
|
|
if ( !["select", "text"].includes(game.activeTool) ) return false;
|
||
|
|
return user.isGM || (user === this.document.author);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_canConfigure(user, event) {
|
||
|
|
return this.controlled;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mouse movement which modifies the dimensions of the drawn shape.
|
||
|
|
* @param {PIXI.FederatedEvent} event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onMouseDraw(event) {
|
||
|
|
const {destination, origin} = event.interactionData;
|
||
|
|
const isShift = event.shiftKey;
|
||
|
|
const isAlt = event.altKey;
|
||
|
|
let position = destination;
|
||
|
|
|
||
|
|
// Drag differently depending on shape type
|
||
|
|
switch ( this.type ) {
|
||
|
|
|
||
|
|
// Polygon Shapes
|
||
|
|
case Drawing.SHAPE_TYPES.POLYGON:
|
||
|
|
const isFreehand = game.activeTool === "freehand";
|
||
|
|
let temporary = true;
|
||
|
|
if ( isFreehand ) {
|
||
|
|
const now = Date.now();
|
||
|
|
temporary = (now - this.#drawTime) < this.constructor.FREEHAND_SAMPLE_RATE;
|
||
|
|
}
|
||
|
|
const snap = !(isShift || isFreehand);
|
||
|
|
this._addPoint(position, {snap, temporary});
|
||
|
|
break;
|
||
|
|
|
||
|
|
// Other Shapes
|
||
|
|
default:
|
||
|
|
if ( !isShift ) position = this.layer.getSnappedPoint(position);
|
||
|
|
const shape = this.document.shape;
|
||
|
|
const minSize = canvas.dimensions.size * 0.5;
|
||
|
|
let dx = position.x - origin.x;
|
||
|
|
let dy = position.y - origin.y;
|
||
|
|
if ( Math.abs(dx) < minSize ) dx = minSize * Math.sign(shape.width);
|
||
|
|
if ( Math.abs(dy) < minSize ) dy = minSize * Math.sign(shape.height);
|
||
|
|
if ( isAlt ) {
|
||
|
|
dx = Math.abs(dy) < Math.abs(dx) ? Math.abs(dy) * Math.sign(dx) : dx;
|
||
|
|
dy = Math.abs(dx) < Math.abs(dy) ? Math.abs(dx) * Math.sign(dy) : dy;
|
||
|
|
}
|
||
|
|
const r = new PIXI.Rectangle(origin.x, origin.y, dx, dy).normalize();
|
||
|
|
this.document.updateSource({
|
||
|
|
x: r.x,
|
||
|
|
y: r.y,
|
||
|
|
shape: {
|
||
|
|
width: r.width,
|
||
|
|
height: r.height
|
||
|
|
}
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh the display
|
||
|
|
this.renderFlags.set({refreshPosition: true, refreshSize: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Interactivity */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritdoc */
|
||
|
|
_onClickLeft(event) {
|
||
|
|
if ( event.target === this.frame.handle ) {
|
||
|
|
event.interactionData.dragHandle = true;
|
||
|
|
event.stopPropagation();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
return super._onClickLeft(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_onDragLeftStart(event) {
|
||
|
|
if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event);
|
||
|
|
return super._onDragLeftStart(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_onDragLeftMove(event) {
|
||
|
|
if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event);
|
||
|
|
return super._onDragLeftMove(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @override */
|
||
|
|
_onDragLeftDrop(event) {
|
||
|
|
if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event);
|
||
|
|
return super._onDragLeftDrop(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/** @inheritDoc */
|
||
|
|
_onDragLeftCancel(event) {
|
||
|
|
if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event);
|
||
|
|
return super._onDragLeftCancel(event);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
/* Resize Handling */
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mouse-over event on a control handle
|
||
|
|
* @param {PIXI.FederatedEvent} event The mouseover event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onHandleHoverIn(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
|
||
|
|
*/
|
||
|
|
_onHandleHoverOut(event) {
|
||
|
|
const handle = event.target;
|
||
|
|
handle?.scale.set(1.0, 1.0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Starting the resize handle drag event, initialize the original data.
|
||
|
|
* @param {PIXI.FederatedEvent} event The mouse interaction event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onHandleDragStart(event) {
|
||
|
|
event.interactionData.originalData = this.document.toObject();
|
||
|
|
const handle = this.frame.handle;
|
||
|
|
event.interactionData.handleOrigin = {x: handle.position.x, y: handle.position.y};
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mousemove while dragging a tile scale handler
|
||
|
|
* @param {PIXI.FederatedEvent} event The mouse interaction event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onHandleDragMove(event) {
|
||
|
|
|
||
|
|
// Pan the canvas if the drag event approaches the edge
|
||
|
|
canvas._onDragCanvasPan(event);
|
||
|
|
|
||
|
|
// Update Drawing dimensions
|
||
|
|
const {destination, origin, handleOrigin, originalData} = event.interactionData;
|
||
|
|
let handleDestination = {
|
||
|
|
x: handleOrigin.x + (destination.x - origin.x),
|
||
|
|
y: handleOrigin.y + (destination.y - origin.y)
|
||
|
|
};
|
||
|
|
if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination);
|
||
|
|
const dx = handleDestination.x - handleOrigin.x;
|
||
|
|
const dy = handleDestination.y - handleOrigin.y;
|
||
|
|
const normalized = Drawing.rescaleDimensions(originalData, dx, dy);
|
||
|
|
|
||
|
|
// Update the drawing, catching any validation failures
|
||
|
|
this.document.updateSource(normalized);
|
||
|
|
this.document.rotation = 0;
|
||
|
|
this.renderFlags.set({refreshTransform: true});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle mouseup after dragging a tile scale handler
|
||
|
|
* @param {PIXI.FederatedEvent} event The mouseup event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onHandleDragDrop(event) {
|
||
|
|
event.interactionData.restoreOriginalData = false;
|
||
|
|
const {destination, origin, handleOrigin, originalData} = event.interactionData;
|
||
|
|
let handleDestination = {
|
||
|
|
x: handleOrigin.x + (destination.x - origin.x),
|
||
|
|
y: handleOrigin.y + (destination.y - origin.y)
|
||
|
|
};
|
||
|
|
if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination);
|
||
|
|
const dx = handleDestination.x - handleOrigin.x;
|
||
|
|
const dy = handleDestination.y - handleOrigin.y;
|
||
|
|
const update = Drawing.rescaleDimensions(originalData, dx, dy);
|
||
|
|
this.document.update(update, {diff: false})
|
||
|
|
.then(() => this.renderFlags.set({refreshTransform: true}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle cancellation of a drag event for one of the resizing handles
|
||
|
|
* @param {PointerEvent} event The drag cancellation event
|
||
|
|
* @protected
|
||
|
|
*/
|
||
|
|
_onHandleDragCancel(event) {
|
||
|
|
if ( event.interactionData.restoreOriginalData !== false ) {
|
||
|
|
this.document.updateSource(event.interactionData.originalData);
|
||
|
|
this.renderFlags.set({refreshTransform: true});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a vectorized rescaling transformation for drawing data and dimensions passed in parameter
|
||
|
|
* @param {Object} original The original drawing data
|
||
|
|
* @param {number} dx The pixel distance dragged in the horizontal direction
|
||
|
|
* @param {number} dy The pixel distance dragged in the vertical direction
|
||
|
|
* @returns {object} The adjusted shape data
|
||
|
|
*/
|
||
|
|
static rescaleDimensions(original, dx, dy) {
|
||
|
|
let {type, points, width, height} = original.shape;
|
||
|
|
width += dx;
|
||
|
|
height += dy;
|
||
|
|
points = points || [];
|
||
|
|
|
||
|
|
// Rescale polygon points
|
||
|
|
if ( type === Drawing.SHAPE_TYPES.POLYGON ) {
|
||
|
|
const scaleX = 1 + (original.shape.width > 0 ? dx / original.shape.width : 0);
|
||
|
|
const scaleY = 1 + (original.shape.height > 0 ? dy / original.shape.height : 0);
|
||
|
|
points = points.map((p, i) => p * (i % 2 ? scaleY : scaleX));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Normalize the shape
|
||
|
|
return this.normalizeShape({
|
||
|
|
x: original.x,
|
||
|
|
y: original.y,
|
||
|
|
shape: {width: Math.round(width), height: Math.round(height), points}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* -------------------------------------------- */
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Adjust the location, dimensions, and points of the Drawing before committing the change.
|
||
|
|
* @param {object} data The DrawingData pending update
|
||
|
|
* @returns {object} The adjusted data
|
||
|
|
*/
|
||
|
|
static normalizeShape(data) {
|
||
|
|
|
||
|
|
// Adjust shapes with an explicit points array
|
||
|
|
const rawPoints = data.shape.points;
|
||
|
|
if ( rawPoints?.length ) {
|
||
|
|
|
||
|
|
// Organize raw points and de-dupe any points which repeated in sequence
|
||
|
|
const xs = [];
|
||
|
|
const ys = [];
|
||
|
|
for ( let i=1; i<rawPoints.length; i+=2 ) {
|
||
|
|
const x0 = rawPoints[i-3];
|
||
|
|
const y0 = rawPoints[i-2];
|
||
|
|
const x1 = rawPoints[i-1];
|
||
|
|
const y1 = rawPoints[i];
|
||
|
|
if ( (x1 === x0) && (y1 === y0) ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
xs.push(x1);
|
||
|
|
ys.push(y1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Determine minimal and maximal points
|
||
|
|
const minX = Math.min(...xs);
|
||
|
|
const maxX = Math.max(...xs);
|
||
|
|
const minY = Math.min(...ys);
|
||
|
|
const maxY = Math.max(...ys);
|
||
|
|
|
||
|
|
// Normalize points relative to minX and minY
|
||
|
|
const points = [];
|
||
|
|
for ( let i=0; i<xs.length; i++ ) {
|
||
|
|
points.push(xs[i] - minX, ys[i] - minY);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update data
|
||
|
|
data.x += minX;
|
||
|
|
data.y += minY;
|
||
|
|
data.shape.width = maxX - minX;
|
||
|
|
data.shape.height = maxY - minY;
|
||
|
|
data.shape.points = points;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Adjust rectangles
|
||
|
|
else {
|
||
|
|
const normalized = new PIXI.Rectangle(data.x, data.y, data.shape.width, data.shape.height).normalize();
|
||
|
|
data.x = normalized.x;
|
||
|
|
data.y = normalized.y;
|
||
|
|
data.shape.width = normalized.width;
|
||
|
|
data.shape.height = normalized.height;
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
}
|