// @flow import { t } from '@lingui/macro'; import { type I18n as I18nType } from '@lingui/core'; import { type ElectronUpdateStatus } from './UpdaterTools'; import { type FileMetadata, type FileMetadataAndStorageProviderName, } from '../ProjectsStorage'; import { type ShortcutMap } from '../KeyboardShortcuts/DefaultShortcuts'; import { type MenuDeclarativeItemTemplate, type MenuItemTemplate, } from '../UI/Menu/Menu.flow'; import { getElectronAccelerator } from '../KeyboardShortcuts'; import { isMacLike } from '../Utils/Platform'; import Window from '../Utils/Window'; import optionalRequire from '../Utils/OptionalRequire'; const electron = optionalRequire('electron'); const isDesktop = !!electron; export type BuildMainMenuProps = {| i18n: I18nType, project: ?gdProject, canSaveProjectAs: boolean, recentProjectFiles: Array, shortcutMap: ShortcutMap, isApplicationTopLevelMenu: boolean, hideAskAi: boolean, |}; export type MainMenuCallbacks = {| onChooseProject: () => void, onOpenRecentFile: ( fileMetadataAndStorageProviderName: FileMetadataAndStorageProviderName ) => Promise, onSaveProject: () => Promise, onSaveProjectAs: () => void, onShowVersionHistory: () => void, onCloseProject: () => Promise, onCloseApp: () => void, onExportProject: () => void, onInviteCollaborators: () => void, onCreateProject: () => void, onOpenProjectManager: (open?: boolean) => void, onOpenHomePage: () => void, onOpenDebugger: () => void, onOpenGlobalSearch: () => void, onOpenAbout: (open?: boolean) => void, onOpenPreferences: (open?: boolean) => void, onOpenLanguage: (open?: boolean) => void, onOpenProfile: (open?: boolean) => void, onOpenAskAi: () => void, setElectronUpdateStatus: ElectronUpdateStatus => void, |}; export type MainMenuExtraCallbacks = {| onClosePreview?: ?(windowId: number) => void, |}; export type MainMenuEvent = | 'main-menu-open' | 'main-menu-open-recent' | 'main-menu-save' | 'main-menu-save-as' | 'main-menu-show-version-history' | 'main-menu-close' | 'main-menu-close-app' | 'main-menu-export' | 'main-menu-invite-collaborators' | 'main-menu-create-project' | 'main-menu-create-blank' | 'main-menu-open-project-manager' | 'main-menu-open-home-page' | 'main-menu-open-debugger' | 'main-menu-open-global-search' | 'main-menu-open-about' | 'main-menu-open-preferences' | 'main-menu-open-language' | 'main-menu-open-profile' | 'main-menu-open-ask-ai' | 'update-status'; const getMainMenuEventCallback = ( mainMenuEvent: string, callbacks: MainMenuCallbacks ): Function => { const mapping = { 'main-menu-open': callbacks.onChooseProject, 'main-menu-open-recent': callbacks.onOpenRecentFile, 'main-menu-save': callbacks.onSaveProject, 'main-menu-save-as': callbacks.onSaveProjectAs, 'main-menu-show-version-history': callbacks.onShowVersionHistory, 'main-menu-close': callbacks.onCloseProject, 'main-menu-close-app': callbacks.onCloseApp, 'main-menu-export': callbacks.onExportProject, 'main-menu-invite-collaborators': callbacks.onInviteCollaborators, 'main-menu-create-project': callbacks.onCreateProject, 'main-menu-open-project-manager': callbacks.onOpenProjectManager, 'main-menu-open-home-page': callbacks.onOpenHomePage, 'main-menu-open-debugger': callbacks.onOpenDebugger, 'main-menu-open-global-search': callbacks.onOpenGlobalSearch, 'main-menu-open-about': callbacks.onOpenAbout, 'main-menu-open-preferences': callbacks.onOpenPreferences, 'main-menu-open-language': callbacks.onOpenLanguage, 'main-menu-open-profile': callbacks.onOpenProfile, 'main-menu-open-ask-ai': callbacks.onOpenAskAi, 'update-status': callbacks.setElectronUpdateStatus, }; // $FlowFixMe[invalid-computed-prop] return mapping[mainMenuEvent] || (() => {}); }; export const buildMainMenuDeclarativeTemplate = ({ shortcutMap, i18n, recentProjectFiles, project, canSaveProjectAs, isApplicationTopLevelMenu, hideAskAi, }: BuildMainMenuProps): Array => { const fileTemplate: MenuDeclarativeItemTemplate = { label: i18n._(t`File`), submenu: [ { label: i18n._(t`Create a game`), accelerator: getElectronAccelerator(shortcutMap['CREATE_NEW_PROJECT']), onClickSendEvent: 'main-menu-create-project', }, { type: 'separator' }, { label: i18n._(t`Open...`), accelerator: getElectronAccelerator(shortcutMap['OPEN_PROJECT']), onClickSendEvent: 'main-menu-open', }, { label: i18n._(t`Open Recent`), submenu: recentProjectFiles.length > 0 ? recentProjectFiles.map(item => ({ label: item.fileMetadata.fileIdentifier, onClickSendEvent: 'main-menu-open-recent', eventArgs: item, })) : [ { label: i18n._(t`No recent project`), enabled: false, }, ], }, { type: 'separator' }, { label: i18n._(t`Save`), accelerator: getElectronAccelerator(shortcutMap['SAVE_PROJECT']), onClickSendEvent: 'main-menu-save', enabled: !!project, }, { label: i18n._(t`Save as...`), accelerator: getElectronAccelerator(shortcutMap['SAVE_PROJECT_AS']), onClickSendEvent: 'main-menu-save-as', enabled: canSaveProjectAs, }, { label: i18n._(t`Show version history`), onClickSendEvent: 'main-menu-show-version-history', enabled: !!project, }, { type: 'separator' }, { label: i18n._(t`Invite collaborators`), accelerator: getElectronAccelerator( shortcutMap['INVITE_COLLABORATORS'] ), onClickSendEvent: 'main-menu-invite-collaborators', enabled: !!project, }, { label: i18n._(t`Export (web, iOS, Android)...`), accelerator: getElectronAccelerator(shortcutMap['EXPORT_GAME']), onClickSendEvent: 'main-menu-export', enabled: !!project, }, { type: 'separator' }, { label: i18n._(t`Close Project`), accelerator: getElectronAccelerator(shortcutMap['CLOSE_PROJECT']), onClickSendEvent: 'main-menu-close', enabled: !!project, }, ...(!isMacLike() || !isApplicationTopLevelMenu ? [ { type: 'separator' }, { label: i18n._(t`Preferences`), onClickSendEvent: 'main-menu-open-preferences', }, ] : []), ], }; // The window is only useful on the desktop app. It will be skipped on the web-app. const editTemplate: MenuDeclarativeItemTemplate = { label: i18n._(t`Edit`), submenu: [ { label: i18n._(t`Undo`), role: 'undo' }, { label: i18n._(t`Redo`), role: 'redo' }, { type: 'separator' }, { label: i18n._(t`Cut`), role: 'cut' }, { label: i18n._(t`Copy`), role: 'copy' }, { label: i18n._(t`Paste`), role: 'paste' }, { label: i18n._(t`Paste and Match Style`), role: 'pasteandmatchstyle' }, { label: i18n._(t`Delete`), role: 'delete' }, { label: i18n._(t`Select All`), role: 'selectall' }, ], }; const viewTemplate: MenuDeclarativeItemTemplate = { label: i18n._(t`View`), submenu: [ { label: i18n._(t`Show Project Manager`), accelerator: getElectronAccelerator( shortcutMap['OPEN_PROJECT_MANAGER'] ), onClickSendEvent: 'main-menu-open-project-manager', enabled: !!project, }, { label: i18n._(t`Show Home`), onClickSendEvent: 'main-menu-open-home-page', }, { label: i18n._(t`Open Debugger`), onClickSendEvent: 'main-menu-open-debugger', enabled: !!project, }, { label: i18n._(t`Global search`), onClickSendEvent: 'main-menu-open-global-search', enabled: !!project, accelerator: getElectronAccelerator(shortcutMap['OPEN_GLOBAL_SEARCH']), }, // Some Electron specific menu items, not shown in the web-app. ...(!!electron ? [ { type: 'separator' }, { label: i18n._(t`Toggle Developer Tools`), role: 'toggledevtools', }, { type: 'separator' }, { label: i18n._(t`Toggle Fullscreen`), role: 'togglefullscreen' }, ] : []), ], }; // The window is only useful on the desktop app. It will be skipped on the web-app. const windowTemplate: MenuDeclarativeItemTemplate = { label: i18n._(t`Window`), role: 'window', submenu: [{ label: i18n._(t`Minimize`), role: 'minimize' }], }; // The help menu is mostly a collection of links. const helpTemplate: MenuDeclarativeItemTemplate = { label: i18n._(t`Help`), role: 'help', submenu: [ ...(hideAskAi ? [] : [ { label: i18n._(t`Ask AI (AI agent and chatbot)`), onClickSendEvent: 'main-menu-open-ask-ai', }, ]), { label: i18n._(t`GDevelop website`), onClickOpenLink: 'http://gdevelop.io', }, { type: 'separator' }, { label: i18n._(t`GDevelop games on gd.games`), onClickOpenLink: 'https://gd.games', }, { label: i18n._(t`Community Forums`), onClickOpenLink: 'https://forum.gdevelop.io', }, { label: i18n._(t`Community Discord Chat`), onClickOpenLink: 'https://discord.gg/gdevelop', }, { type: 'separator' }, { label: i18n._(t`YouTube channel (tutorials and more)`), onClickOpenLink: 'https://www.youtube.com/c/gdevelopapp', }, { label: i18n._(t`TikTok`), onClickOpenLink: 'https://tiktok.com/@gdevelop', }, { label: i18n._(t`Twitter`), onClickOpenLink: 'https://twitter.com/gdevelopapp', }, { label: i18n._(t`Instagram`), onClickOpenLink: 'https://www.instagram.com/gdevelopapp', }, { label: i18n._(t`Facebook`), onClickOpenLink: 'https://facebook.com/gdevelopapp', }, { label: i18n._(t`Reddit`), onClickOpenLink: 'https://www.reddit.com/r/gdevelop', }, { label: i18n._(t`GitHub repository`), onClickOpenLink: 'https://github.com/4ian/GDevelop', }, { type: 'separator' }, { label: i18n._(t`Buy GDevelop goodies and swag`), onClickOpenLink: 'https://goodies.gdevelop.io', }, { label: i18n._(t`Contribute to GDevelop`), onClickOpenLink: 'https://gdevelop.io/page/contribute', }, { label: i18n._(t`Create Extensions for GDevelop`), onClickOpenLink: 'https://github.com/4ian/GDevelop/blob/master/newIDE/README-extensions.md', }, { type: 'separator' }, { label: i18n._(t`Help translate GDevelop`), onClickOpenLink: 'https://crowdin.com/project/gdevelop', }, { label: i18n._(t`Report a wrong translation`), onClickOpenLink: 'https://github.com/4ian/GDevelop/issues/969', }, ...(isMacLike() && isDesktop ? [] : [ { type: 'separator' }, { label: i18n._(t`About GDevelop`), onClickSendEvent: 'main-menu-open-about', }, ]), ], }; // Structure of the menu. Some electron specific menus are not even shown // on the web-app, because they would not work and make sense at all. // View (with Global search, Debugger, etc.) is shown in top menu and in drawer when project is open. const template: Array = [ fileTemplate, ...(!!electron && isApplicationTopLevelMenu ? [editTemplate] : []), ...(!!electron && isApplicationTopLevelMenu ? [viewTemplate] : project ? [viewTemplate] : []), ...(!!electron && isApplicationTopLevelMenu ? [windowTemplate] : []), helpTemplate, ]; // macOS has a menu with the name of the app. if (isMacLike() && isApplicationTopLevelMenu) { template.unshift({ label: i18n._(t`GDevelop 5`), submenu: [ { label: i18n._(t`About GDevelop`), onClickSendEvent: 'main-menu-open-about', }, { type: 'separator' }, { label: i18n._(t`My Profile`), onClickSendEvent: 'main-menu-open-profile', }, { label: i18n._(t`Preferences`), onClickSendEvent: 'main-menu-open-preferences', }, { label: i18n._(t`Language`), onClickSendEvent: 'main-menu-open-language', }, { type: 'separator' }, { role: 'services', submenu: [] }, { type: 'separator' }, { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }); // $FlowFixMe[incompatible-type] - submenu is guaranteed to exist. // $FlowFixMe[incompatible-use] editTemplate.submenu.push( { type: 'separator' }, { label: i18n._(t`Speech`), submenu: [{ role: 'startspeaking' }, { role: 'stopspeaking' }], } ); // $FlowFixMe[incompatible-type] - submenu is guaranteed to exist. // $FlowFixMe[prop-missing] windowTemplate.submenu = [ { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }, ]; } return template; }; export const adaptFromDeclarativeTemplate = ( menuDeclarativeTemplate: Array, callbacks: MainMenuCallbacks ): Array => { const adaptMenuDeclarativeItemTemplate = ( menuTemplate: Array ): Array => menuTemplate.map((menuItemTemplate: MenuDeclarativeItemTemplate) => { const { // $FlowFixMe[incompatible-type] - property can be undefined. onClickSendEvent, // $FlowFixMe[incompatible-type] - property can be undefined. onClickOpenLink, // $FlowFixMe[incompatible-type] - property can be undefined. eventArgs, ...menuItemTemplateRest } = menuItemTemplate; const hasOnClick = onClickSendEvent || onClickOpenLink; // $FlowFixMe[incompatible-type] - we're putting both a click and a submenu, so not strictly following the schema. return { ...menuItemTemplateRest, click: hasOnClick ? function() { if (menuItemTemplate.onClickSendEvent) { const mainMenuEvent = menuItemTemplate.onClickSendEvent; const callback = getMainMenuEventCallback( mainMenuEvent, callbacks ); if (eventArgs) callback(eventArgs); else callback(); } if (menuItemTemplate.onClickOpenLink) { Window.openExternalURL(menuItemTemplate.onClickOpenLink); } } : undefined, submenu: menuItemTemplate.submenu ? adaptMenuDeclarativeItemTemplate(menuItemTemplate.submenu) : undefined, }; }); return adaptMenuDeclarativeItemTemplate(menuDeclarativeTemplate); };