diff --git a/GDJS/Runtime/InGameEditor/InGameEditor.tsx b/GDJS/Runtime/InGameEditor/InGameEditor.tsx index 3cc38a72e105..494699aae72b 100644 --- a/GDJS/Runtime/InGameEditor/InGameEditor.tsx +++ b/GDJS/Runtime/InGameEditor/InGameEditor.tsx @@ -337,15 +337,7 @@ namespace gdjs { const RIGHT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 2); const LEFT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(91, 1); const RIGHT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(93, 2); - const W_KEY = 87; - const A_KEY = 65; const C_KEY = 67; - const S_KEY = 83; - const D_KEY = 68; - const Q_KEY = 81; - const E_KEY = 69; - const F_KEY = 70; - const O_KEY = 79; const V_KEY = 86; const X_KEY = 88; const Y_KEY = 89; @@ -353,11 +345,151 @@ namespace gdjs { const ESC_KEY = 27; const EQUAL_KEY = 187; const MINUS_KEY = 189; - const KEY_DIGIT_1 = 49; - const KEY_DIGIT_2 = 50; - const KEY_DIGIT_3 = 51; const ROTATION_SNAP_DEGREES = 45; + /** + * The key codes of the keys that can be used in a shortcut, by the name of + * the key in the IDE shortcuts (the `code` of the keyboard events). + */ + const shortcutKeyCodes: { [code: string]: integer } = { + Tab: 9, + Space: 32, + Comma: 188, + Equal: 187, + Minus: 189, + // The input manager stores the numpad keys with their location. + NumpadAdd: gdjs.InputManager.getLocationAwareKeyCode(107, 3), + NumpadSubtract: gdjs.InputManager.getLocationAwareKeyCode(109, 3), + }; + for (let letterIndex = 0; letterIndex < 26; letterIndex++) { + shortcutKeyCodes['Key' + String.fromCharCode(65 + letterIndex)] = + 65 + letterIndex; + } + for (let digit = 0; digit <= 9; digit++) { + shortcutKeyCodes['Digit' + digit] = 48 + digit; + } + for (let functionKeyIndex = 1; functionKeyIndex <= 12; functionKeyIndex++) { + shortcutKeyCodes['F' + functionKeyIndex] = 111 + functionKeyIndex; + } + + /** + * A shortcut parsed from the IDE format (for example "CmdOrCtrl+Shift+KeyW"): + * the key code of the key and the modifiers that must be pressed with it. + */ + type ParsedShortcut = { + keyCode: integer; + ctrlOrCmd: boolean; + shift: boolean; + alt: boolean; + }; + + const parseShortcut = (shortcutString: string): ParsedShortcut | null => { + if (!shortcutString) return null; + + const parsedShortcut: ParsedShortcut = { + keyCode: 0, + ctrlOrCmd: false, + shift: false, + alt: false, + }; + for (const shortcutPart of shortcutString.split('+')) { + if (shortcutPart === 'CmdOrCtrl') parsedShortcut.ctrlOrCmd = true; + else if (shortcutPart === 'Shift') parsedShortcut.shift = true; + else if (shortcutPart === 'Alt') parsedShortcut.alt = true; + else if (shortcutKeyCodes[shortcutPart] !== undefined) + parsedShortcut.keyCode = shortcutKeyCodes[shortcutPart]; + else return null; + } + return parsedShortcut.keyCode ? parsedShortcut : null; + }; + + /** + * The keyboard shortcuts of the in-game editor, sent by the IDE (which owns + * their default values, see `DefaultShortcuts.js`) and customizable there. + * The keys of the shortcuts are checked with the input manager of the game. + */ + class InGameEditorShortcuts { + private _parsedShortcuts: { [commandName: string]: ParsedShortcut } = {}; + + /** + * Set the shortcuts sent by the IDE. A command that is not listed, or + * whose shortcut can't be parsed, has no shortcut. + */ + update(shortcuts: { [commandName: string]: string }): void { + this._parsedShortcuts = {}; + for (const commandName in shortcuts) { + const parsedShortcut = parseShortcut(shortcuts[commandName]); + if (parsedShortcut) this._parsedShortcuts[commandName] = parsedShortcut; + } + } + + private _areModifiersMatching( + inputManager: gdjs.InputManager, + parsedShortcut: ParsedShortcut, + ignoreShift: boolean + ): boolean { + return ( + isControlOrCmdPressed(inputManager) === parsedShortcut.ctrlOrCmd && + isAltPressed(inputManager) === parsedShortcut.alt && + (ignoreShift || isShiftPressed(inputManager) === parsedShortcut.shift) + ); + } + + /** + * Check if the shortcut of a command is pressed (held down). + * `ignoreShift` allows Shift to be used as an extra modifier (for example + * to move the camera faster) without preventing the shortcut to match. + */ + isPressed( + inputManager: gdjs.InputManager, + commandName: string, + ignoreShift: boolean = false + ): boolean { + const parsedShortcut = this._parsedShortcuts[commandName]; + if (!parsedShortcut) return false; + + return ( + inputManager.isKeyPressed(parsedShortcut.keyCode) && + this._areModifiersMatching(inputManager, parsedShortcut, ignoreShift) + ); + } + + /** + * Check if the shortcut of a command was just pressed during this frame. + */ + wasJustPressed( + inputManager: gdjs.InputManager, + commandName: string + ): boolean { + const parsedShortcut = this._parsedShortcuts[commandName]; + if (!parsedShortcut) return false; + + return ( + inputManager.wasKeyJustPressed(parsedShortcut.keyCode) && + this._areModifiersMatching(inputManager, parsedShortcut, false) + ); + } + + /** + * Check if a key, with the modifiers currently pressed, is the shortcut of + * a command: such a key must not be forwarded to the IDE. + */ + isShortcutKey(inputManager: gdjs.InputManager, keyCode: integer): boolean { + for (const commandName in this._parsedShortcuts) { + const parsedShortcut = this._parsedShortcuts[commandName]; + // Shift makes the camera move faster: it's not part of these shortcuts. + const ignoreShift = moveCameraCommandNames.indexOf(commandName) !== -1; + if ( + parsedShortcut.keyCode === keyCode && + this._areModifiersMatching(inputManager, parsedShortcut, ignoreShift) + ) { + return true; + } + } + return false; + } + } + const exceptionallyGetKeyCodeFromLocationAwareKeyCode = ( locationAwareKeyCode: number ): number => { @@ -395,6 +527,12 @@ namespace gdjs { toolbarSeparatorColor: string; textColorPrimary: string; }; + /** + * The shortcuts handled by the in-game editor, by command name, in the + * format of the IDE shortcuts (for example "Shift+KeyW"). A command that + * is not listed keeps its default shortcut, an empty string removes it. + */ + shortcuts?: { [commandName: string]: string }; }; const defaultInGameEditorSettings: InGameEditorSettings = { @@ -405,6 +543,7 @@ namespace gdjs { toolbarSeparatorColor: 'black', textColorPrimary: 'black', }, + shortcuts: {}, }; let hasWindowFocus = true; @@ -570,23 +709,26 @@ namespace gdjs { return true; }; - const freeCameraKeys = [ - LEFT_KEY, - RIGHT_KEY, - UP_KEY, - DOWN_KEY, - W_KEY, - S_KEY, - A_KEY, - D_KEY, - Q_KEY, - E_KEY, + const arrowKeys = [LEFT_KEY, RIGHT_KEY, UP_KEY, DOWN_KEY]; + const moveCameraCommandNames = [ + 'IN_GAME_EDITOR_MOVE_CAMERA_FORWARD', + 'IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD', + 'IN_GAME_EDITOR_MOVE_CAMERA_LEFT', + 'IN_GAME_EDITOR_MOVE_CAMERA_RIGHT', + 'IN_GAME_EDITOR_MOVE_CAMERA_UP', + 'IN_GAME_EDITOR_MOVE_CAMERA_DOWN', ]; - const shouldSwitchToFreeCamera = (inputManager: gdjs.InputManager) => - !isControlOrCmdPressed(inputManager) && - !isAltPressed(inputManager) && - !isShiftPressed(inputManager) && - freeCameraKeys.some((key) => inputManager.isKeyPressed(key)); + const shouldSwitchToFreeCamera = ( + inputManager: gdjs.InputManager, + shortcuts: InGameEditorShortcuts + ) => + (!isControlOrCmdPressed(inputManager) && + !isAltPressed(inputManager) && + !isShiftPressed(inputManager) && + arrowKeys.some((key) => inputManager.isKeyPressed(key))) || + moveCameraCommandNames.some((commandName) => + shortcuts.isPressed(inputManager, commandName, true) + ); const snap = (value: float, size: float, offset: float) => size ? offset + size * Math.round((value - offset) / size) : value; @@ -961,6 +1103,7 @@ namespace gdjs { private _instancesEditorSettings: InstancesEditorSettings | null = null; private _toolbar: Toolbar; private _inGameEditorSettings: InGameEditorSettings; + private _shortcuts: InGameEditorShortcuts = new InGameEditorShortcuts(); constructor( game: RuntimeGame, @@ -1032,7 +1175,16 @@ namespace gdjs { } } + /** + * The keyboard shortcuts of the in-game editor, as customized in the IDE. + */ + getShortcuts(): InGameEditorShortcuts { + return this._shortcuts; + } + private _applyInGameEditorSettings() { + this._shortcuts.update(this._inGameEditorSettings.shortcuts || {}); + if (typeof document === 'undefined') return; const rootElement = document.documentElement; @@ -1707,13 +1859,19 @@ namespace gdjs { if (!currentScene) return; const selectedObject = this._selection.getLastSelectedObject(); - if (inputManager.isKeyPressed(F_KEY) && selectedObject) { + if ( + selectedObject && + this._shortcuts.isPressed( + inputManager, + 'IN_GAME_EDITOR_FOCUS_ON_SELECTION' + ) + ) { this._focusOnSelection(); } if ( !this._getEditorCamera().isFreeCamera() && - shouldSwitchToFreeCamera(inputManager) + shouldSwitchToFreeCamera(inputManager, this._shortcuts) ) { this._getEditorCamera().switchToFreeCamera(); } @@ -3150,6 +3308,9 @@ namespace gdjs { const keyCode = exceptionallyGetKeyCodeFromLocationAwareKeyCode(locationAwareKeyCode); + // A shortcut of the in-game editor is handled here, not by the IDE. + if (this._shortcuts.isShortcutKey(inputManager, keyCode)) continue; + const debuggerClient = this._runtimeGame._debuggerClient; if (debuggerClient) { debuggerClient.sendKeyboardShortcut({ @@ -3658,11 +3819,26 @@ namespace gdjs { private _handleTransformControlsMode() { const inputManager = this._runtimeGame.getInputManager(); - if (inputManager.wasKeyJustPressed(KEY_DIGIT_1)) { + if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_TRANSLATE_MODE' + ) + ) { this._setTransformControlsMode('translate'); - } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_2)) { + } else if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_ROTATE_MODE' + ) + ) { this._setTransformControlsMode('rotate'); - } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_3)) { + } else if ( + this._shortcuts.wasJustPressed( + inputManager, + 'IN_GAME_EDITOR_SCALE_MODE' + ) + ) { this._setTransformControlsMode('scale'); } } @@ -4403,7 +4579,10 @@ namespace gdjs { } // With touches, 3 touches will orbit around the point "in front of the camera". if ( - (touchCount === 3 || inputManager.isKeyPressed(O_KEY)) && + (touchCount === 3 || + this.editor + .getShortcuts() + .isPressed(inputManager, 'IN_GAME_EDITOR_ORBIT_CAMERA')) && this.isFreeCamera() ) { const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level. @@ -4989,7 +5168,9 @@ namespace gdjs { } // Movement with the keyboard: - // Either arrow keys (move in the camera plane) or WASD ("FPS move" + Q/E for up/down). + // Either arrow keys (move in the camera plane) or the customizable + // shortcuts (by default WASD for a "FPS move" + Q/E for up/down). + // Shift makes the camera move faster. const moveSpeed = isShiftPressed(inputManager) ? 48 : 6; if ( @@ -5008,29 +5189,35 @@ namespace gdjs { if (inputManager.isKeyPressed(DOWN_KEY)) { moveCameraByVector(up, -moveSpeed); } - // Forward/back - if (inputManager.isKeyPressed(W_KEY)) { - moveCameraByVector(forward, moveSpeed); - } - if (inputManager.isKeyPressed(S_KEY)) { - moveCameraByVector(forward, -moveSpeed); - } + } - // Left/right (strafe) - if (inputManager.isKeyPressed(A_KEY)) { - moveCameraByVector(right, -moveSpeed); - } - if (inputManager.isKeyPressed(D_KEY)) { - moveCameraByVector(right, moveSpeed); - } + const shortcuts = this._editorCamera.editor.getShortcuts(); + const isMoveCameraShortcutPressed = (commandName: string) => + shortcuts.isPressed(inputManager, commandName, true); + // Forward/back + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_FORWARD')) { + moveCameraByVector(forward, moveSpeed); + } + if ( + isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD') + ) { + moveCameraByVector(forward, -moveSpeed); + } - // Up/down - if (inputManager.isKeyPressed(Q_KEY)) { - moveCameraByVector(up, -moveSpeed); - } - if (inputManager.isKeyPressed(E_KEY)) { - moveCameraByVector(up, moveSpeed); - } + // Left/right (strafe) + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_LEFT')) { + moveCameraByVector(right, -moveSpeed); + } + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_RIGHT')) { + moveCameraByVector(right, moveSpeed); + } + + // Up/down + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_DOWN')) { + moveCameraByVector(up, -moveSpeed); + } + if (isMoveCameraShortcutPressed('IN_GAME_EDITOR_MOVE_CAMERA_UP')) { + moveCameraByVector(up, moveSpeed); } // Movement with keyboard: zoom in/out. diff --git a/newIDE/app/src/CommandPalette/CommandsList.js b/newIDE/app/src/CommandPalette/CommandsList.js index c56fd7297a02..d92f509c1604 100644 --- a/newIDE/app/src/CommandPalette/CommandsList.js +++ b/newIDE/app/src/CommandPalette/CommandsList.js @@ -1,6 +1,7 @@ // @flow import { t } from '@lingui/macro'; import { type MessageDescriptor } from '../Utils/i18n/MessageDescriptor.flow'; +import Window from '../Utils/Window'; export type CommandName = | 'QUIT_APP' @@ -75,17 +76,47 @@ export type CommandName = | 'OPEN_EXTENSION_SETTINGS' | 'OPEN_PROFILE' | 'OPEN_MEMORY_TRACKER_REGISTRY' - | 'INSTALL_CLI_IN_PATH'; + | 'INSTALL_CLI_IN_PATH' + | 'TILEMAP_FREEHAND_BRUSH' + | 'TILEMAP_RECTANGLE_PAINT' + | 'TILEMAP_FILL_BUCKET' + | 'TILEMAP_TILE_PICKER' + | 'TILEMAP_ERASE' + | 'TILEMAP_FLIP_HORIZONTALLY' + | 'TILEMAP_FLIP_VERTICALLY' + | 'IN_GAME_EDITOR_TRANSLATE_MODE' + | 'IN_GAME_EDITOR_ROTATE_MODE' + | 'IN_GAME_EDITOR_SCALE_MODE' + | 'IN_GAME_EDITOR_FOCUS_ON_SELECTION' + | 'IN_GAME_EDITOR_MOVE_CAMERA_FORWARD' + | 'IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD' + | 'IN_GAME_EDITOR_MOVE_CAMERA_LEFT' + | 'IN_GAME_EDITOR_MOVE_CAMERA_RIGHT' + | 'IN_GAME_EDITOR_MOVE_CAMERA_UP' + | 'IN_GAME_EDITOR_MOVE_CAMERA_DOWN' + | 'IN_GAME_EDITOR_ORBIT_CAMERA'; export const commandAreas = { GENERAL: (t`General`: any), - IDE: (t`IDE`: any), PROJECT: (t`Project`: any), SCENE: (t`Scene`: any), + SCENE_3D: (t`3D Editor`: any), + TILEMAP: (t`Tilemap tools`: any), EVENTS: (t`Events`: any), + // Only displayed when the IDE runs in development mode. + DEVELOPER: (t`Developer`: any), }; -type CommandArea = $Keys; +export type CommandArea = $Keys; + +/** + * The areas of the commands displayed to the user, in order: the developer + * area is only displayed in development mode. + */ +export const getDisplayedCommandAreaNames = (): Array => + Object.keys(commandAreas).filter( + areaName => areaName !== 'DEVELOPER' || Window.isDev() + ); type CommandMetadata = {| area: CommandArea, @@ -93,6 +124,13 @@ type CommandMetadata = {| noShortcut?: boolean, // If true, command won't show up in shortcuts list ghost?: boolean, // If true, command won't show up in palette handledByElectron?: boolean, // If true, command shortcut is handled by Electron in desktop app + /** + * If true, the shortcut is handled by the in-game editor (the game running + * in the embedded preview), which has the keyboard focus when it's used. + * The IDE ignores these shortcuts, and the ones without modifier can't + * clash with the IDE ones. + */ + handledByInGameEditor?: boolean, |}; const commandsList: { [CommandName]: CommandMetadata } = { @@ -103,12 +141,12 @@ const commandsList: { [CommandName]: CommandMetadata } = { handledByElectron: true, }, OPEN_PROJECT_MANAGER: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open project manager`, handledByElectron: true, }, OPEN_PROFILE: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open My Profile`, }, LAUNCH_NEW_PREVIEW: { area: 'PROJECT', displayText: t`Launch new preview` }, @@ -132,7 +170,7 @@ const commandsList: { [CommandName]: CommandMetadata } = { area: 'PROJECT', displayText: t`Show diagnostic report`, }, - OPEN_HOME_PAGE: { area: 'IDE', displayText: t`Show Home` }, + OPEN_HOME_PAGE: { area: 'GENERAL', displayText: t`Show Home` }, CREATE_NEW_PROJECT: { area: 'GENERAL', displayText: t`Create a new project`, @@ -182,16 +220,16 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open recent project...`, }, OPEN_COMMAND_PALETTE: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open command palette`, ghost: true, }, RESTART_IN_GAME_EDITOR: { - area: 'IDE', + area: 'SCENE_3D', displayText: t`Restart 3D editor`, }, INSTALL_CLI_IN_PATH: { - area: 'IDE', + area: 'GENERAL', displayText: t`Install GDevelop CLI in PATH`, noShortcut: true, }, @@ -227,23 +265,23 @@ const commandsList: { [CommandName]: CommandMetadata } = { noShortcut: true, }, OPEN_GLOBAL_SEARCH: { - area: 'IDE', + area: 'GENERAL', displayText: t`Global search (search in project)`, }, // Tab-opening commands - OPEN_LAYOUT: { area: 'IDE', displayText: t`Open scene...` }, + OPEN_LAYOUT: { area: 'GENERAL', displayText: t`Open scene...` }, OPEN_EXTERNAL_EVENTS: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open external events...`, }, OPEN_EXTERNAL_LAYOUT: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open external layout...`, }, - OPEN_EXTENSION: { area: 'IDE', displayText: t`Open extension...` }, + OPEN_EXTENSION: { area: 'GENERAL', displayText: t`Open extension...` }, OPEN_GAMEPLAY_TEST: { - area: 'IDE', + area: 'GENERAL', displayText: t`Open gameplay test...`, }, RUN_GAMEPLAY_TEST: { @@ -388,11 +426,123 @@ const commandsList: { [CommandName]: CommandMetadata } = { displayText: t`Open extension settings`, }, + // Tile map painting tools (shown when painting a tile map in the scene editor) + TILEMAP_FREEHAND_BRUSH: { + area: 'TILEMAP', + displayText: t`Freehand tile brush`, + }, + TILEMAP_RECTANGLE_PAINT: { + area: 'TILEMAP', + displayText: t`Rectangle tile paint`, + }, + TILEMAP_FILL_BUCKET: { + area: 'TILEMAP', + displayText: t`Tile fill bucket`, + }, + TILEMAP_TILE_PICKER: { + area: 'TILEMAP', + displayText: t`Tile picker`, + }, + TILEMAP_ERASE: { + area: 'TILEMAP', + displayText: t`Tile eraser`, + }, + TILEMAP_FLIP_HORIZONTALLY: { + area: 'TILEMAP', + displayText: t`Flip tile horizontally`, + }, + TILEMAP_FLIP_VERTICALLY: { + area: 'TILEMAP', + displayText: t`Flip tile vertically`, + }, + + // In-game (3D) editor commands. Their shortcuts are handled by the game + // running in the embedded preview: the IDE only sends them the shortcuts. + IN_GAME_EDITOR_TRANSLATE_MODE: { + area: 'SCENE_3D', + displayText: t`Move tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_ROTATE_MODE: { + area: 'SCENE_3D', + displayText: t`Rotate tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_SCALE_MODE: { + area: 'SCENE_3D', + displayText: t`Scale tool`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_FOCUS_ON_SELECTION: { + area: 'SCENE_3D', + displayText: t`Focus the camera on the selection`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: { + area: 'SCENE_3D', + displayText: t`Move the camera forward`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: { + area: 'SCENE_3D', + displayText: t`Move the camera backward`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: { + area: 'SCENE_3D', + displayText: t`Move the camera to the left`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: { + area: 'SCENE_3D', + displayText: t`Move the camera to the right`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_UP: { + area: 'SCENE_3D', + displayText: t`Move the camera up`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: { + area: 'SCENE_3D', + displayText: t`Move the camera down`, + ghost: true, + handledByInGameEditor: true, + }, + IN_GAME_EDITOR_ORBIT_CAMERA: { + area: 'SCENE_3D', + displayText: t`Orbit mode`, + ghost: true, + handledByInGameEditor: true, + }, + // Debug commands OPEN_MEMORY_TRACKER_REGISTRY: { - area: 'IDE', + area: 'DEVELOPER', displayText: t`Open memory tracker registry`, + // A tool for the developers of GDevelop: hidden from the palette (and + // from the shortcuts list, like the whole area) in production. + ghost: !Window.isDev(), }, }; +/** + * The names of the commands whose shortcuts are handled by the in-game editor. + * The shortcuts of these commands are sent to the in-game editor (see + * `InGameEditorSettings`) instead of being handled by the IDE. + */ +export const getCommandNamesHandledByInGameEditor = (): Array => + Object.keys(commandsList).filter( + commandName => !!commandsList[commandName].handledByInGameEditor + ); + export default commandsList; diff --git a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js index f217cc38c361..a1bcbd536d2a 100644 --- a/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js +++ b/newIDE/app/src/EmbeddedGame/InGameEditorSettings.js @@ -1,6 +1,11 @@ // @flow import * as React from 'react'; import GDevelopThemeContext from '../UI/Theme/GDevelopThemeContext'; +import { useShortcutMap } from '../KeyboardShortcuts'; +import { + getCommandNamesHandledByInGameEditor, + type CommandName, +} from '../CommandPalette/CommandsList'; export type InGameEditorSettings = { theme: { @@ -10,8 +15,16 @@ export type InGameEditorSettings = { toolbarSeparatorColor: string, textColorPrimary: string, }, + /** + * The shortcuts handled by the in-game editor, by command name, in the same + * format as the IDE shortcuts (for example "Shift+KeyW"). An empty string + * means that the command has no shortcut. + */ + shortcuts: { [CommandName]: string }, }; +const commandNamesHandledByInGameEditor = getCommandNamesHandledByInGameEditor(); + /** * Generate the settings sent to the in-game editor, either at preview launch * or when there is a change in the settings. @@ -25,6 +38,19 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { const toolbarSeparatorColor = gdevelopTheme.toolbar.separatorColor; const textColorPrimary = gdevelopTheme.text.color.primary; + const shortcutMap = useShortcutMap(); + const shortcuts = React.useMemo( + () => + commandNamesHandledByInGameEditor.reduce( + (shortcuts: { [CommandName]: string }, commandName) => { + shortcuts[commandName] = shortcutMap[commandName] || ''; + return shortcuts; + }, + {} + ), + [shortcutMap] + ); + const inGameEditorSettings = React.useMemo( () => ({ theme: { @@ -34,6 +60,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarSeparatorColor, textColorPrimary, }, + shortcuts, }), [ iconButtonSelectedBackgroundColor, @@ -41,6 +68,7 @@ export const useInGameEditorSettings = (): InGameEditorSettings => { toolbarBackgroundColor, toolbarSeparatorColor, textColorPrimary, + shortcuts, ] ); diff --git a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js index 3f3b05aa304e..14461f3d5ec1 100644 --- a/newIDE/app/src/InstancesEditor/TileSetVisualizer.js +++ b/newIDE/app/src/InstancesEditor/TileSetVisualizer.js @@ -20,6 +20,7 @@ import { useLongTouch, type ClientCoordinates } from '../Utils/UseLongTouch'; import Text from '../UI/Text'; import EmptyMessage from '../UI/EmptyMessage'; import { isTileSetBadlyConfigured } from '../Utils/TileMap'; +import { useCommand } from '../CommandPalette/CommandHooks'; const styles = { tilesetAndTooltipsContainer: { @@ -781,6 +782,129 @@ const TileSetVisualizer = ({ const isAtlasImageSet = !!atlasResourceName; + // The painting tools, used both by the toolbar buttons and by the keyboard + // shortcuts (commands). Selecting the current tool again deselects it. + const isToolSelected = (kind: string): boolean => + !!tileMapTileSelection && tileMapTileSelection.kind === kind; + + const togglePaintingTool = (kind: 'freehand' | 'rectangle' | 'floodfill') => { + if (isToolSelected(kind)) { + onSelectTileMapTile(null); + return; + } + // The rectangle tool restores its last selection of tiles. + if ( + kind === 'rectangle' && + lastSelection && + lastSelection.kind === 'rectangle' + ) { + onSelectTileMapTile(lastSelection); + return; + } + onSelectTileMapTile({ + kind: (kind: any), + coordinates: lastPaintingSelection + ? lastPaintingSelection.coordinates + : [{ x: 0, y: 0 }, { x: 0, y: 0 }], + flipHorizontally: shouldFlipHorizontally, + flipVertically: shouldFlipVertically, + }); + }; + const toggleTilePicker = () => { + if (isToolSelected('picker')) { + onSelectTileMapTile(null); + previousToolRef.current = null; + } else { + // Store the current selection before switching to picker + previousToolRef.current = tileMapTileSelection; + onSelectTileMapTile({ kind: 'picker' }); + } + }; + const toggleErase = () => { + if (isToolSelected('erase')) onSelectTileMapTile(null); + else onSelectTileMapTile({ kind: 'erase' }); + }; + const canFlip = + !!tileMapTileSelection && + tileMapTileSelection.kind !== 'erase' && + tileMapTileSelection.kind !== 'picker'; + const toggleFlip = (axis: 'flipHorizontally' | 'flipVertically') => { + const isHorizontal = axis === 'flipHorizontally'; + const newShouldFlip = isHorizontal + ? !shouldFlipHorizontally + : !shouldFlipVertically; + if (isHorizontal) setShouldFlipHorizontally(newShouldFlip); + else setShouldFlipVertically(newShouldFlip); + if (tileMapPaintingSelection) { + onSelectTileMapTile({ + kind: (tileMapPaintingSelection.kind: any), + coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), + flipHorizontally: isHorizontal + ? newShouldFlip + : tileMapPaintingSelection.flipHorizontally, + flipVertically: isHorizontal + ? tileMapPaintingSelection.flipVertically + : newShouldFlip, + }); + } + }; + + // Keyboard shortcuts of the painting tools, only when the toolbar is shown. + // `useCommand` registers a command again each time its handler changes, and + // this component renders at each mouse move on the atlas: the handlers are + // kept stable and read the (fresh) tools from a ref when they are called. + const paintingToolsRef = React.useRef<{| + togglePaintingTool: typeof togglePaintingTool, + toggleTilePicker: typeof toggleTilePicker, + toggleErase: typeof toggleErase, + toggleFlip: typeof toggleFlip, + |}>({ togglePaintingTool, toggleTilePicker, toggleErase, toggleFlip }); + paintingToolsRef.current = { + togglePaintingTool, + toggleTilePicker, + toggleErase, + toggleFlip, + }; + const paintingCommandHandlers = React.useMemo( + () => ({ + freehandBrush: () => + paintingToolsRef.current.togglePaintingTool('freehand'), + rectanglePaint: () => + paintingToolsRef.current.togglePaintingTool('rectangle'), + fillBucket: () => + paintingToolsRef.current.togglePaintingTool('floodfill'), + tilePicker: () => paintingToolsRef.current.toggleTilePicker(), + erase: () => paintingToolsRef.current.toggleErase(), + flipHorizontally: () => + paintingToolsRef.current.toggleFlip('flipHorizontally'), + flipVertically: () => + paintingToolsRef.current.toggleFlip('flipVertically'), + }), + [] + ); + const arePaintingCommandsEnabled = showPaintingToolbar && isAtlasImageSet; + useCommand('TILEMAP_FREEHAND_BRUSH', arePaintingCommandsEnabled, { + handler: paintingCommandHandlers.freehandBrush, + }); + useCommand('TILEMAP_RECTANGLE_PAINT', arePaintingCommandsEnabled, { + handler: paintingCommandHandlers.rectanglePaint, + }); + useCommand('TILEMAP_FILL_BUCKET', arePaintingCommandsEnabled, { + handler: paintingCommandHandlers.fillBucket, + }); + useCommand('TILEMAP_TILE_PICKER', arePaintingCommandsEnabled, { + handler: paintingCommandHandlers.tilePicker, + }); + useCommand('TILEMAP_ERASE', showPaintingToolbar, { + handler: paintingCommandHandlers.erase, + }); + useCommand('TILEMAP_FLIP_HORIZONTALLY', showPaintingToolbar && canFlip, { + handler: paintingCommandHandlers.flipHorizontally, + }); + useCommand('TILEMAP_FLIP_VERTICALLY', showPaintingToolbar && canFlip, { + handler: paintingCommandHandlers.flipVertically, + }); + return ( {showPaintingToolbar && ( @@ -791,26 +915,8 @@ const TileSetVisualizer = ({ id="freehandBrush" size="small" tooltip={t`Freehand brush`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'freehand' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'freehand' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'freehand', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - }} + selected={isToolSelected('freehand')} + onClick={() => togglePaintingTool('freehand')} disabled={!isAtlasImageSet} > @@ -819,30 +925,8 @@ const TileSetVisualizer = ({ id="rectanglePaint" size="small" tooltip={t`Rectangle paint`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'rectangle' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'rectangle' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile( - lastSelection && lastSelection.kind === 'rectangle' - ? lastSelection - : { - kind: 'rectangle', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - } - ); - }} + selected={isToolSelected('rectangle')} + onClick={() => togglePaintingTool('rectangle')} disabled={!isAtlasImageSet} > @@ -851,26 +935,8 @@ const TileSetVisualizer = ({ id="fillBucket" size="small" tooltip={t`Fill bucket`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'floodfill' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'floodfill' - ) - onSelectTileMapTile(null); - else - onSelectTileMapTile({ - kind: 'floodfill', - coordinates: lastPaintingSelection - ? lastPaintingSelection.coordinates - : [{ x: 0, y: 0 }, { x: 0, y: 0 }], - flipHorizontally: shouldFlipHorizontally, - flipVertically: shouldFlipVertically, - }); - }} + selected={isToolSelected('floodfill')} + onClick={() => togglePaintingTool('floodfill')} disabled={!isAtlasImageSet} > @@ -879,23 +945,8 @@ const TileSetVisualizer = ({ id="tilePicker" size="small" tooltip={t`Tile picker`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'picker' - ) { - onSelectTileMapTile(null); - previousToolRef.current = null; - } else { - // Store the current selection before switching to picker - previousToolRef.current = tileMapTileSelection; - onSelectTileMapTile({ kind: 'picker' }); - } - }} + selected={isToolSelected('picker')} + onClick={toggleTilePicker} disabled={!isAtlasImageSet} > @@ -904,18 +955,8 @@ const TileSetVisualizer = ({ id="eraseBrush" size="small" tooltip={t`Erase`} - selected={ - !!tileMapTileSelection && - tileMapTileSelection.kind === 'erase' - } - onClick={e => { - if ( - !!tileMapTileSelection && - tileMapTileSelection.kind === 'erase' - ) - onSelectTileMapTile(null); - else onSelectTileMapTile({ kind: 'erase' }); - }} + selected={isToolSelected('erase')} + onClick={toggleErase} > @@ -926,24 +967,8 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Horizontal flip`} selected={shouldFlipHorizontally} - disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind === 'erase' || - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - const newShouldFlipHorizontally = !shouldFlipHorizontally; - setShouldFlipHorizontally(newShouldFlipHorizontally); - if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { - kind: (tileMapPaintingSelection.kind: any), - coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: newShouldFlipHorizontally, - flipVertically: tileMapPaintingSelection.flipVertically, - }; - onSelectTileMapTile(selection); - } - }} + disabled={!canFlip} + onClick={() => toggleFlip('flipHorizontally')} > @@ -952,25 +977,8 @@ const TileSetVisualizer = ({ size="small" tooltip={t`Vertical flip`} selected={shouldFlipVertically} - disabled={ - !tileMapTileSelection || - tileMapTileSelection.kind === 'erase' || - tileMapTileSelection.kind === 'picker' - } - onClick={e => { - const newShouldFlipVertically = !shouldFlipVertically; - setShouldFlipVertically(newShouldFlipVertically); - if (tileMapPaintingSelection) { - const selection: TileMapTileSelection = { - kind: (tileMapPaintingSelection.kind: any), - coordinates: (tileMapPaintingSelection.coordinates: TileMapCoordinates[]), - flipHorizontally: - tileMapPaintingSelection.flipHorizontally, - flipVertically: newShouldFlipVertically, - }; - onSelectTileMapTile(selection); - } - }} + disabled={!canFlip} + onClick={() => toggleFlip('flipVertically')} > diff --git a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js index 99b11b2f35b2..a6e5404a41ae 100644 --- a/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js +++ b/newIDE/app/src/KeyboardShortcuts/DefaultShortcuts.js @@ -61,6 +61,31 @@ const defaultShortcuts: ShortcutMap = { CHOOSE_AND_ADD_EVENT: 'Shift+KeyW', MOVE_EVENTS_IN_NEW_GROUP: 'CmdOrCtrl+KeyG', OPEN_EXTENSION_SETTINGS: '', + + // Tile map painting tools. Inspired by Godot and Aseprite, but the letters + // E, P, G, I and V are already used by the scene editor commands above, so + // these tools use Shift with their first letter. + TILEMAP_FREEHAND_BRUSH: 'KeyB', + TILEMAP_RECTANGLE_PAINT: 'KeyR', + TILEMAP_FILL_BUCKET: 'Shift+KeyF', + TILEMAP_TILE_PICKER: 'Shift+KeyP', + TILEMAP_ERASE: 'Shift+KeyE', + TILEMAP_FLIP_HORIZONTALLY: 'Shift+KeyH', + TILEMAP_FLIP_VERTICALLY: 'Shift+KeyY', + + // In-game (3D) editor. These shortcuts are only active when the game + // preview has the focus, so they can reuse letters of the IDE commands. + IN_GAME_EDITOR_TRANSLATE_MODE: 'Digit1', + IN_GAME_EDITOR_ROTATE_MODE: 'Digit2', + IN_GAME_EDITOR_SCALE_MODE: 'Digit3', + IN_GAME_EDITOR_FOCUS_ON_SELECTION: 'KeyF', + IN_GAME_EDITOR_MOVE_CAMERA_FORWARD: 'KeyW', + IN_GAME_EDITOR_MOVE_CAMERA_BACKWARD: 'KeyS', + IN_GAME_EDITOR_MOVE_CAMERA_LEFT: 'KeyA', + IN_GAME_EDITOR_MOVE_CAMERA_RIGHT: 'KeyD', + IN_GAME_EDITOR_MOVE_CAMERA_UP: 'KeyE', + IN_GAME_EDITOR_MOVE_CAMERA_DOWN: 'KeyQ', + IN_GAME_EDITOR_ORBIT_CAMERA: 'KeyO', }; /** diff --git a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js index 32aa71fcb0f6..fbfb6136d294 100644 --- a/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js +++ b/newIDE/app/src/KeyboardShortcuts/ShortcutsList.js @@ -1,25 +1,33 @@ // @flow import * as React from 'react'; -import { t, Trans } from '@lingui/macro'; +import { Trans } from '@lingui/macro'; import { type I18n } from '@lingui/core'; -import List from '@material-ui/core/List'; import Text from '../UI/Text'; +import EmptyMessage from '../UI/EmptyMessage'; import DetectShortcutDialog from './DetectShortcutDialog'; -import RaisedButton from '../UI/RaisedButton'; -import DismissableAlertMessage from '../UI/DismissableAlertMessage'; -import { type ShortcutMap } from './DefaultShortcuts'; +import defaultShortcuts, { type ShortcutMap } from './DefaultShortcuts'; import { getShortcutDisplayName } from './index'; -import Window from '../Utils/Window'; -import defaultShortcuts, { - defaultSecondaryShortcuts, -} from '../KeyboardShortcuts/DefaultShortcuts'; import ShortcutsListRow from './ShortcutsListRow'; import commandsList, { type CommandName, commandAreas, + getDisplayedCommandAreaNames, } from '../CommandPalette/CommandsList'; import { ColumnStackLayout } from '../UI/Layout'; +const styles = { + section: { + display: 'flex', + flexDirection: 'column', + }, + // An area with its title, separated from the previous one. + areaSection: { + display: 'flex', + flexDirection: 'column', + marginTop: 10, + }, +}; + /** * Get shortcut string to be displayed after patching the default * shortcut with user-defined shortcut, if any. @@ -33,23 +41,42 @@ const getPatchedShortcutString = ( return userShortcut == null ? defaultShortcut : userShortcut; }; +/** + * The shortcuts of the commands handled by the in-game editor are only active + * when the game preview has the focus, and the preview forwards to the IDE the + * keys pressed with a modifier. So a shortcut without modifier can't clash + * between the two contexts, while a shortcut with a modifier can (the in-game + * editor takes it over when it has the focus). + */ +const getShortcutContextKey = ( + commandName: CommandName, + shortcutString: string +): string => { + const isIsolatedInGameEditorShortcut = + !!commandsList[commandName].handledByInGameEditor && + !shortcutString.includes('+'); + return ( + (isIsolatedInGameEditorShortcut ? 'in-game-editor:' : 'ide:') + + shortcutString + ); +}; + /** * Sorts all commands into an object keyed by area name, and also creates a - * reverse mapping from shortcut string to list of commands with that shortcut. + * reverse mapping from shortcut (in its context, see `getShortcutContextKey`) + * to list of commands with that shortcut. */ const sortCommandsIntoAreasAndGetReverseMap = ( userShortcutMap: ShortcutMap ) => { - const areaWiseCommands = {}; - const shortcutStringToCommands: { [string]: Array } = {}; + const areaWiseCommands: { [string]: Array } = {}; + const shortcutContextKeyToCommands: { [string]: Array } = {}; Object.keys(commandsList) .filter(name => !commandsList[name].noShortcut) .forEach(name => { // Sort commands by area const areaName = commandsList[name].area; - // $FlowFixMe[prop-missing] if (!areaWiseCommands[areaName]) areaWiseCommands[areaName] = []; - // $FlowFixMe[prop-missing] areaWiseCommands[areaName].push(name); // Add to shortcut-command mapping @@ -60,19 +87,129 @@ const sortCommandsIntoAreasAndGetReverseMap = ( userShortcut ); if (shortcutString === '') return; - shortcutStringToCommands[shortcutString] = ( - shortcutStringToCommands[shortcutString] || [] + const shortcutContextKey = getShortcutContextKey(name, shortcutString); + shortcutContextKeyToCommands[shortcutContextKey] = ( + shortcutContextKeyToCommands[shortcutContextKey] || [] ).concat(name); }); - return [areaWiseCommands, shortcutStringToCommands]; + return [areaWiseCommands, shortcutContextKeyToCommands]; +}; + +/** + * Normalize a text for a search: case insensitive and ignoring spaces, so that + * "ctrl+s" matches "Ctrl + S". + */ +export const normalizeForSearch = (text: string): string => + text.toLowerCase().replace(/\s+/g, ''); + +type ShortcutRowData = {| + commandName: CommandName, + commandDisplayText: string, + shortcutDisplayName: string, + isDefault: boolean, + /** The other commands using the same shortcut, if any. */ + clashingCommandNames: Array, +|}; + +type ShortcutSectionData = {| + areaName: string, + title: string, + rows: Array, +|}; + +/** + * Get the shortcuts grouped by area, keeping only the commands matching the + * search text (by name, by shortcut, or by area name: searching "tilemap" + * shows all the shortcuts of the "Tilemap tools" area). Sections without any + * match are omitted. + */ +export const getShortcutSections = ( + i18n: I18n, + userShortcutMap: ShortcutMap, + searchText: string +): Array => { + const [ + areaWiseCommands, + shortcutContextKeyToCommands, + ] = sortCommandsIntoAreasAndGetReverseMap(userShortcutMap); + const normalizedSearchText = normalizeForSearch(searchText); + + // The areas are displayed in the order of `commandAreas`, which is also the + // order of the areas list in the preferences dialog. + return getDisplayedCommandAreaNames() + .filter(areaName => !!areaWiseCommands[areaName]) + .map( + (areaName): ShortcutSectionData => { + const areaTitle = i18n._(commandAreas[areaName]); + // An area whose name matches shows all its shortcuts. + const isAreaNameMatching = + !!normalizedSearchText && + normalizeForSearch(areaTitle).includes(normalizedSearchText); + const rows: Array = areaWiseCommands[areaName] + .map( + (commandName: CommandName): ShortcutRowData | null => { + // Get default and user-set shortcuts + const userShortcut = userShortcutMap[commandName]; + const defaultShortcut = defaultShortcuts[commandName] || ''; + const shortcutString = getPatchedShortcutString( + defaultShortcut, + userShortcut + ); + const shortcutDisplayName = getShortcutDisplayName( + shortcutString + ); + const commandDisplayText = i18n._( + commandsList[commandName].displayText + ); + + const matchesSearch = + !normalizedSearchText || + isAreaNameMatching || + normalizeForSearch(commandDisplayText).includes( + normalizedSearchText + ) || + normalizeForSearch(shortcutDisplayName).includes( + normalizedSearchText + ); + if (!matchesSearch) return null; + + // Find the other commands using the same shortcut, if any. + const clashingCommandNames = ( + shortcutContextKeyToCommands[ + getShortcutContextKey(commandName, shortcutString) + ] || [] + ).filter(otherCommandName => otherCommandName !== commandName); + + return { + commandName, + commandDisplayText, + shortcutDisplayName, + isDefault: shortcutString === defaultShortcut, + clashingCommandNames, + }; + } + ) + .filter(Boolean); + + return { + areaName, + title: areaTitle, + rows, + }; + } + ) + .filter(section => section.rows.length > 0); }; type Props = {| i18n: I18n, userShortcutMap: ShortcutMap, onEdit: (commandName: CommandName, shortcut: string) => void, - onReset: () => void, + /** Filter the displayed commands by name or by shortcut. */ + searchText?: string, + /** Give an id to the element of each area, to be able to scroll to it. */ + getSectionElementId?: (areaName: string) => string, |}; const ShortcutsList = (props: Props): React.Node => { @@ -81,91 +218,51 @@ const ShortcutsList = (props: Props): React.Node => { setEditedShortcut, ] = React.useState(null); - const resetAllShortcutsToDefault = () => { - const answer = Window.showConfirmDialog( - props.i18n._( - t`Are you sure you want to reset all shortcuts to their default values?` - ), - 'question' - ); - if (answer) props.onReset(); - }; - const resetShortcut = (commandName: CommandName) => { props.onEdit(commandName, defaultShortcuts[commandName]); }; - const [ - areaWiseCommands, - shortcutStringToCommands, - ] = sortCommandsIntoAreasAndGetReverseMap(props.userShortcutMap); - - const commandPaletteShortcut = getShortcutDisplayName( - props.userShortcutMap['OPEN_COMMAND_PALETTE'] || - defaultShortcuts['OPEN_COMMAND_PALETTE'] - ); - const commandPaletteSecondaryShortcut = getShortcutDisplayName( - defaultSecondaryShortcuts['OPEN_COMMAND_PALETTE'] + const sections = getShortcutSections( + props.i18n, + props.userShortcutMap, + props.searchText || '' ); return ( - - - - You can open the command palette by pressing {commandPaletteShortcut}{' '} - or {commandPaletteSecondaryShortcut}. - - - Reset all shortcuts to default} - onClick={resetAllShortcutsToDefault} - fullWidth - /> - - {Object.keys(areaWiseCommands).map(areaName => ( - - - {props.i18n._(commandAreas[areaName])} - - {areaWiseCommands[areaName].map((commandName: string) => { - // Get default and user-set shortcuts - // $FlowFixMe[incompatible-type] - const userShortcut = props.userShortcutMap[commandName]; - // $FlowFixMe[incompatible-type] - const defaultShortcut = defaultShortcuts[commandName] || ''; - const shortcutString = getPatchedShortcutString( - defaultShortcut, - userShortcut - ); - const shortcutDisplayName = getShortcutDisplayName( - shortcutString - ); - // Check if shortcut clashes with another command - const clashingCommands = shortcutStringToCommands[shortcutString]; - const hasClash = clashingCommands && clashingCommands.length > 1; - - return ( + + {sections.length > 0 ? ( + sections.map(section => ( +
+ {section.title} +
+ {section.rows.map(row => ( setEditedShortcut(commandName)} - // $FlowFixMe[incompatible-type] - onResetShortcut={() => resetShortcut(commandName)} + key={row.commandName} + shortcutString={row.shortcutDisplayName} + commandName={row.commandName} + isDefault={row.isDefault} + clashingCommandNames={row.clashingCommandNames} + onEditShortcut={() => setEditedShortcut(row.commandName)} + onResetShortcut={() => resetShortcut(row.commandName)} /> - ); - })} - - ))} - + ))} +
+
+ )) + ) : ( + + No shortcut matches your search. + + )} {editedShortcut && ( , onEditShortcut: () => void, onResetShortcut: () => void, |}; const ShortcutsListRow = (props: Props): React.Node => { + const commandDisplayText = props.i18n._( + commandsList[props.commandName].displayText + ); + const clashingCommandsDisplayText = props.clashingCommandNames + .map(clashingCommandName => + props.i18n._(commandsList[clashingCommandName].displayText) + ) + .join(', '); + return ( - - - - - {props.isClashing && ( - This shortcut clashes with another action.} - > - - - )} - No shortcut} - onClick={props.onEditShortcut} - color={props.shortcutString ? 'secondary' : 'default'} - /> - {!props.isDefault && ( - - - - )} - - - + + {props.clashingCommandNames.length > 0 && ( +
+ + This shortcut is also used by: {clashingCommandsDisplayText} + + } + > + + +
+ )} +
+ No shortcut} + onClick={props.onEditShortcut} + color={props.shortcutString ? 'secondary' : 'default'} + /> +
+
+ + + +
+
); }; diff --git a/newIDE/app/src/KeyboardShortcuts/index.js b/newIDE/app/src/KeyboardShortcuts/index.js index acd5495ebe77..e9b150f054c6 100644 --- a/newIDE/app/src/KeyboardShortcuts/index.js +++ b/newIDE/app/src/KeyboardShortcuts/index.js @@ -240,7 +240,9 @@ export const getShortcutMetadataFromEvent = ( export const useShortcutMap = (): ShortcutMap => { const preferences = React.useContext(PreferencesContext); const userShortcutMap = preferences.values.userShortcutMap; - return { ...defaultShortcuts, ...userShortcutMap }; + return React.useMemo(() => ({ ...defaultShortcuts, ...userShortcutMap }), [ + userShortcutMap, + ]); }; type UseKeyboardShortcutsProps = {| @@ -270,11 +272,19 @@ export const useKeyboardShortcuts = ({ const shortcutData = getShortcutMetadataFromEvent(e); if (!shortcutData.isValid) return; - // Get corresponding command, if it exists + // Get corresponding command, if it exists. The commands handled by + // the in-game editor are ignored: their shortcuts are only active + // when the game preview has the focus, and are handled by the game. const commandName = - Object.keys(shortcutMap).find( - name => shortcutMap[name] === shortcutData.shortcutString - ) || + Object.keys(shortcutMap).find(name => { + // The user shortcut map can contain commands that don't exist anymore. + const command = commandsList[name]; + return ( + !!command && + !command.handledByInGameEditor && + shortcutMap[name] === shortcutData.shortcutString + ); + }) || Object.keys(defaultSecondaryShortcuts).find( name => defaultSecondaryShortcuts[name] === shortcutData.shortcutString diff --git a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js index ddc5aa20c745..d4bb0feedb78 100644 --- a/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js +++ b/newIDE/app/src/MainFrame/Preferences/LanguageSelector.js @@ -76,9 +76,55 @@ const renderLanguageSelectOption = localeMetadata => { ); }; -const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { +/** + * The select field listing the available languages, to be placed in a + * settings row or in a layout of your choice. + */ +export const LanguageSelectField = ({ + onLanguageChanged, +}: Props): React.Node => { const { values, setLanguage } = useContext(PreferencesContext); + return ( + { + setLanguage(value); + onLanguageChanged(value); + }} + > + + {goodProgressLocales.map(localeMetadata => + renderLanguageSelectOption(localeMetadata) + )} + {incompleteLocales.map(localeMetadata => + renderLanguageSelectOption(localeMetadata) + )} + + ); +}; + +/** + * The text inviting to contribute to the translations. + */ +const TranslationContributionText = (): React.Node => ( + + + You can{' '} + + Window.openExternalURL('https://crowdin.com/project/gdevelop') + } + > + help translate GDevelop into your language + + . + + +); + +const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { return ( @@ -88,37 +134,10 @@ const LanguageSelector = ({ onLanguageChanged }: Props): React.Node => { - { - setLanguage(value); - onLanguageChanged(value); - }} - > - - {goodProgressLocales.map(localeMetadata => - renderLanguageSelectOption(localeMetadata) - )} - {incompleteLocales.map(localeMetadata => - renderLanguageSelectOption(localeMetadata) - )} - + - - - You can{' '} - - Window.openExternalURL('https://crowdin.com/project/gdevelop') - } - > - help translate GDevelop into your language - - . - - +
); }; diff --git a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js index a7ab698b33db..ea0c576af164 100644 --- a/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js +++ b/newIDE/app/src/MainFrame/Preferences/PreferencesDialog.js @@ -3,45 +3,401 @@ import { t, Trans } from '@lingui/macro'; import { type I18n } from '@lingui/core'; import React from 'react'; +import { Divider } from '@material-ui/core'; import FlatButton from '../../UI/FlatButton'; -import LocalFolderPicker from '../../UI/LocalFolderPicker'; +import HelpButton from '../../UI/HelpButton'; +import CompactLocalFolderPicker from '../../UI/CompactLocalFolderPicker'; import SelectOption from '../../UI/SelectOption'; import { CompactToggleField } from '../../UI/CompactToggleField'; import Dialog from '../../UI/Dialog'; -import { Column, Line } from '../../UI/Grid'; +import { Column, Line, marginsSize } from '../../UI/Grid'; +import { homepageDesktopMenuBarWidth } from '../EditorContainers/HomePage/HomePageMenuBar'; import { themes } from '../../UI/Theme/ThemeRegistry'; import { getAllThemes } from '../../CodeEditor/Theme'; import Window from '../../Utils/Window'; import optionalRequire from '../../Utils/OptionalRequire'; -import PreferencesContext from './PreferencesContext'; +import PreferencesContext, { + type EditorMosaicName, +} from './PreferencesContext'; import Text from '../../UI/Text'; -import { ColumnStackLayout, LineStackLayout } from '../../UI/Layout'; +import EmptyMessage from '../../UI/EmptyMessage'; +import { ColumnStackLayout } from '../../UI/Layout'; import { Tabs } from '../../UI/Tabs'; -import ShortcutsList from '../../KeyboardShortcuts/ShortcutsList'; -import LanguageSelector from './LanguageSelector'; +import SettingsRow, { useSettingsRowControlIds } from '../../UI/SettingsRow'; +import VerticalTabButton from '../../UI/VerticalTabButton'; +import SearchBar from '../../UI/SearchBar'; +import DismissableAlertMessage from '../../UI/DismissableAlertMessage'; +import ShortcutsList, { + getShortcutSections, + normalizeForSearch, +} from '../../KeyboardShortcuts/ShortcutsList'; +import { LanguageSelectField } from './LanguageSelector'; import Link from '../../UI/Link'; +import { + commandAreas, + getDisplayedCommandAreaNames, + type CommandArea, +} from '../../CommandPalette/CommandsList'; +import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'; import { useResponsiveWindowSize } from '../../UI/Responsive/ResponsiveWindowMeasurer'; import { adaptAcceleratorString } from '../../UI/AcceleratorString'; -import { getElectronAccelerator } from '../../KeyboardShortcuts'; -import defaultShortcuts from '../../KeyboardShortcuts/DefaultShortcuts'; +import { + getElectronAccelerator, + getShortcutDisplayName, +} from '../../KeyboardShortcuts'; +import defaultShortcuts, { + defaultSecondaryShortcuts, +} from '../../KeyboardShortcuts/DefaultShortcuts'; import AlertMessage from '../../UI/AlertMessage'; import ErrorBoundary from '../../UI/ErrorBoundary'; import CompactSelectField from '../../UI/CompactSelectField'; +import useAlertDialog from '../../UI/Alert/useAlertDialog'; +import TuneIcon from '../../UI/CustomSvgIcons/Tune'; +import BrushIcon from '../../UI/CustomSvgIcons/Brush'; +import GridIcon from '../../UI/CustomSvgIcons/Grid'; +import DesktopIcon from '../../UI/CustomSvgIcons/Desktop'; +import UpdateIcon from '../../UI/CustomSvgIcons/Update'; +import EventsIcon from '../../UI/CustomSvgIcons/Events'; +import PlayIcon from '../../UI/CustomSvgIcons/Play'; +import SceneIcon from '../../UI/CustomSvgIcons/Scene'; +import HelpIcon from '../../UI/CustomSvgIcons/Help'; +import SettingsIcon from '../../UI/CustomSvgIcons/Settings'; +import FolderIcon from '../../UI/CustomSvgIcons/Folder'; +import HammerIcon from '../../UI/CustomSvgIcons/Hammer'; +import SparkleIcon from '../../UI/CustomSvgIcons/Sparkle'; +import RobotFaceIcon from '../../UI/CustomSvgIcons/RobotFace'; +import ExtensionIcon from '../../UI/CustomSvgIcons/Extension'; +import DebugIcon from '../../UI/CustomSvgIcons/Debug'; +import ProjectManagerIcon from '../../UI/CustomSvgIcons/ProjectManager'; +import Object3dIcon from '../../UI/CustomSvgIcons/Object3d'; +import Grid2dIcon from '../../UI/CustomSvgIcons/Grid2d'; const electron = optionalRequire('electron'); +export type PreferencesSectionName = + | 'general' + | 'appearance' + | 'layouts' + | 'dialogs' + | 'updates' + | 'events-sheet' + | 'previews' + | 'scene-editor' + | 'help' + | 'other' + | 'folders' + | 'experimental' + | 'contributor' + | 'ask-ai' + | 'extensions' + | 'developer'; + +export type PreferencesTabName = 'preferences' | 'shortcuts'; + +type GetIconFunction = ({ + color: string, + fontSize: 'inherit' | 'small', +}) => React.Node; + +type PreferencesSection = {| + name: PreferencesSectionName, + /** The label, displayed with `i18n._` and used by the search. */ + label: MessageDescriptor, + getIcon: GetIconFunction, +|}; + +const sections: Array = [ + { + name: 'general', + label: t`General`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'updates', + label: t`Updates`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'help', + label: t`Help and tutorials`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'appearance', + label: t`Appearance`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'layouts', + label: t`Layouts`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'dialogs', + label: t`Dialogs`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'events-sheet', + label: t`Events Sheet`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'previews', + label: t`Previews & Saves`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'scene-editor', + label: t`Scene editor`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'ask-ai', + label: t`Ask AI`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'extensions', + label: t`Extensions`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'folders', + label: t`Folders`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'contributor', + label: t`Contributor options`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'experimental', + label: t`Experimental`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + { + name: 'other', + label: t`Advanced`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + // Settings for the developers of GDevelop, in development mode only. + ...(Window.isDev() + ? [ + { + name: 'developer', + label: t`Developer`, + getIcon: ({ color, fontSize }) => ( + + ), + }, + ] + : []), +]; + +type ShortcutAreaDefinition = {| + name: CommandArea, + getIcon: GetIconFunction, +|}; + +/** + * The icons of the areas of the commands. The areas themselves (and their + * labels and order) come from `commandAreas`, displayed as the sections of the + * shortcuts tab. + */ +const shortcutAreaIcons: { [CommandArea]: GetIconFunction } = { + GENERAL: ({ color, fontSize }) => ( + + ), + PROJECT: ({ color, fontSize }) => ( + + ), + SCENE: ({ color, fontSize }) => ( + + ), + SCENE_3D: ({ color, fontSize }) => ( + + ), + TILEMAP: ({ color, fontSize }) => ( + + ), + EVENTS: ({ color, fontSize }) => ( + + ), + DEVELOPER: ({ color, fontSize }) => ( + + ), +}; + +const shortcutAreas: Array = getDisplayedCommandAreaNames().map( + areaName => ({ name: areaName, getIcon: shortcutAreaIcons[areaName] }) +); + +/** + * A setting displayed as a row: a translated label (also used by the search) + * and the control to change it. + */ +type SettingDefinition = {| + id: string, + label: string, + renderControl: () => React.Node, +|}; + +/** + * The content of a section: its settings, and optional content displayed + * around them when the section is opened (not when searching). + */ +type SectionContent = {| + settings: Array, + renderHeader?: () => React.Node, + renderFooter?: () => React.Node, +|}; + +const getSectionElementId = (sectionName: string): string => + `preferences-section-content-${sectionName}`; +const getShortcutAreaElementId = (areaName: string): string => + `preferences-shortcuts-area-${areaName}`; + +// When scrolling to a section, its title is placed this number of pixels +// below the top of the content, so that it does not stick to the edge. +const sectionScrollOffset = 16; +// A section is considered displayed at the top of the content (and highlighted +// in the sections list) when its top is at most this number of pixels below the +// top of the content. Larger than the scroll offset, so that a section that has +// just been scrolled to is the highlighted one. +const scrollSpyTolerance = sectionScrollOffset + 8; + +const styles = { + body: { + display: 'flex', + flex: 1, + minHeight: 0, + }, + // A section with its title, separated from the previous one. + section: { + marginTop: 10, + }, + sectionsColumn: { + width: homepageDesktopMenuBarWidth, + flexShrink: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + paddingRight: marginsSize, + }, + sectionContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + paddingLeft: marginsSize * 2, + }, + sectionContentOnMobile: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + }, + fullWidthControl: { + flex: 1, + minWidth: 0, + }, +}; + type Props = {| i18n: I18n, onClose: (options: {| languageDidChange: boolean |}) => void, onOpenQuickCustomizationDialog: () => void, + /** The tab displayed when the dialog opens. Defaults to the preferences. */ + initialTab?: PreferencesTabName, + /** The preferences section displayed when the dialog opens. Defaults to the general one. */ + initialSection?: PreferencesSectionName, + /** + * Display the settings of the desktop version (folders, Electron specific + * options...). Defaults to whether the dialog runs in the desktop version: + * only useful to force it, for example in Storybook. + */ + isDesktop?: boolean, |}; +/** + * A toggle alone, labelled by the settings row containing it: clicking on the + * label of the row toggles it, and screen readers read the label. + */ +const SettingToggle = ({ + checked, + onCheck, +}: {| + checked: boolean, + onCheck: (newValue: boolean) => void, +|}) => { + const controlIds = useSettingsRowControlIds(); + return ( + + ); +}; + const PreferencesDialog = ({ i18n, onClose, onOpenQuickCustomizationDialog, + initialTab, + initialSection, + isDesktop = !!electron, }: Props) => { const { isMobile } = useResponsiveWindowSize(); - const [currentTab, setCurrentTab] = React.useState('preferences'); + const { showConfirmation } = useAlertDialog(); + const [ + currentSection, + setCurrentSection, + ] = React.useState(initialSection || 'general'); + const [currentTab, setCurrentTab] = React.useState( + initialTab || 'preferences' + ); + const [ + currentShortcutArea, + setCurrentShortcutArea, + ] = React.useState('GENERAL'); + const [searchText, setSearchText] = React.useState(''); const [languageDidChange, setLanguageDidChange] = React.useState( false ); @@ -94,501 +450,456 @@ const PreferencesDialog = ({ const initialUse3DEditor = React.useRef(values.use3DEditor); - return ( - Preferences} - actions={[ - Close} - primary={false} - onClick={() => onClose({ languageDidChange })} - />, - ]} - onRequestClose={() => onClose({ languageDidChange })} - open - maxWidth="sm" - fixedContent={ - Preferences }, - { value: 'shortcuts', label: Keyboard Shortcuts }, - ...(electron - ? [{ value: 'folders', label: Folders }] - : []), - ]} - // Enforce scroll on very small screens, because the tabs have long names. - variant={isMobile ? 'scrollable' : undefined} - /> + const isSearching = searchText.trim() !== ''; + + // All the sections of a tab are displayed one after another: the sections + // list on the left scrolls to the chosen section, and highlights the section + // displayed at the top of the content while scrolling. + const sectionContentRef = React.useRef(null); + // The element to scroll to once rendered: an element id, or the empty + // string to scroll to the top of the content. + const pendingScrollElementIdRef = React.useRef( + initialSection && (initialTab || 'preferences') === 'preferences' + ? getSectionElementId(initialSection) + : null + ); + + // A scroll made by the dialog itself (to the section chosen in the list) + // must not change the highlighted entry: near the bottom of the content, the + // chosen section can't reach the top, and the scroll spy would highlight the + // last entry instead of the chosen one. + const isProgrammaticScrollRef = React.useRef(false); + const setProgrammaticScrollTop = ( + container: HTMLDivElement, + scrollTop: number + ) => { + if (container.scrollTop === scrollTop) return; + isProgrammaticScrollRef.current = true; + container.scrollTop = scrollTop; + // In case no scroll event is fired (already at the bottom, for example). + setTimeout(() => { + isProgrammaticScrollRef.current = false; + }, 100); + }; + + const scrollToElement = (elementId: string) => { + const container = sectionContentRef.current; + const element = document.getElementById(elementId); + if (!container || !element) return; + + setProgrammaticScrollTop( + container, + container.scrollTop + + element.getBoundingClientRect().top - + container.getBoundingClientRect().top - + sectionScrollOffset + ); + }; + + React.useEffect(() => { + // Scroll once the content to scroll to is rendered (for example after + // the search has been cleared or the tab has been changed). + const pendingScrollElementId = pendingScrollElementIdRef.current; + if (pendingScrollElementId == null) return; + pendingScrollElementIdRef.current = null; + + if (pendingScrollElementId === '') { + if (sectionContentRef.current) + setProgrammaticScrollTop(sectionContentRef.current, 0); + } else { + scrollToElement(pendingScrollElementId); + } + }); + + /** + * Find the entry (section or shortcut area) displayed at the top of the + * content, or the last one if the content is scrolled to the bottom. + */ + const getEntryKeyAtTop = ( + entryKeys: Array, + getElementId: string => string + ): ?string => { + const container = sectionContentRef.current; + if (!container || entryKeys.length === 0) return null; + + const isScrolledToBottom = + container.scrollTop + container.clientHeight >= + container.scrollHeight - 1; + if (isScrolledToBottom) return entryKeys[entryKeys.length - 1]; + + const containerTop = container.getBoundingClientRect().top; + let entryKeyAtTop = entryKeys[0]; + entryKeys.forEach(entryKey => { + const element = document.getElementById(getElementId(entryKey)); + if ( + element && + element.getBoundingClientRect().top - containerTop <= scrollSpyTolerance + ) { + entryKeyAtTop = entryKey; } - > - {currentTab === 'preferences' && ( - - - Language - - - { - setLanguageDidChange(true); - }} - /> - - - Appearance - - - - - - UI Theme - - - - setThemeName(value)} - > - {Object.keys(themes).map(themeName => ( - - ))} - - - - - - - Code editor Theme - - - - setCodeEditorThemeName(value)} - > - {getAllThemes().map(codeEditorTheme => ( - - ))} - - - - - - - You can contribute and{' '} - { + if (isProgrammaticScrollRef.current) { + isProgrammaticScrollRef.current = false; + return; + } + if (isSearching) return; + + if (currentTab === 'preferences') { + const sectionNameAtTop = getEntryKeyAtTop( + visibleSections.map(section => section.name), + getSectionElementId + ); + if (sectionNameAtTop && sectionNameAtTop !== currentSection) { + setCurrentSection((sectionNameAtTop: any)); + } + } else { + const areaNameAtTop = getEntryKeyAtTop( + shortcutAreas.map(area => area.name), + getShortcutAreaElementId + ); + if (areaNameAtTop && areaNameAtTop !== currentShortcutArea) { + setCurrentShortcutArea((areaNameAtTop: any)); + } + } + }; + + const hasCustomizedShortcuts = Object.keys(values.userShortcutMap).some( + commandName => + values.userShortcutMap[commandName] != null && + values.userShortcutMap[commandName] !== + (defaultShortcuts[commandName] || '') + ); + + const resetAllShortcutsToDefault = async () => { + const answer = await showConfirmation({ + title: t`Reset all shortcuts`, + message: t`Are you sure you want to reset all shortcuts to their default values?`, + confirmButtonLabel: t`Reset all shortcuts`, + level: 'warning', + }); + if (answer) resetShortcutsToDefault(); + }; + + const commandPaletteShortcut = getShortcutDisplayName( + values.userShortcutMap['OPEN_COMMAND_PALETTE'] || + defaultShortcuts['OPEN_COMMAND_PALETTE'] + ); + const commandPaletteSecondaryShortcut = getShortcutDisplayName( + defaultSecondaryShortcuts['OPEN_COMMAND_PALETTE'] + ); + + const renderToggle = ( + checked: boolean, + onCheck: (newValue: boolean) => void + ) => ; + + const renderResetLayoutButton = (editorMosaicName: EditorMosaicName) => ( + Reset} + onClick={() => setDefaultEditorMosaicNode(editorMosaicName, null)} + disabled={!getDefaultEditorMosaicNode(editorMosaicName)} + /> + ); + + const renderResetButton = (onClick: () => void, disabled: boolean) => ( + Reset} + onClick={onClick} + disabled={disabled} + /> + ); + + const getSectionContent = ( + sectionName: PreferencesSectionName + ): SectionContent => { + switch (sectionName) { + case 'general': + return { + settings: [ + { + id: 'language', + label: i18n._(t`Choose GDevelop language`), + renderControl: () => ( +
+ setLanguageDidChange(true)} + /> +
+ ), + }, + { + id: 'show-create-section-by-default', + label: i18n._( + t`Show the "Create" section by default when opening GDevelop` + ), + renderControl: () => + renderToggle( + values.showCreateSectionByDefault, + setShowCreateSectionByDefault + ), + }, + { + id: 'auto-open-most-recent-project', + label: i18n._( + t`Automatically re-open the project edited during last session` + ), + renderControl: () => + renderToggle( + values.autoOpenMostRecentProject, + setAutoOpenMostRecentProject + ), + }, + ], + }; + case 'appearance': + return { + settings: [ + { + id: 'ui-theme', + label: i18n._(t`UI Theme`), + renderControl: () => ( +
+ setThemeName(value)} + > + {Object.keys(themes).map(themeName => ( + + ))} + +
+ ), + }, + { + id: 'code-editor-theme', + label: i18n._(t`Code editor Theme`), + renderControl: () => ( +
+ setCodeEditorThemeName(value)} + > + {getAllThemes().map(codeEditorTheme => ( + + ))} + +
+ ), + }, + ], + renderFooter: () => ( + + + You can contribute and{' '} + + Window.openExternalURL( 'https://github.com/4ian/GDevelop/blob/master/newIDE/README-themes.md' + ) + } + > + create your own themes + + . + + + ), + }; + case 'layouts': + return { + settings: [ + { + id: 'scene-editor-layout', + label: i18n._(t`Scene Editor layout`), + renderControl: () => renderResetLayoutButton('scene-editor'), + }, + { + id: 'debugger-layout', + label: i18n._(t`Debugger layout`), + renderControl: () => renderResetLayoutButton('debugger'), + }, + { + id: 'resources-editor-layout', + label: i18n._(t`Resource Editor layout`), + renderControl: () => renderResetLayoutButton('resources-editor'), + }, + { + id: 'extension-editor-layout', + label: i18n._(t`Extension Editor layout`), + renderControl: () => + renderResetLayoutButton('events-functions-extension-editor'), + }, + ], + renderHeader: () => ( + + + Reset the position and the size of the panels of an editor to + their default values. + + + ), + }; + case 'dialogs': + return { + settings: [ + { + id: 'backdrop-click-behavior', + label: i18n._(t`Dialog backdrop click behavior`), + renderControl: () => ( +
+ + setBackdropClickBehavior(value) } - onClick={() => - Window.openExternalURL( - 'https://github.com/4ian/GDevelop/blob/master/newIDE/README-themes.md' - ) + > + + + + +
+ ), + }, + ...(isDesktop + ? [ + { + id: 'resources-importation-behavior', + label: i18n._( + t`Importing resources outside from the project folder` + ), + renderControl: () => ( +
+ + setResourcesImporationBehavior(value) + } + > + + + + +
+ ), + }, + ] + : []), + ], + }; + case 'updates': + return { + settings: [ + { + id: 'auto-download-updates', + label: i18n._(t`Auto download and install updates (recommended)`), + renderControl: () => + renderToggle( + values.autoDownloadUpdates, + setAutoDownloadUpdates + ), + }, + { + id: 'auto-display-changelog', + label: i18n._( + t`Display What's New when a new version is launched (recommended)` + ), + renderControl: () => + renderToggle( + values.autoDisplayChangelog, + setAutoDisplayChangelog + ), + }, + ], + }; + case 'events-sheet': + return { + settings: [ + { + id: 'events-sheet-object-thumbnails', + label: i18n._(t`Display object thumbnails in Events Sheets`), + renderControl: () => + renderToggle( + values.eventsSheetShowObjectThumbnails, + setEventsSheetShowObjectThumbnails + ), + }, + { + id: 'events-sheet-assignment-operators', + label: i18n._(t`Display assignment operators in Events Sheets`), + renderControl: () => + renderToggle( + values.eventsSheetUseAssignmentOperators, + setEventsSheetUseAssignmentOperators + ), + }, + { + id: 'events-sheet-indent-scale', + label: i18n._(t`Indent Scale in Events Sheet`), + renderControl: () => ( +
+ + setEventsSheetIndentScale(parseInt(value, 10)) } > - create your own themes - - . - - - - - - Layouts - - - Reset Scene Editor layout} - onClick={() => setDefaultEditorMosaicNode('scene-editor', null)} - disabled={!getDefaultEditorMosaicNode('scene-editor')} - /> - Reset Debugger layout} - onClick={() => setDefaultEditorMosaicNode('debugger', null)} - disabled={!getDefaultEditorMosaicNode('debugger')} - /> - Reset Resource Editor layout} - onClick={() => - setDefaultEditorMosaicNode('resources-editor', null) - } - disabled={!getDefaultEditorMosaicNode('resources-editor')} - /> - Reset Extension Editor layout} - onClick={() => - setDefaultEditorMosaicNode( - 'events-functions-extension-editor', - null - ) - } - disabled={ - !getDefaultEditorMosaicNode('events-functions-extension-editor') - } - /> - - - Dialogs - - - - - - Dialog backdrop click behavior - - - - setBackdropClickBehavior(value)} - > - - - - - - - - {!!electron && ( - - - - - Importing resources outside from the project folder - - - - + + + + + + + + + +
+ ), + }, + { + id: 'events-sheet-escape-key-behavior', + label: i18n._( + t`Escape key behavior when editing an parameter inline` + ), + renderControl: () => ( +
- setResourcesImporationBehavior(value) + setEventsSheetCancelInlineParameter(value) } > - - - + + - - - )} - - - Updates - - - - - - - Events Sheet - - - - - - - - Indent Scale in Events Sheet - - - - - setEventsSheetIndentScale(parseInt(value, 10)) - } - > - - - - - - - - - - - - - - - - Escape key behavior when editing an parameter inline - - - - - - setEventsSheetCancelInlineParameter(value) - } - > - - - - - - - - - Embedded help and tutorials - - - Reset hidden embedded explanations} - onClick={() => showAllAlertMessages()} - disabled={!Object.keys(values.hiddenAlertMessages).length} - /> - Reset hidden embedded tutorials} - onClick={() => showAllTutorialHints()} - disabled={!Object.keys(values.hiddenTutorialHints).length} - /> - Reset hidden announcements} - onClick={() => showAllAnnouncements()} - disabled={!Object.keys(values.hiddenAnnouncements).length} - /> - - - - Advanced - - - - At launch - - - - - Previews - - - - - - - setPreviewCrashReportUploadLevel( - check ? 'exclude-javascript-code-events' : 'none' - ) - } - checked={values.previewCrashReportUploadLevel !== 'none'} - label={i18n._( - t`Send crash reports during previews to GDevelop` - )} - /> - - {electron && ( - <> - - - - - - - )} - - Scene editor - - - - {initialUse3DEditor.current !== values.use3DEditor && ( - - - For the 3D change to take effect, close and reopen all - currently opened scenes. - - - )} - - Other - - Reset hidden Ask AI text inputs} - onClick={() => showAllAskAiStandAloneForms()} - disabled={ - !Object.keys(values.hiddenAskAiStandAloneForms).length - } - /> - - - setDisplaySaveReminder({ activated: check })} - checked={values.displaySaveReminder.activated} - label={i18n._( - t`Display save reminder after significant changes in project` - )} - /> - - - - - Deprecated actions and conditions warning - - - +
+ ), + }, + { + id: 'deprecated-instruction-warning', + label: i18n._(t`Deprecated actions and conditions warning`), + renderControl: () => ( +
{ @@ -608,114 +919,661 @@ const PreferencesDialog = ({ label={t`Icon and [DEPRECATED] text`} /> - - - - - {!!electron && ( - - setWatchProjectFolderFilesForLocalProjects(check) - } - checked={values.watchProjectFolderFilesForLocalProjects} - label={i18n._( - t`Watch the project folder for file changes in order to refresh the resources used in the editor (images, 3D models, fonts, etc.)` - )} +
+ ), + }, + { + id: 'show-js-type-error', + label: i18n._( + t`Show type errors in JavaScript events (needs a restart)` + ), + renderControl: () => + renderToggle(values.showJsTypeError, setShowJsTypeError), + }, + ], + }; + case 'previews': + return { + settings: [ + { + id: 'autosave-on-preview', + label: i18n._(t`Auto-save project on preview`), + renderControl: () => + renderToggle(values.autosaveOnPreview, setAutosaveOnPreview), + }, + { + id: 'fetch-player-token-for-preview', + label: i18n._(t`Automatically log in as a player in preview`), + renderControl: () => + renderToggle( + values.fetchPlayerTokenForPreviewAutomatically, + setFetchPlayerTokenForPreviewAutomatically + ), + }, + { + id: 'open-diagnostic-report-automatically', + label: i18n._( + t`Automatically open the diagnostic report at preview` + ), + renderControl: () => + renderToggle( + values.openDiagnosticReportAutomatically, + setOpenDiagnosticReportAutomatically + ), + }, + { + id: 'block-preview-on-diagnostic-errors', + label: i18n._( + t`Block preview and export when diagnostic errors are found` + ), + renderControl: () => + renderToggle( + values.blockPreviewAndExportOnDiagnosticErrors, + setBlockPreviewAndExportOnDiagnosticErrors + ), + }, + { + id: 'preview-crash-reports', + label: i18n._(t`Send crash reports during previews to GDevelop`), + renderControl: () => + renderToggle( + values.previewCrashReportUploadLevel !== 'none', + check => + setPreviewCrashReportUploadLevel( + check ? 'exclude-javascript-code-events' : 'none' + ) + ), + }, + { + id: 'take-screenshot-on-preview', + label: i18n._( + t`Take a screenshot in previews for the game dashboard and gd.games` + ), + renderControl: () => + renderToggle( + values.takeScreenshotOnPreview, + setTakeScreenshotOnPreview + ), + }, + ...(isDesktop + ? [ + { + id: 'hide-menu-bar-in-preview', + label: i18n._(t`Hide the menu bar in the preview window`), + renderControl: () => + renderToggle( + values.isMenuBarHiddenInPreview, + setIsMenuBarHiddenInPreview + ), + }, + { + id: 'always-on-top-in-preview', + label: i18n._( + t`Always display the preview window on top of the editor` + ), + renderControl: () => + renderToggle( + values.isAlwaysOnTopInPreview, + setIsAlwaysOnTopInPreview + ), + }, + { + id: 'use-shortcut-to-close-preview-window', + label: i18n._( + t`Enable "Close project" shortcut (${adaptAcceleratorString( + getElectronAccelerator( + values.userShortcutMap['CLOSE_PROJECT'] || + defaultShortcuts['CLOSE_PROJECT'] + ) + )}) to close preview window` + ), + renderControl: () => + renderToggle( + values.useShortcutToClosePreviewWindow, + setUseShortcutToClosePreviewWindow + ), + }, + ] + : []), + { + id: 'display-save-reminder', + label: i18n._( + t`Display save reminder after significant changes in project` + ), + renderControl: () => + renderToggle(values.displaySaveReminder.activated, check => + setDisplaySaveReminder({ activated: check }) + ), + }, + ], + }; + case 'scene-editor': + return { + settings: [ + { + id: 'show-basic-profiling-counters', + label: i18n._(t`Display profiling information in scene editor`), + renderControl: () => + renderToggle( + values.showBasicProfilingCounters, + setShowBasicProfilingCounters + ), + }, + { + id: 'use-3d-editor', + label: i18n._(t`Show objects in 3D in the scene editor`), + renderControl: () => + renderToggle(values.use3DEditor, setUse3DEditor), + }, + ], + renderFooter: () => + initialUse3DEditor.current !== values.use3DEditor ? ( + + + For the 3D change to take effect, close and reopen all + currently opened scenes. + + + ) : null, + }; + case 'help': + return { + settings: [ + { + id: 'hidden-alert-messages', + label: i18n._(t`Hidden embedded explanations`), + renderControl: () => + renderResetButton( + showAllAlertMessages, + !Object.keys(values.hiddenAlertMessages).length + ), + }, + { + id: 'hidden-tutorial-hints', + label: i18n._(t`Hidden embedded tutorials`), + renderControl: () => + renderResetButton( + showAllTutorialHints, + !Object.keys(values.hiddenTutorialHints).length + ), + }, + { + id: 'hidden-announcements', + label: i18n._(t`Hidden announcements`), + renderControl: () => + renderResetButton( + showAllAnnouncements, + !Object.keys(values.hiddenAnnouncements).length + ), + }, + ], + }; + case 'ask-ai': + return { + settings: [ + { + id: 'show-ask-ai-button-in-title-bar', + label: i18n._(t`Show "Ask AI" button in the title bar`), + renderControl: () => + renderToggle( + values.showAiAskButtonInTitleBar, + setShowAiAskButtonInTitleBar + ), + }, + { + id: 'automatically-use-credits-for-ai-requests', + label: i18n._( + t`Automatically use GDevelop credits for AI requests when run out of AI credits` + ), + renderControl: () => + renderToggle( + values.automaticallyUseCreditsForAiRequests, + setAutomaticallyUseCreditsForAiRequests + ), + }, + { + id: 'hidden-ask-ai-forms', + label: i18n._(t`Hidden Ask AI text inputs`), + renderControl: () => + renderResetButton( + showAllAskAiStandAloneForms, + !Object.keys(values.hiddenAskAiStandAloneForms).length + ), + }, + ], + }; + case 'other': + return { + settings: [ + { + id: 'use-background-serializer-for-saving', + label: i18n._( + t`Use experimental background serializer for saving projects` + ), + renderControl: () => + renderToggle( + values.useBackgroundSerializerForSaving, + setUseBackgroundSerializerForSaving + ), + }, + ...(isDesktop + ? [ + { + id: 'watch-project-folder-files', + label: i18n._( + t`Watch the project folder for file changes in order to refresh the resources used in the editor (images, 3D models, fonts, etc.)` + ), + renderControl: () => + renderToggle( + values.watchProjectFolderFilesForLocalProjects, + check => + setWatchProjectFolderFilesForLocalProjects(check) + ), + }, + ] + : []), + ...(isDesktop && values.disableNpmScriptConfirmation + ? [ + { + id: 'npm-script-confirmation', + label: i18n._(t`npm script security warning`), + renderControl: () => ( + Re-enable} + onClick={() => setDisableNpmScriptConfirmation(false)} + /> + ), + }, + ] + : []), + ], + }; + case 'folders': + // Only in the desktop version: the section is hidden otherwise. + if (!isDesktop) return { settings: [] }; + return { + settings: [ + { + id: 'new-projects-default-folder', + label: i18n._(t`Default folder for new projects`), + renderControl: () => ( + - )} - {!!electron && values.disableNpmScriptConfirmation && ( - - Re-enable npm script security warning} - onClick={() => setDisableNpmScriptConfirmation(false)} - /> - - )} -
- + ), + }, + ], + }; + case 'extensions': + return { + settings: [ + { + id: 'show-experimental-extensions', + label: i18n._( + t`Show experimental extensions in the list of extensions` + ), + renderControl: () => + renderToggle( + values.showExperimentalExtensions, + setShowExperimentalExtensions + ), + }, + ], + }; + case 'experimental': + // No experimental setting for now: the section is hidden while empty. + return { settings: [] }; + case 'developer': + return { + settings: [ + { + id: 'use-gdjs-development-watcher', + label: i18n._( + t`Watch changes in game engine (GDJS) sources and auto import them` + ), + renderControl: () => + renderToggle( + values.useGDJSDevelopmentWatcher, + setUseGDJSDevelopmentWatcher + ), + }, + { + id: 'quick-customization', + label: i18n._(t`Quick customization`), + renderControl: () => ( + Open} + /> + ), + }, + ], + }; + case 'contributor': + return { + settings: [ + { + id: 'show-in-app-tutorial-developer-mode', + label: i18n._( + t`Show button to load guided lesson from file and test it` + ), + renderControl: () => + renderToggle( + values.showInAppTutorialDeveloperMode, + setShowInAppTutorialDeveloperMode + ), + }, + ], + }; + default: + return { settings: [] }; + } + }; - - Contributor options + /** + * The sections having something to display. A section without any setting + * (for example when its settings are only available on some platforms) is + * hidden from the sections list and the content. + */ + const visibleSections: Array = sections.filter( + section => { + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + return settings.length > 0 || !!renderHeader || !!renderFooter; + } + ); + // The current section can be hidden (an initial section without any setting + // on this platform): the first visible section is highlighted instead. + const highlightedSectionName = visibleSections.some( + section => section.name === currentSection + ) + ? currentSection + : visibleSections[0].name; + + const renderSettingsRows = (settings: Array) => ( + + {settings.map(setting => ( + + {setting.renderControl()} + + ))} + + ); + + const renderSection = (section: PreferencesSection) => { + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + + return ( +
+ + + {i18n._(section.label)} - - - - - {Window.isDev() && ( - <> - - - Developer options - - - - Open quick customization} - /> - - - - )} + {renderHeader && renderHeader()} + {settings.length > 0 && renderSettingsRows(settings)} + {renderFooter && renderFooter()} + +
+ ); + }; + + /** + * Display all the sections of the current tab, one after another. The + * sections list on the left (not displayed on mobile) scrolls to a section. + */ + const renderCurrentTabContent = () => { + if (currentTab === 'shortcuts') { + return ( + + + + The command palette is a search bar listing all the commands of + the editor: type the name of a command to run it, without having + to remember its shortcut. Open it by pressing{' '} + {commandPaletteShortcut} or {commandPaletteSecondaryShortcut}. + + + - )} - {currentTab === 'shortcuts' && ( - - + ); + } + + return ( + + {visibleSections.map(renderSection)} + + ); + }; + + /** + * Display the settings of all the sections matching the search, grouped by + * section. A section whose name matches shows all its settings. + */ + const renderSearchResults = () => { + const normalizedSearchText = normalizeForSearch(searchText); + + const hasMatchingShortcuts = + getShortcutSections(i18n, values.userShortcutMap, searchText).length > 0; + const matchingSections = sections + .map(section => { + const { settings, renderHeader, renderFooter } = getSectionContent( + section.name + ); + const isSectionNameMatching = normalizeForSearch( + i18n._(section.label) + ).includes(normalizedSearchText); + const matchingSettings = isSectionNameMatching + ? settings + : settings.filter(setting => + normalizeForSearch(setting.label).includes(normalizedSearchText) + ); + return matchingSettings.length > 0 + ? { section, settings: matchingSettings, renderHeader, renderFooter } + : null; + }) + .filter(Boolean); + + if (matchingSections.length === 0 && !hasMatchingShortcuts) { + return ( + + No setting matches your search. + + ); + } + + return ( + + {matchingSections.map( + ({ section, settings, renderHeader, renderFooter }) => ( + + {i18n._(section.label)} + {renderHeader && renderHeader()} + {renderSettingsRows(settings)} + {renderFooter && renderFooter()} + + ) + )} + {hasMatchingShortcuts && ( + + + Keyboard Shortcuts + - - )} - {electron && currentTab === 'folders' && ( + )} +
+ ); + }; + + const onSelectTab = (tabName: PreferencesTabName) => { + setSearchText(''); + setCurrentTab(tabName); + // Start from the top of the new tab. + if (tabName === 'preferences') setCurrentSection(visibleSections[0].name); + else setCurrentShortcutArea(shortcutAreas[0].name); + pendingScrollElementIdRef.current = ''; + }; + + const onSelectSection = (sectionName: PreferencesSectionName) => { + setSearchText(''); + setCurrentSection(sectionName); + pendingScrollElementIdRef.current = getSectionElementId(sectionName); + }; + + const onSelectShortcutArea = (areaName: CommandArea) => { + setSearchText(''); + setCurrentShortcutArea(areaName); + pendingScrollElementIdRef.current = getShortcutAreaElementId(areaName); + }; + + // The entries of the sections list, on the left of the content: the + // preferences sections or the shortcut areas, depending on the current tab. + const sectionListEntries = + currentTab === 'preferences' + ? visibleSections.map(section => ({ + key: section.name, + label: i18n._(section.label), + getIcon: section.getIcon, + isActive: !isSearching && highlightedSectionName === section.name, + onSelect: () => onSelectSection(section.name), + })) + : shortcutAreas.map(area => ({ + key: area.name, + label: i18n._(commandAreas[area.name]), + getIcon: area.getIcon, + isActive: !isSearching && currentShortcutArea === area.name, + onSelect: () => onSelectShortcutArea(area.name), + })); + + return ( + Preferences} + id="preferences-dialog" + actions={[ + Close} + primary={false} + onClick={() => onClose({ languageDidChange })} + />, + ]} + secondaryActions={[ + Help for keyboard shortcuts + ) : ( + Help for preferences + ) + } + />, + currentTab === 'preferences' && !isMobile ? ( + Help translate GDevelop} + onClick={() => + Window.openExternalURL('https://crowdin.com/project/gdevelop') + } + /> + ) : null, + currentTab === 'shortcuts' && !isSearching ? ( + Reset all shortcuts} + onClick={resetAllShortcutsToDefault} + disabled={!hasCustomizedShortcuts} + /> + ) : null, + ]} + onRequestClose={() => onClose({ languageDidChange })} + open + fullHeight + flexColumnBody + fixedContent={ - Preferences }, + { value: 'shortcuts', label: Keyboard Shortcuts }, + ]} + /> + - )} + } + > +
+ {!isMobile && ( + <> +
+ + {sectionListEntries.map(entry => ( + + ))} + +
+ + + )} +
+ {isSearching ? ( + + {renderSearchResults()} + + ) : ( + renderCurrentTabContent() + )} +
+
); }; diff --git a/newIDE/app/src/UI/CompactLocalFolderPicker/index.js b/newIDE/app/src/UI/CompactLocalFolderPicker/index.js new file mode 100644 index 000000000000..5b2925af5b0d --- /dev/null +++ b/newIDE/app/src/UI/CompactLocalFolderPicker/index.js @@ -0,0 +1,123 @@ +// @flow +import { I18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { type I18n as I18nType } from '@lingui/core'; + +import * as React from 'react'; +import CompactTextField from '../CompactTextField'; +import IconButton from '../IconButton'; +import Folder from '../CustomSvgIcons/Folder'; +import { LineStackLayout } from '../Layout'; +import optionalRequire from '../../Utils/OptionalRequire'; +const electron = optionalRequire('electron'); +const remote = optionalRequire('@electron/remote'); +const dialog = remote ? remote.dialog : null; + +const styles = { + icon: { + fontSize: 18, + }, +}; + +type Props = {| + type: 'export' | 'create-game' | 'default-workspace', + value: string, + onChange: string => void, + defaultPath?: string, + id?: string, + disabled?: boolean, +|}; + +type TitleAndMessage = {| + title: string, + message: string, +|}; + +const getTitleAndMessage = ( + i18n: I18nType, + type: 'export' | 'create-game' | 'default-workspace' +): TitleAndMessage => { + if (type === 'export') { + return { + title: i18n._(t`Choose an export folder`), + message: i18n._(t`Choose where to export the game`), + }; + } + if (type === 'default-workspace') { + return { + title: i18n._(t`Choose a workspace folder`), + message: i18n._(t`Choose where to create your projects`), + }; + } + return { + title: i18n._(t`Choose a folder for the new game`), + message: i18n._(t`Choose where to create the game`), + }; +}; + +/** + * A compact variant of `LocalFolderPicker`, to be used alongside other + * compact fields (`CompactTextField`, `CompactSelectField`...). + */ +const CompactLocalFolderPicker = ({ + type, + value, + onChange, + defaultPath, + id, + disabled, +}: Props): React.Node => { + // Use an internal state to avoid validating the value when the user + // is typing in the text field. This allows typing a "/" without the + // formatting kicking in. + const [textValue, setTextValue] = React.useState(value); + + const onChooseFolder = async ({ title, message }: TitleAndMessage) => { + if (!dialog || !electron) return; + + const browserWindow = remote.getCurrentWindow(); + const { filePaths } = await dialog.showOpenDialog(browserWindow, { + title, + properties: ['openDirectory', 'createDirectory'], + message, + defaultPath: defaultPath, + }); + + if (!filePaths || !filePaths.length) return; + + const filePath = filePaths[0]; + onChange(filePath); + setTextValue(filePath); + }; + + return ( + + {({ i18n }) => { + const titleAndMessage = getTitleAndMessage(i18n, type); + return ( + + setTextValue(newValue)} + onBlur={() => onChange(textValue)} + placeholder={titleAndMessage.title} + disabled={disabled} + /> + onChooseFolder(titleAndMessage)} + > + + + + ); + }} + + ); +}; + +export default CompactLocalFolderPicker; diff --git a/newIDE/app/src/UI/CompactToggleField/index.js b/newIDE/app/src/UI/CompactToggleField/index.js index 97e18bf0709b..fae25f724e67 100644 --- a/newIDE/app/src/UI/CompactToggleField/index.js +++ b/newIDE/app/src/UI/CompactToggleField/index.js @@ -16,9 +16,14 @@ const styles = { }, }; type Props = {| + /** The label displayed next to the toggle. Empty to display the toggle alone. */ label: string, markdownDescription?: ?string, id?: string, + /** The id of the checkbox itself, to be targeted by an external label. */ + inputId?: string, + /** The id of the element labelling the toggle, when `label` is empty. */ + ariaLabelledBy?: string, checked: boolean, onCheck: (newValue: boolean) => void, disabled?: boolean, @@ -60,6 +65,8 @@ export const CompactToggleField = (props: Props): React.MixedElement => {
props.onCheck(!props.checked)} disabled={props.disabled} @@ -88,7 +95,7 @@ export const CompactToggleField = (props: Props): React.MixedElement => {
- {props.hideTooltip ? ( + {!props.label ? null : props.hideTooltip ? ( label ) : ( (null); + +/** + * The ids of the label and of the control of the enclosing settings row, so + * that a control can be labelled by the row (for accessibility). The label is + * not clickable on purpose: it would silently toggle the setting, which is too + * easy to do by mistake with a mouse or on a touchscreen. + */ +export const useSettingsRowControlIds = (): ?SettingsRowControlIds => + React.useContext(SettingsRowContext); + +let generatedRowIdsCount = 0; + +type Props = {| + id?: string, + /** The name of the setting, displayed on the left. */ + label: React.Node, + /** The control (toggle, select field, button...), aligned on the right. */ + children?: React.Node, +|}; + +/** + * A row of a settings list: a label on the left and a control aligned on the + * right, in a fixed width column shared by all the rows, like in a table. + */ +const SettingsRow = ({ id, label, children }: Props): React.Node => { + const { isMobile } = useResponsiveWindowSize(); + const muiTheme = useTheme(); + // The row is highlighted on hover: the label and its control are far apart, + // so the highlight is what shows which control belongs to which label. + const [isHovered, setIsHovered] = React.useState(false); + const generatedIdRef = React.useRef(''); + if (!generatedIdRef.current) { + generatedRowIdsCount++; + generatedIdRef.current = `settings-row-${generatedRowIdsCount}`; + } + const rowId = id || generatedIdRef.current; + const controlIds = React.useMemo( + () => ({ controlId: `${rowId}-control`, labelId: `${rowId}-label` }), + [rowId] + ); + + return ( +
{ + if (event.pointerType === 'mouse') setIsHovered(true); + }} + onPointerLeave={() => setIsHovered(false)} + > +
+ + + {label} + + +
+
+ + {children} + +
+
+ ); +}; + +export default SettingsRow; diff --git a/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js new file mode 100644 index 000000000000..2b332c733140 --- /dev/null +++ b/newIDE/app/src/stories/componentStories/Preferences/PreferencesDialog.stories.js @@ -0,0 +1,204 @@ +// @flow +import * as React from 'react'; +import { I18n } from '@lingui/react'; +import { action } from '@storybook/addon-actions'; + +import paperDecorator from '../../PaperDecorator'; +import alertDecorator from '../../AlertDecorator'; +import PreferencesDialog, { + type PreferencesSectionName, + type PreferencesTabName, +} from '../../../MainFrame/Preferences/PreferencesDialog'; +import PreferencesContext, { + initialPreferences, + type Preferences, +} from '../../../MainFrame/Preferences/PreferencesContext'; +import { type ShortcutMap } from '../../../KeyboardShortcuts/DefaultShortcuts'; +import defaultShortcuts from '../../../KeyboardShortcuts/DefaultShortcuts'; +import { type CommandName } from '../../../CommandPalette/CommandsList'; + +export default { + title: 'Preferences/PreferencesDialog', + component: PreferencesDialog, + decorators: [paperDecorator, alertDecorator], +}; + +/** + * A preferences provider holding the values in a local state, so that the + * toggles, select fields and shortcuts react to the changes made in the story, + * without persisting anything. + */ +const StatefulPreferencesProvider = ({ + initialUserShortcutMap, + children, +}: {| + initialUserShortcutMap: ShortcutMap, + children: React.Node, +|}) => { + const [values, setValues] = React.useState({ + ...initialPreferences.values, + userShortcutMap: initialUserShortcutMap, + }); + + const setValue = (name: string, value: any) => { + action('Set preference')(name, value); + // $FlowFixMe[incompatible-type] - the name is the one of a preference. + setValues(previousValues => ({ ...previousValues, [name]: value })); + }; + + const setShortcutForCommand = ( + commandName: CommandName, + shortcut: string + ) => { + action('Set shortcut')(commandName, shortcut); + setValues(previousValues => { + const userShortcutMap = { ...previousValues.userShortcutMap }; + if (shortcut === (defaultShortcuts[commandName] || '')) { + // Same behavior as the real provider: a shortcut set back to its + // default value is removed from the user map. + delete userShortcutMap[commandName]; + } else { + userShortcutMap[commandName] = shortcut; + } + return { ...previousValues, userShortcutMap }; + }); + }; + + // $FlowFixMe[incompatible-type] - only the setters used by the dialog are overridden. + const preferences: Preferences = { + ...initialPreferences, + // $FlowFixMe[incompatible-type] + values, + setThemeName: value => setValue('themeName', value), + setCodeEditorThemeName: value => setValue('codeEditorThemeName', value), + setAutoDownloadUpdates: value => setValue('autoDownloadUpdates', value), + setAutoDisplayChangelog: value => setValue('autoDisplayChangelog', value), + setEventsSheetShowObjectThumbnails: value => + setValue('eventsSheetShowObjectThumbnails', value), + setEventsSheetUseAssignmentOperators: value => + setValue('eventsSheetUseAssignmentOperators', value), + setEventsSheetIndentScale: value => + setValue('eventsSheetIndentScale', value), + setEventsSheetCancelInlineParameter: value => + setValue('eventsSheetCancelInlineParameter', value), + setAutosaveOnPreview: value => setValue('autosaveOnPreview', value), + setAutoOpenMostRecentProject: value => + setValue('autoOpenMostRecentProject', value), + setShowCreateSectionByDefault: value => + setValue('showCreateSectionByDefault', value), + setBackdropClickBehavior: value => setValue('backdropClickBehavior', value), + setShowExperimentalExtensions: value => + setValue('showExperimentalExtensions', value), + setShowInAppTutorialDeveloperMode: value => + setValue('showInAppTutorialDeveloperMode', value), + setOpenDiagnosticReportAutomatically: value => + setValue('openDiagnosticReportAutomatically', value), + setBlockPreviewAndExportOnDiagnosticErrors: value => + setValue('blockPreviewAndExportOnDiagnosticErrors', value), + setShowDeprecatedInstructionWarning: value => + setValue('showDeprecatedInstructionWarning', value), + setUse3DEditor: value => setValue('use3DEditor', value), + setShowBasicProfilingCounters: value => + setValue('showBasicProfilingCounters', value), + setDisplaySaveReminder: value => setValue('displaySaveReminder', value), + setFetchPlayerTokenForPreviewAutomatically: value => + setValue('fetchPlayerTokenForPreviewAutomatically', value), + setPreviewCrashReportUploadLevel: value => + setValue('previewCrashReportUploadLevel', value), + setTakeScreenshotOnPreview: value => + setValue('takeScreenshotOnPreview', value), + setShowAiAskButtonInTitleBar: value => + setValue('showAiAskButtonInTitleBar', value), + setAutomaticallyUseCreditsForAiRequests: value => + setValue('automaticallyUseCreditsForAiRequests', value), + setUseBackgroundSerializerForSaving: value => + setValue('useBackgroundSerializerForSaving', value), + setShowJsTypeError: value => setValue('showJsTypeError', value), + setShortcutForCommand, + resetShortcutsToDefault: () => { + action('Reset all shortcuts')(); + setValues(previousValues => ({ ...previousValues, userShortcutMap: {} })); + }, + }; + + return ( + + {children} + + ); +}; + +const PreferencesDialogStory = ({ + initialTab, + initialSection, + initialUserShortcutMap = {}, + isDesktop = false, +}: {| + initialTab?: PreferencesTabName, + initialSection?: PreferencesSectionName, + initialUserShortcutMap?: ShortcutMap, + /** Display the settings of the desktop version. */ + isDesktop?: boolean, +|}) => ( + + + {({ i18n }) => ( + + )} + + +); + +const customizedUserShortcutMap: ShortcutMap = { + // Modified shortcut: displays the reset button. + SAVE_PROJECT: 'CmdOrCtrl+Shift+KeyS', + // Removed shortcut: displays "No shortcut" and the reset button. + LAUNCH_NEW_PREVIEW: '', + // Clashing shortcuts: both display a warning naming the other command. + OPEN_OBJECTS_PANEL: 'KeyG', + // Shortcut given to a command without a default one. + OPEN_PROJECT_PROPERTIES: 'Alt+KeyP', +}; + +export const Web = (): React.Node => ; + +export const Desktop = (): React.Node => ; + +export const DesktopFoldersSection = (): React.Node => ( + +); + +export const EventsSheetSection = (): React.Node => ( + +); + +export const KeyboardShortcutsSection = (): React.Node => ( + +); + +export const Mobile = (): React.Node => ; +Mobile.parameters = { + viewport: { defaultViewport: 'mobile1' }, +}; + +export const MobileKeyboardShortcutsSection = (): React.Node => ( + +); +MobileKeyboardShortcutsSection.parameters = { + viewport: { defaultViewport: 'mobile1' }, +}; diff --git a/newIDE/app/src/stories/everything-else.stories.js b/newIDE/app/src/stories/everything-else.stories.js index 65fc284c3f5c..c526d20894a0 100644 --- a/newIDE/app/src/stories/everything-else.stories.js +++ b/newIDE/app/src/stories/everything-else.stories.js @@ -105,7 +105,6 @@ import { } from '../UI/Accordion'; import ProjectPropertiesDialog from '../ProjectManager/ProjectPropertiesDialog'; import { LoadingScreenEditor } from '../ProjectManager/LoadingScreenEditor'; -import PreferencesDialog from '../MainFrame/Preferences/PreferencesDialog'; import { ExtensionsAccordion, ExamplesAccordion, @@ -2416,18 +2415,3 @@ storiesOf('ProjectPropertiesDialog/LoadingScreenEditor', module) } /> )); - -// $FlowFixMe[invalid-export] -storiesOf('PreferencesDialog', module).add('default', () => ( - - {({ i18n }) => ( - - )} - -));