/** * A Note is an implementation of PlaceableObject which represents an annotated location within the Scene. * Each Note links to a JournalEntry document and represents its location on the map. * @category - Canvas * @see {@link NoteDocument} * @see {@link NotesLayer} */ class Note extends PlaceableObject { /** @inheritdoc */ static embeddedName = "Note"; /** @override */ static RENDER_FLAGS = { redraw: {propagate: ["refresh"]}, refresh: {propagate: ["refreshState", "refreshPosition", "refreshTooltip", "refreshElevation"], alias: true}, refreshState: {propagate: ["refreshVisibility"]}, refreshVisibility: {}, refreshPosition: {}, refreshTooltip: {}, refreshElevation: {propagate: ["refreshVisibility"]}, /** @deprecated since v12 */ refreshText: {propagate: ["refreshTooltip"], deprecated: {since: 12, until: 14}, alias: true} }; /* -------------------------------------------- */ /** * The control icon. * @type {ControlIcon} */ controlIcon; /* -------------------------------------------- */ /** * The tooltip. * @type {PreciseText} */ tooltip; /* -------------------------------------------- */ /** @override */ get bounds() { const {x, y, iconSize} = this.document; const r = iconSize / 2; return new PIXI.Rectangle(x - r, y - r, 2*r, 2*r); } /* -------------------------------------------- */ /** * The associated JournalEntry which is referenced by this Note * @type {JournalEntry} */ get entry() { return this.document.entry; } /* -------------------------------------------- */ /** * The specific JournalEntryPage within the associated JournalEntry referenced by this Note. */ get page() { return this.document.page; } /* -------------------------------------------- */ /** * Determine whether the Note is visible to the current user based on their perspective of the Scene. * Visibility depends on permission to the underlying journal entry, as well as the perspective of controlled Tokens. * If Token Vision is required, the user must have a token with vision over the note to see it. * @type {boolean} */ get isVisible() { const accessTest = this.document.page ?? this.document.entry; const access = accessTest?.testUserPermission(game.user, "LIMITED") ?? true; if ( (access === false) || !canvas.visibility.tokenVision || this.document.global ) return access; const point = {x: this.document.x, y: this.document.y}; const tolerance = this.document.iconSize / 4; return canvas.visibility.testVisibility(point, {tolerance, object: this}); } /* -------------------------------------------- */ /* Rendering /* -------------------------------------------- */ /** @override */ async _draw(options) { this.controlIcon = this.addChild(this._drawControlIcon()); this.tooltip = this.addChild(this._drawTooltip()); } /* -------------------------------------------- */ /** * Draw the control icon. * @returns {ControlIcon} * @protected */ _drawControlIcon() { const {texture, iconSize} = this.document; const icon = new ControlIcon({texture: texture.src, size: iconSize, tint: texture.tint}); icon.x -= (iconSize / 2); icon.y -= (iconSize / 2); return icon; } /* -------------------------------------------- */ /** * Draw the tooltip. * @returns {PreciseText} * @protected */ _drawTooltip() { const tooltip = new PreciseText(this.document.label, this._getTextStyle()); tooltip.eventMode = "none"; return tooltip; } /* -------------------------------------------- */ /** * Refresh the tooltip. * @protected */ _refreshTooltip() { this.tooltip.text = this.document.label; this.tooltip.style = this._getTextStyle(); const halfPad = (0.5 * this.document.iconSize) + 12; switch ( this.document.textAnchor ) { case CONST.TEXT_ANCHOR_POINTS.CENTER: this.tooltip.anchor.set(0.5, 0.5); this.tooltip.position.set(0, 0); break; case CONST.TEXT_ANCHOR_POINTS.BOTTOM: this.tooltip.anchor.set(0.5, 0); this.tooltip.position.set(0, halfPad); break; case CONST.TEXT_ANCHOR_POINTS.TOP: this.tooltip.anchor.set(0.5, 1); this.tooltip.position.set(0, -halfPad); break; case CONST.TEXT_ANCHOR_POINTS.LEFT: this.tooltip.anchor.set(1, 0.5); this.tooltip.position.set(-halfPad, 0); break; case CONST.TEXT_ANCHOR_POINTS.RIGHT: this.tooltip.anchor.set(0, 0.5); this.tooltip.position.set(halfPad, 0); break; } } /* -------------------------------------------- */ /** * Define a PIXI TextStyle object which is used for the tooltip displayed for this Note * @returns {PIXI.TextStyle} * @protected */ _getTextStyle() { const style = CONFIG.canvasTextStyle.clone(); // Positioning if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right"; else if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left"; // Font preferences style.fontFamily = this.document.fontFamily || CONFIG.defaultFontFamily; style.fontSize = this.document.fontSize; // Toggle stroke style depending on whether the text color is dark or light const color = this.document.textColor; style.fill = color; style.stroke = color.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF; style.strokeThickness = 4; return style; } /* -------------------------------------------- */ /* Incremental Refresh */ /* -------------------------------------------- */ /** @override */ _applyRenderFlags(flags) { if ( flags.refreshState ) this._refreshState(); if ( flags.refreshVisibility ) this._refreshVisibility(); if ( flags.refreshPosition ) this._refreshPosition(); if ( flags.refreshTooltip ) this._refreshTooltip(); if ( flags.refreshElevation ) this._refreshElevation(); } /* -------------------------------------------- */ /** * Refresh the visibility. * @protected */ _refreshVisibility() { const wasVisible = this.visible; this.visible = this.isVisible; if ( this.controlIcon ) this.controlIcon.refresh({ visible: this.visible, borderVisible: this.hover || this.layer.highlightObjects }); if ( wasVisible !== this.visible ) { this.layer.hintMapNotes(); MouseInteractionManager.emulateMoveEvent(); } } /* -------------------------------------------- */ /** * Refresh the state of the Note. Called the Note enters a different interaction state. * @protected */ _refreshState() { this.alpha = this._getTargetAlpha(); this.tooltip.visible = this.hover || this.layer.highlightObjects; this.zIndex = this.hover ? 1 : 0; } /* -------------------------------------------- */ /** * Refresh the position of the Note. Called with the coordinates change. * @protected */ _refreshPosition() { const {x, y} = this.document; if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent(); this.position.set(this.document.x, this.document.y); } /* -------------------------------------------- */ /** * Refresh the elevation of the control icon. * @protected */ _refreshElevation() { this.controlIcon.elevation = this.document.elevation; } /* -------------------------------------------- */ /* Document Event Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { super._onUpdate(changed, options, userId); // Incremental Refresh const positionChanged = ("x" in changed) || ("y" in changed); this.renderFlags.set({ redraw: ("texture" in changed) || ("iconSize" in changed), refreshVisibility: positionChanged || ["entryId", "pageId", "global"].some(k => k in changed), refreshPosition: positionChanged, refreshTooltip: ["text", "fontFamily", "fontSize", "textAnchor", "textColor", "iconSize"].some(k => k in changed), refreshElevation: "elevation" in changed }); } /* -------------------------------------------- */ /* Interactivity */ /* -------------------------------------------- */ /** @override */ _canHover(user) { return true; } /* -------------------------------------------- */ /** @override */ _canView(user) { const {entry, page} = this.document; if ( !entry ) return false; if ( game.user.isGM ) return true; if ( page?.testUserPermission(game.user, "LIMITED", {exact: true}) ) { // Special-case handling for image pages. return page.type === "image"; } const accessTest = page ?? entry; return accessTest.testUserPermission(game.user, "OBSERVER"); } /* -------------------------------------------- */ /** @override */ _canConfigure(user) { return canvas.notes.active && this.document.canUserModify(game.user, "update"); } /* -------------------------------------------- */ /** @inheritdoc */ _onClickLeft2(event) { const {entry, page} = this.document; if ( !entry ) return; const options = {}; if ( page ) { options.mode = JournalSheet.VIEW_MODES.SINGLE; options.pageId = page.id; } const allowed = Hooks.call("activateNote", this, options); if ( allowed === false ) return; if ( page?.type === "image" ) { return new ImagePopout(page.src, { uuid: page.uuid, title: page.name, caption: page.image.caption }).render(true); } entry.sheet.render(true, options); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get text() { const msg = "Note#text has been deprecated. Use Note#document#label instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.document.label; } /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get size() { const msg = "Note#size has been deprecated. Use Note#document#iconSize instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.document.iconSize; } }