/** * A class responsible for managing defined game keybinding. * Each keybinding is a string key/value pair belonging to a certain namespace and a certain store scope. * * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global * Game object as as game.keybindings. * * @see {@link Game#keybindings} * @see {@link SettingKeybindingConfig} * @see {@link KeybindingsConfig} */ class ClientKeybindings { constructor() { /** * Registered Keybinding actions * @type {Map} */ this.actions = new Map(); /** * A mapping of a string key to possible Actions that might execute off it * @type {Map} */ this.activeKeys = new Map(); /** * A stored cache of Keybind Actions Ids to Bindings * @type {Map} */ this.bindings = undefined; /** * A count of how many registered keybindings there are * @type {number} * @private */ this._registered = 0; /** * A timestamp which tracks the last time a pan operation was performed * @type {number} * @private */ this._moveTime = 0; } static MOVEMENT_DIRECTIONS = { UP: "up", LEFT: "left", DOWN: "down", RIGHT: "right" }; static ZOOM_DIRECTIONS = { IN: "in", OUT: "out" }; /** * An alias of the movement key set tracked by the keyboard * @returns {Set}> */ get moveKeys() { return game.keyboard.moveKeys; } /* -------------------------------------------- */ /** * Initializes the keybinding values for all registered actions */ initialize() { // Create the bindings mapping for all actions which have been registered this.bindings = new Map(Object.entries(game.settings.get("core", "keybindings"))); for ( let k of Array.from(this.bindings.keys()) ) { if ( !this.actions.has(k) ) this.bindings.delete(k); } // Register bindings for all actions for ( let [action, config] of this.actions) { let bindings = config.uneditable; bindings = config.uneditable.concat(this.bindings.get(action) ?? config.editable); this.bindings.set(action, bindings); } // Create a mapping of keys which trigger actions this.activeKeys = new Map(); for ( let [key, action] of this.actions ) { let bindings = this.bindings.get(key); for ( let binding of bindings ) { if ( !binding ) continue; if ( !this.activeKeys.has(binding.key) ) this.activeKeys.set(binding.key, []); let actions = this.activeKeys.get(binding.key); actions.push({ action: key, key: binding.key, name: action.name, requiredModifiers: binding.modifiers, optionalModifiers: action.reservedModifiers, onDown: action.onDown, onUp: action.onUp, precedence: action.precedence, order: action.order, repeat: action.repeat, restricted: action.restricted }); this.activeKeys.set(binding.key, actions.sort(this.constructor._compareActions)); } } } /* -------------------------------------------- */ /** * Register a new keybinding * * @param {string} namespace The namespace the Keybinding Action belongs to * @param {string} action A unique machine-readable id for the Keybinding Action * @param {KeybindingActionConfig} data Configuration for keybinding data * * @example Define a keybinding which shows a notification * ```js * game.keybindings.register("myModule", "showNotification", { * name: "My Settings Keybinding", * hint: "A description of what will occur when the Keybinding is executed.", * uneditable: [ * { * key: "Digit1", * modifiers: ["Control"] * } * ], * editable: [ * { * key: "F1" * } * ], * onDown: () => { ui.notifications.info("Pressed!") }, * onUp: () => {}, * restricted: true, // Restrict this Keybinding to gamemaster only? * reservedModifiers: ["Alt"], // On ALT, the notification is permanent instead of temporary * precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL * }); * ``` */ register(namespace, action, data) { if ( this.bindings ) throw new Error("You cannot register a Keybinding after the init hook"); if ( !namespace || !action ) throw new Error("You must specify both the namespace and action portion of the Keybinding action"); action = `${namespace}.${action}`; data.namespace = namespace; data.precedence = data.precedence ?? CONST.KEYBINDING_PRECEDENCE.NORMAL; data.order = this._registered++; data.uneditable = this.constructor._validateBindings(data.uneditable ?? []); data.editable = this.constructor._validateBindings(data.editable ?? []); data.repeat = data.repeat ?? false; data.reservedModifiers = this.constructor._validateModifiers(data.reservedModifiers ?? []); this.actions.set(action, data); } /* -------------------------------------------- */ /** * Get the current Bindings of a given namespace's Keybinding Action * * @param {string} namespace The namespace under which the setting is registered * @param {string} action The keybind action to retrieve * @returns {KeybindingActionBinding[]} * * @example Retrieve the current Keybinding Action Bindings * ```js * game.keybindings.get("myModule", "showNotification"); * ``` */ get(namespace, action) { if ( !namespace || !action ) throw new Error("You must specify both namespace and key portions of the keybind"); action = `${namespace}.${action}`; const keybind = this.actions.get(action); if ( !keybind ) throw new Error("This is not a registered keybind action"); return this.bindings.get(action) || []; } /* -------------------------------------------- */ /** * Set the editable Bindings of a Keybinding Action for a certain namespace and Action * * @param {string} namespace The namespace under which the Keybinding is registered * @param {string} action The Keybinding action to set * @param {KeybindingActionBinding[]} bindings The Bindings to assign to the Keybinding * * @example Update the current value of a keybinding * ```js * game.keybindings.set("myModule", "showNotification", [ * { * key: "F2", * modifiers: [ "CONTROL" ] * } * ]); * ``` */ async set(namespace, action, bindings) { if ( !namespace || !action ) throw new Error("You must specify both namespace and action portions of the Keybind"); action = `${namespace}.${action}`; const keybind = this.actions.get(action); if ( !keybind ) throw new Error("This is not a registered keybind"); if ( keybind.restricted && !game.user.isGM ) throw new Error("Only a GM can edit this keybind"); const mapping = game.settings.get("core", "keybindings"); // Set to default if value is undefined and return if ( bindings === undefined ) { delete mapping[action]; return game.settings.set("core", "keybindings", mapping); } bindings = this.constructor._validateBindings(bindings); // Verify no reserved Modifiers were set as Keys for ( let binding of bindings ) { if ( keybind.reservedModifiers.includes(binding.key) ) { throw new Error(game.i18n.format("KEYBINDINGS.ErrorReservedModifier", {key: binding.key})); } } // Save editable bindings to setting mapping[action] = bindings; await game.settings.set("core", "keybindings", mapping); } /* ---------------------------------------- */ /** * Reset all client keybindings back to their default configuration. */ async resetDefaults() { const setting = game.settings.settings.get("core.keybindings"); return game.settings.set("core", "keybindings", setting.default); } /* -------------------------------------------- */ /** * A helper method that, when given a value, ensures that the returned value is a standardized Binding array * @param {KeybindingActionBinding[]} values An array of keybinding assignments to be validated * @returns {KeybindingActionBinding[]} An array of keybinding assignments confirmed as valid * @private */ static _validateBindings(values) { if ( !(values instanceof Array) ) throw new Error(game.i18n.localize("KEYBINDINGS.MustBeArray")); for ( let binding of values ) { if ( !binding.key ) throw new Error("Each KeybindingActionBinding must contain a valid key designation"); if ( KeyboardManager.PROTECTED_KEYS.includes(binding.key) ) { throw new Error(game.i18n.format("KEYBINDINGS.ErrorProtectedKey", { key: binding.key })); } binding.modifiers = this._validateModifiers(binding.modifiers ?? []); } return values; } /* -------------------------------------------- */ /** * Validate that assigned modifiers are allowed * @param {string[]} keys An array of modifiers which may be valid * @returns {string[]} An array of modifiers which are confirmed as valid * @private */ static _validateModifiers(keys) { const modifiers = []; for ( let key of keys ) { if ( key in KeyboardManager.MODIFIER_KEYS ) key = KeyboardManager.MODIFIER_KEYS[key]; // backwards-compat if ( !Object.values(KeyboardManager.MODIFIER_KEYS).includes(key) ) { throw new Error(game.i18n.format("KEYBINDINGS.ErrorIllegalModifier", { key, allowed: modifiers.join(",") })); } modifiers.push(key); } return modifiers; } /* -------------------------------------------- */ /** * Compares two Keybinding Actions based on their Order * @param {KeybindingAction} a The first Keybinding Action * @param {KeybindingAction} b the second Keybinding Action * @returns {number} * @internal */ static _compareActions(a, b) { if (a.precedence === b.precedence) return a.order - b.order; return a.precedence - b.precedence; } /* ---------------------------------------- */ /* Core Keybinding Actions */ /* ---------------------------------------- */ /** * Register core keybindings. * @param {string} view The active game view * @internal */ _registerCoreKeybindings(view) { const {SHIFT, CONTROL, ALT} = KeyboardManager.MODIFIER_KEYS; // General Purpose - All Views game.keybindings.register("core", "dismiss", { name: "KEYBINDINGS.Dismiss", uneditable: [ {key: "Escape"} ], onDown: ClientKeybindings._onDismiss, precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED }); // Game View Only if ( view !== "game" ) return; game.keybindings.register("core", "cycleView", { name: "KEYBINDINGS.CycleView", editable: [ {key: "Tab"} ], onDown: ClientKeybindings._onCycleView, reservedModifiers: [SHIFT], repeat: true }); game.keybindings.register("core", "measuredRulerMovement", { name: "KEYBINDINGS.MoveAlongMeasuredRuler", editable: [ {key: "Space"} ], onDown: ClientKeybindings._onMeasuredRulerMovement, precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, reservedModifiers: [SHIFT, CONTROL] }); game.keybindings.register("core", "pause", { name: "KEYBINDINGS.Pause", restricted: true, editable: [ {key: "Space"} ], onDown: ClientKeybindings._onPause, precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED }); game.keybindings.register("core", "delete", { name: "KEYBINDINGS.Delete", uneditable: [ {key: "Delete"} ], editable: [ {key: "Backspace"} ], onDown: ClientKeybindings._onDelete }); game.keybindings.register("core", "highlight", { name: "KEYBINDINGS.Highlight", editable: [ {key: "AltLeft"}, {key: "AltRight"} ], onUp: ClientKeybindings._onHighlight, onDown: ClientKeybindings._onHighlight }); game.keybindings.register("core", "selectAll", { name: "KEYBINDINGS.SelectAll", uneditable: [ {key: "KeyA", modifiers: [CONTROL]} ], onDown: ClientKeybindings._onSelectAllObjects }); game.keybindings.register("core", "undo", { name: "KEYBINDINGS.Undo", uneditable: [ {key: "KeyZ", modifiers: [CONTROL]} ], onDown: ClientKeybindings._onUndo }); game.keybindings.register("core", "copy", { name: "KEYBINDINGS.Copy", uneditable: [ {key: "KeyC", modifiers: [CONTROL]} ], onDown: ClientKeybindings._onCopy }); game.keybindings.register("core", "paste", { name: "KEYBINDINGS.Paste", uneditable: [ {key: "KeyV", modifiers: [CONTROL]} ], onDown: ClientKeybindings._onPaste, reservedModifiers: [ALT, SHIFT] }); game.keybindings.register("core", "sendToBack", { name: "KEYBINDINGS.SendToBack", editable: [ {key: "BracketLeft"} ], onDown: ClientKeybindings.#onSendToBack }); game.keybindings.register("core", "bringToFront", { name: "KEYBINDINGS.BringToFront", editable: [ {key: "BracketRight"} ], onDown: ClientKeybindings.#onBringToFront }); game.keybindings.register("core", "target", { name: "KEYBINDINGS.Target", editable: [ {key: "KeyT"} ], onDown: ClientKeybindings._onTarget, reservedModifiers: [SHIFT] }); game.keybindings.register("core", "characterSheet", { name: "KEYBINDINGS.ToggleCharacterSheet", editable: [ {key: "KeyC"} ], onDown: ClientKeybindings._onToggleCharacterSheet, precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY }); game.keybindings.register("core", "panUp", { name: "KEYBINDINGS.PanUp", uneditable: [ {key: "ArrowUp"}, {key: "Numpad8"} ], editable: [ {key: "KeyW"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panLeft", { name: "KEYBINDINGS.PanLeft", uneditable: [ {key: "ArrowLeft"}, {key: "Numpad4"} ], editable: [ {key: "KeyA"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panDown", { name: "KEYBINDINGS.PanDown", uneditable: [ {key: "ArrowDown"}, {key: "Numpad2"} ], editable: [ {key: "KeyS"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panRight", { name: "KEYBINDINGS.PanRight", uneditable: [ {key: "ArrowRight"}, {key: "Numpad6"} ], editable: [ {key: "KeyD"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panUpLeft", { name: "KEYBINDINGS.PanUpLeft", uneditable: [ {key: "Numpad7"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panUpRight", { name: "KEYBINDINGS.PanUpRight", uneditable: [ {key: "Numpad9"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panDownLeft", { name: "KEYBINDINGS.PanDownLeft", uneditable: [ {key: "Numpad1"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "panDownRight", { name: "KEYBINDINGS.PanDownRight", uneditable: [ {key: "Numpad3"} ], onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]), reservedModifiers: [CONTROL, SHIFT], repeat: true }); game.keybindings.register("core", "zoomIn", { name: "KEYBINDINGS.ZoomIn", uneditable: [ {key: "NumpadAdd"} ], editable: [ {key: "PageUp"} ], onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.IN); }, repeat: true }); game.keybindings.register("core", "zoomOut", { name: "KEYBINDINGS.ZoomOut", uneditable: [ {key: "NumpadSubtract"} ], editable: [ {key: "PageDown"} ], onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.OUT); }, repeat: true }); for ( const number of Array.fromRange(9, 1).concat([0]) ) { game.keybindings.register("core", `executeMacro${number}`, { name: game.i18n.format("KEYBINDINGS.ExecuteMacro", { number }), editable: [{key: `Digit${number}`}], onDown: context => ClientKeybindings._onMacroExecute(context, number), precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED }); } for ( const page of Array.fromRange(5, 1) ) { game.keybindings.register("core", `swapMacroPage${page}`, { name: game.i18n.format("KEYBINDINGS.SwapMacroPage", { page }), editable: [{key: `Digit${page}`, modifiers: [ALT]}], onDown: context => ClientKeybindings._onMacroPageSwap(context, page), precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED }); } game.keybindings.register("core", "pushToTalk", { name: "KEYBINDINGS.PTTKey", editable: [{key: "Backquote"}], onDown: game.webrtc._onPTTStart.bind(game.webrtc), onUp: game.webrtc._onPTTEnd.bind(game.webrtc), precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, repeat: false }); game.keybindings.register("core", "focusChat", { name: "KEYBINDINGS.FocusChat", editable: [{key: "KeyC", modifiers: [SHIFT]}], onDown: ClientKeybindings._onFocusChat, precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY, repeat: false }); } /* -------------------------------------------- */ /** * Handle Select all action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onSelectAllObjects(context) { if ( !canvas.ready ) return false; canvas.activeLayer.controlAll(); return true; } /* -------------------------------------------- */ /** * Handle Cycle View actions * @param {KeyboardEventContext} context The context data of the event * @private */ static _onCycleView(context) { if ( !canvas.ready ) return false; // Attempt to cycle tokens, otherwise re-center the canvas if ( canvas.tokens.active ) { let cycled = canvas.tokens.cycleTokens(!context.isShift, false); if ( !cycled ) canvas.recenter(); } return true; } /* -------------------------------------------- */ /** * Handle Dismiss actions * @param {KeyboardEventContext} context The context data of the event * @private */ static async _onDismiss(context) { // Save fog of war if there are pending changes if ( canvas.ready ) canvas.fog.commit(); // Case 1 - dismiss an open context menu if (ui.context && ui.context.menu.length) { await ui.context.close(); return true; } // Case 2 - dismiss an open Tour if (Tour.tourInProgress) { Tour.activeTour.exit(); return true; } // Case 3 - close open UI windows const closingApps = []; for ( const app of Object.values(ui.windows) ) { closingApps.push(app.close({closeKey: true}).then(() => !app.rendered)); } for ( const app of foundry.applications.instances.values() ) { if ( app.hasFrame ) closingApps.push(app.close({closeKey: true}).then(() => !app.rendered)); } const closedApp = (await Promise.all(closingApps)).some(c => c); // Confirm an application actually closed if ( closedApp ) return true; // Case 4 (GM) - release controlled objects (if not in a preview) if ( game.view !== "game" ) return; if (game.user.isGM && (canvas.activeLayer instanceof PlaceablesLayer) && canvas.activeLayer.controlled.length) { if ( !canvas.activeLayer.preview?.children.length ) canvas.activeLayer.releaseAll(); return true; } // Case 5 - toggle the main menu ui.menu.toggle(); // Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog. if ( canvas.ready ) await canvas.fog.save(); return true; } /* -------------------------------------------- */ /** * Open Character sheet for current token or controlled actor * @param {KeyboardEventContext} context The context data of the event * @private */ static _onToggleCharacterSheet(context) { return game.toggleCharacterSheet(); } /* -------------------------------------------- */ /** * Handle action to target the currently hovered token. * @param {KeyboardEventContext} context The context data of the event * @private */ static _onTarget(context) { if ( !canvas.ready ) return false; const layer = canvas.activeLayer; if ( !(layer instanceof TokenLayer) ) return false; const hovered = layer.hover; if ( !hovered || hovered.document.isSecret ) return false; hovered.setTarget(!hovered.isTargeted, {releaseOthers: !context.isShift}); return true; } /* -------------------------------------------- */ /** * Handle action to send the currently controlled placeables to the back. * @param {KeyboardEventContext} context The context data of the event */ static #onSendToBack(context) { if ( !canvas.ready ) return false; return canvas.activeLayer?._sendToBackOrBringToFront(false) ?? false; } /* -------------------------------------------- */ /** * Handle action to bring the currently controlled placeables to the front. * @param {KeyboardEventContext} context The context data of the event */ static #onBringToFront(context) { if ( !canvas.ready ) return false; return canvas.activeLayer?._sendToBackOrBringToFront(true) ?? false; } /* -------------------------------------------- */ /** * Handle DELETE Keypress Events * @param {KeyboardEventContext} context The context data of the event * @private */ static _onDelete(context) { // Remove hotbar Macro if ( ui.hotbar._hover ) { game.user.assignHotbarMacro(null, ui.hotbar._hover); return true; } // Delete placeables from Canvas layer else if ( canvas.ready && ( canvas.activeLayer instanceof PlaceablesLayer ) ) { canvas.activeLayer._onDeleteKey(context.event); return true; } } /* -------------------------------------------- */ /** * Handle keyboard movement once a small delay has elapsed to allow for multiple simultaneous key-presses. * @param {KeyboardEventContext} context The context data of the event * @param {InteractionLayer} layer The active InteractionLayer instance * @private */ _handleMovement(context, layer) { if ( !this.moveKeys.size ) return; // Get controlled objects let objects = layer.placeables.filter(o => o.controlled); if ( objects.length === 0 ) return; // Get the directions of movement let directions = this.moveKeys; const grid = canvas.grid; const diagonals = (grid.type !== CONST.GRID_TYPES.SQUARE) || (grid.diagonals !== CONST.GRID_DIAGONALS.ILLEGAL); if ( !diagonals ) directions = new Set(Array.from(directions).slice(-1)); // Define movement offsets and get moved directions let dx = 0; let dy = 0; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1; if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1; // Perform the shift or rotation layer.moveMany({dx, dy, rotate: context.isShift}); } /* -------------------------------------------- */ /** * Handle panning the canvas using CTRL + directional keys */ _handleCanvasPan() { // Determine movement offsets let dx = 0; let dy = 0; if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT)) dx -= 1; if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP)) dy -= 1; if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) dx += 1; if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN)) dy += 1; // Clear the pending set this.moveKeys.clear(); // Pan by the grid size const s = canvas.dimensions.size; return canvas.animatePan({ x: canvas.stage.pivot.x + (dx * s), y: canvas.stage.pivot.y + (dy * s), duration: 100 }); } /* -------------------------------------------- */ /** * Handle Measured Ruler Movement Action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onMeasuredRulerMovement(context) { if ( !canvas.ready ) return; const ruler = canvas.controls.ruler; if ( ruler.state !== Ruler.STATES.MEASURING ) return; ruler._onMoveKeyDown(context); return true; } /* -------------------------------------------- */ /** * Handle Pause Action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onPause(context) { game.togglePause(undefined, true); return true; } /* -------------------------------------------- */ /** * Handle Highlight action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onHighlight(context) { if ( !canvas.ready ) return false; canvas.highlightObjects(!context.up); return true; } /* -------------------------------------------- */ /** * Handle Pan action * @param {KeyboardEventContext} context The context data of the event * @param {string[]} movementDirections The Directions being panned in * @private */ _onPan(context, movementDirections) { // Case 1: Check for Tour if ( (Tour.tourInProgress) && (!context.repeat) && (!context.up) ) { Tour.onMovementAction(movementDirections); return true; } // Case 2: Check for Canvas if ( !canvas.ready ) return false; // Remove Keys on Up if ( context.up ) { for ( let d of movementDirections ) { this.moveKeys.delete(d); } return true; } // Keep track of when we last moved const now = Date.now(); const delta = now - this._moveTime; // Track the movement set for ( let d of movementDirections ) { this.moveKeys.add(d); } // Handle canvas pan using CTRL if ( context.isControl ) { if ( ["KeyW", "KeyA", "KeyS", "KeyD"].includes(context.key) ) return false; this._handleCanvasPan(); return true; } // Delay 50ms before shifting tokens in order to capture diagonal movements const layer = canvas.activeLayer; if ( (layer === canvas.tokens) || (layer === canvas.tiles) ) { if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms setTimeout(() => this._handleMovement(context, layer), 50); } this._moveTime = now; return true; } /* -------------------------------------------- */ /** * Handle Macro executions * @param {KeyboardEventContext} context The context data of the event * @param {number} number The numbered macro slot to execute * @private */ static _onMacroExecute(context, number) { const slot = ui.hotbar.macros.find(m => m.key === number); if ( slot.macro ) { slot.macro.execute(); return true; } return false; } /* -------------------------------------------- */ /** * Handle Macro page swaps * @param {KeyboardEventContext} context The context data of the event * @param {number} page The numbered macro page to activate * @private */ static _onMacroPageSwap(context, page) { ui.hotbar.changePage(page); return true; } /* -------------------------------------------- */ /** * Handle action to copy data to clipboard * @param {KeyboardEventContext} context The context data of the event * @private */ static _onCopy(context) { // Case 1 - attempt a copy operation on the PlaceablesLayer if (window.getSelection().toString() !== "") return false; if ( !canvas.ready ) return false; let layer = canvas.activeLayer; if ( layer instanceof PlaceablesLayer ) layer.copyObjects(); return true; } /* -------------------------------------------- */ /** * Handle Paste action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onPaste(context ) { if ( !canvas.ready ) return false; let layer = canvas.activeLayer; if ( (layer instanceof PlaceablesLayer) && layer._copy.length ) { const pos = canvas.mousePosition; layer.pasteObjects(pos, {hidden: context.isAlt, snap: !context.isShift}); return true; } } /* -------------------------------------------- */ /** * Handle Undo action * @param {KeyboardEventContext} context The context data of the event * @private */ static _onUndo(context) { if ( !canvas.ready ) return false; // Undo history for a PlaceablesLayer const layer = canvas.activeLayer; if ( !(layer instanceof PlaceablesLayer) ) return false; layer.undoHistory(); return true; } /* -------------------------------------------- */ /** * Handle presses to keyboard zoom keys * @param {KeyboardEventContext} context The context data of the event * @param {ClientKeybindings.ZOOM_DIRECTIONS} zoomDirection The direction to zoom * @private */ static _onZoom(context, zoomDirection ) { if ( !canvas.ready ) return false; const delta = zoomDirection === ClientKeybindings.ZOOM_DIRECTIONS.IN ? 1.05 : 0.95; canvas.animatePan({scale: delta * canvas.stage.scale.x, duration: 100}); return true; } /* -------------------------------------------- */ /** * Bring the chat window into view and focus the input * @param {KeyboardEventContext} context The context data of the event * @returns {boolean} * @private */ static _onFocusChat(context) { const sidebar = ui.sidebar._element[0]; ui.sidebar.activateTab(ui.chat.tabName); // If the sidebar is collapsed and the chat popover is not visible, open it if ( sidebar.classList.contains("collapsed") && !ui.chat._popout ) { const popout = ui.chat.createPopout(); popout._render(true).then(() => { popout.element.find("#chat-message").focus(); }); } else { ui.chat.element.find("#chat-message").focus(); } return true; } }