diff --git a/.bundlewatch.config.js b/.bundlewatch.config.js index f87095cd9..1080e4160 100644 --- a/.bundlewatch.config.js +++ b/.bundlewatch.config.js @@ -10,7 +10,7 @@ module.exports = { }, { path: './build/livecodes/*(app|embed|lite|headless).*.js', - maxSize: '100kB', + maxSize: '120kB', }, // { // path: './build/livecodes/lang-*.js', diff --git a/.vscode/settings.json b/.vscode/settings.json index df19b0c0f..713a99fa6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,7 +22,8 @@ "Import-maps", "Formatter", "Build", - "services" + "services", + "CommandMenu" ], "html.customData": ["./.vscode/html.html-data.json"] } diff --git a/docs/docs/contribution/adding-languages.md b/docs/docs/contribution/adding-languages.md index 9b81acc06..107583931 100644 --- a/docs/docs/contribution/adding-languages.md +++ b/docs/docs/contribution/adding-languages.md @@ -22,7 +22,7 @@ If you still have doubts if the language qualifies, [let's discuss it](https://g - [ ] Add language name and aliases to [models](https://github.com/live-codes/livecodes/blob/3a2617850f09487b9af92de862093f082942b8a9/src/sdk/models.ts#L129). - [ ] Add editor support (e.g. syntax highlighting) for [Monaco](https://github.com/live-codes/livecodes/tree/develop/src/livecodes/editor/monaco), [CodeMirror](https://github.com/live-codes/livecodes/tree/develop/src/livecodes/editor/codemirror) and [Prismjs](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/editor/codejar/codejar.ts) (if not auto-loaded). - [ ] Add [language info](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/html/language-info.html). -- [ ] Consider adding a [starter template](https://github.com/live-codes/livecodes/tree/develop/src/livecodes/templates/starter). If you do, add it to the [list of starter templates](https://github.com/live-codes/livecodes/blob/develop/docs/src/components/TemplateList.tsx) in docs. +- [ ] Consider adding a [starter template](https://github.com/live-codes/livecodes/tree/develop/src/livecodes/templates/starter). If you do, add it to the [list of starter templates](https://github.com/live-codes/livecodes/blob/develop/docs/src/components/TemplateList.tsx) in docs, [command menu](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/UI/command-menu-actions.ts#L235) and [language info](https://github.com/live-codes/livecodes/blob/develop/src/livecodes/html/language-info.html). - [ ] Add [end-to-ends tests](https://github.com/live-codes/livecodes/tree/develop/e2e/specs). - [ ] Add language [documentation](https://github.com/live-codes/livecodes/tree/develop/docs/docs/languages). - [ ] Add language to documentation website [slider](https://github.com/live-codes/livecodes/blob/develop/docs/src/components/LanguageSliders.tsx). diff --git a/docs/docs/features/command-menu.md b/docs/docs/features/command-menu.md new file mode 100644 index 000000000..6313ccf5a --- /dev/null +++ b/docs/docs/features/command-menu.md @@ -0,0 +1,34 @@ +# Command Menu + +The command menu allows running a large set of commands from the UI. It is keyboard-friendly and allows for searching and selecting commands. Most of the functionality of LiveCodes can be achieved using the command menu, which can really improve productivity and DX. + +It can be triggered from the keyboard by pressing Ctrl + K (or + K on Mac), or from the Help Menu. + +![Open Command Menu from UI](../../static/img/screenshots/command-menu-1.jpg) + +The available commands cover a wide range of functionality, like showing and hiding UI elements (e.g. different editors, the [result page](./result.md), [console](./console.md), [compiled code viewer](./compiled-code.md), and [tests](./tests.md)), changing [languages](../languages), loading [starter templates](./templates.md), opening different screens (e.g. new project, opening saved projects, [import](./import.md), [embeds](./embeds.md), [deploy](./deploy.md), [share](./share.md) and more). +In addition many commands can be executed from the command menu, such as running code, formatting code, changing settings (e.g. autorun, autosave, [AI code assistant](./ai.md), changing [themes](./themes.md), [editor settings](./editor-settings.md), and more). + +![LiveCodes Command Menu](../../static/img/screenshots/command-menu-2.jpg) + +![LiveCodes Command Menu](../../static/img/screenshots/command-menu-3.jpg) + +![LiveCodes Command Menu](../../static/img/screenshots/command-menu-4.jpg) + +## Using the Command Menu + +Commands can be navigated and selected by: + +- The mouse: scrolling and clicking +- The keyboard: using the up and down arrow keys to navigate, pressing Enter to select, Backspace to move to parent category and Esc to close the command menu. +- Searching: typing in the search box for fuzzy search. + +![LiveCodes Command Menu](../../static/img/screenshots/command-menu-5.jpg) + +![LiveCodes Command Menu](../../static/img/screenshots/command-menu-6.jpg) + +## Keyboard Shortcuts + +If a command has a keyboard shortcut, it will be shown in the command menu. In addition, the whole list of keyboard shortcuts can be opened from the command menu (by searching for "Keyboard Shortcuts") or from the UI from the Help Menu. + +![Keyboard Shortcuts](../../static/img/screenshots/keyboard-shortcuts.jpg) diff --git a/docs/docs/features/keyboard-shortcuts.md b/docs/docs/features/keyboard-shortcuts.md index c7978b219..aac244963 100644 --- a/docs/docs/features/keyboard-shortcuts.md +++ b/docs/docs/features/keyboard-shortcuts.md @@ -1 +1,9 @@ # Keyboard Shortcuts + +Many commands can be executed from the keyboard using keyboard shortcuts. The full list can be found in the keyboard shortcuts menu, accessed from the Help Menu or from the [command menu](./command-menu.md) by pressing Ctrl + K (or + K on Mac) and searching for "Keyboard Shortcuts". + +The code editor shortcuts are the same as VS Code, which can be found [here](https://code.visualstudio.com/docs/getstarted/keybindings#_basic-editing). + +![Open Command Menu from UI](../../static/img/screenshots/command-menu-1.jpg) + +![Keyboard Shortcuts](../../static/img/screenshots/keyboard-shortcuts.jpg) diff --git a/docs/sidebars.js b/docs/sidebars.js index a10313f1e..56a4cbfcc 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -35,6 +35,7 @@ const sidebars = { 'features/intellisense', 'features/ai', 'features/code-format', + 'features/command-menu', 'features/keyboard-shortcuts', 'features/user-settings', 'features/editor-settings', diff --git a/docs/static/img/screenshots/command-menu-1.jpg b/docs/static/img/screenshots/command-menu-1.jpg new file mode 100644 index 000000000..26bf33afc Binary files /dev/null and b/docs/static/img/screenshots/command-menu-1.jpg differ diff --git a/docs/static/img/screenshots/command-menu-2.jpg b/docs/static/img/screenshots/command-menu-2.jpg new file mode 100644 index 000000000..c26817ca6 Binary files /dev/null and b/docs/static/img/screenshots/command-menu-2.jpg differ diff --git a/docs/static/img/screenshots/command-menu-3.jpg b/docs/static/img/screenshots/command-menu-3.jpg new file mode 100644 index 000000000..ba719edc4 Binary files /dev/null and b/docs/static/img/screenshots/command-menu-3.jpg differ diff --git a/docs/static/img/screenshots/command-menu-4.jpg b/docs/static/img/screenshots/command-menu-4.jpg new file mode 100644 index 000000000..a11fcf0b9 Binary files /dev/null and b/docs/static/img/screenshots/command-menu-4.jpg differ diff --git a/docs/static/img/screenshots/command-menu-5.jpg b/docs/static/img/screenshots/command-menu-5.jpg new file mode 100644 index 000000000..42591a5d5 Binary files /dev/null and b/docs/static/img/screenshots/command-menu-5.jpg differ diff --git a/docs/static/img/screenshots/command-menu-6.jpg b/docs/static/img/screenshots/command-menu-6.jpg new file mode 100644 index 000000000..965f34c6a Binary files /dev/null and b/docs/static/img/screenshots/command-menu-6.jpg differ diff --git a/docs/static/img/screenshots/keyboard-shortcuts.jpg b/docs/static/img/screenshots/keyboard-shortcuts.jpg new file mode 100644 index 000000000..980456afd Binary files /dev/null and b/docs/static/img/screenshots/keyboard-shortcuts.jpg differ diff --git a/scripts/i18n-export.js b/scripts/i18n-export.js index 757d9a665..e0de038d0 100644 --- a/scripts/i18n-export.js +++ b/scripts/i18n-export.js @@ -241,7 +241,9 @@ const processHTML = async (files) => { desc: generateElementsNote(elements), }; } - const value = prop.startsWith('data-') ? element.dataset[prop.slice(5)] : element[prop]; + const value = prop.startsWith('data-') + ? element.dataset[prop.slice(5)] + : element[prop] || element.getAttribute(prop); return { value: value.trim(), desc: '', diff --git a/src/livecodes/UI/command-menu-actions.ts b/src/livecodes/UI/command-menu-actions.ts new file mode 100644 index 000000000..619967fd1 --- /dev/null +++ b/src/livecodes/UI/command-menu-actions.ts @@ -0,0 +1,1104 @@ +/* eslint-disable camelcase */ +/* eslint-disable import/no-internal-modules */ +import type { Config, INinjaAction, TemplateName } from '../models'; +import { appLanguages } from '../i18n/app-languages'; +import { languageIsEnabled, languages, processorIsEnabled, processors } from '../languages'; +import { isMac, predefinedValues, stringUnionToArray } from '../utils/utils'; +import * as UI from './selectors'; + +export const getCommandMenuActions = ({ + deps, +}: { + deps: { + getConfig: () => Config; + loadStarterTemplate: (templateName: TemplateName) => Promise; + changeEditorSettings: (config: Partial) => void; + changeLayout: (layout: Config['layout']) => void; + }; +}) => { + const { getConfig, loadStarterTemplate, changeEditorSettings, changeLayout } = deps; + + const actions: INinjaAction[] = [ + { + id: 'Show', + title: window.deps.translateString('commandMenu.show.title', 'Show …'), + mdIcon: 'visibility', + children: [ + { + id: 'Next Editor', + title: window.deps.translateString('commandMenu.show.next', 'Show Next Editor'), + hotkey: 'ctrl+alt+ArrowRight', + mdIcon: 'skip_next', + handler: () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + ctrlKey: true, + altKey: true, + key: 'ArrowRight', + code: 'ArrowRight', + }), + ); + }, + }, + { + id: 'Previous Editor', + title: window.deps.translateString('commandMenu.show.previous', 'Show Previous Editor'), + hotkey: 'ctrl+alt+ArrowLeft', + mdIcon: 'skip_previous', + handler: () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + ctrlKey: true, + altKey: true, + key: 'ArrowLeft', + code: 'ArrowLeft', + }), + ); + }, + }, + { + id: 'Markup Editor', + title: window.deps.translateString('commandMenu.show.markup', 'Show Markup Editor'), + hotkey: 'ctrl+alt+1', + mdIcon: 'html', + handler: () => { + UI.getMarkupEditorTitle()?.click(); + }, + }, + { + id: 'Style Editor', + title: window.deps.translateString('commandMenu.show.style', 'Show Style Editor'), + hotkey: 'ctrl+alt+2', + mdIcon: 'css', + handler: () => { + UI.getStyleEditorTitle()?.click(); + }, + }, + { + id: 'Script Editor', + title: window.deps.translateString('commandMenu.show.script', 'Show Script Editor'), + hotkey: 'ctrl+alt+3', + mdIcon: 'javascript', + handler: () => { + UI.getScriptEditorTitle()?.click(); + }, + }, + { + id: 'Toggle Result', + title: window.deps.translateString('commandMenu.show.result', 'Toggle Result'), + hotkey: 'ctrl+alt+R', + // mdIcon: 'split_scene', + icon: icons.split_scene, + handler: () => { + UI.getResultButton()?.click(); + }, + }, + { + id: 'Toggle Console', + title: window.deps.translateString('commandMenu.show.console', 'Toggle Console'), + hotkey: 'ctrl+alt+C', + mdIcon: 'terminal', + handler: () => { + UI.getConsoleButton()?.dispatchEvent(new Event('touchstart')); + }, + }, + { + id: 'Maximize Console', + title: window.deps.translateString( + 'commandMenu.show.maximizeConsole', + 'Maximize Console', + ), + hotkey: 'ctrl+alt+C+F', + mdIcon: 'terminal', + handler: () => { + UI.getConsoleButton()?.dispatchEvent(new Event('dblclick')); + }, + }, + { + id: 'Toggle Compiled Code', + title: window.deps.translateString('commandMenu.show.compiled', 'Toggle Compiled Code'), + // mdIcon: 'code_blocks', + icon: icons.code_blocks, + handler: () => { + UI.getCompiledButton()?.dispatchEvent(new Event('touchstart')); + }, + }, + { + id: 'Maximize Compiled Code', + title: window.deps.translateString( + 'commandMenu.show.maximizeCompiled', + 'Maximize Compiled Code', + ), + icon: icons.code_blocks, + handler: () => { + UI.getCompiledButton()?.dispatchEvent(new Event('dblclick')); + }, + }, + { + id: 'Toggle Tests', + title: window.deps.translateString('commandMenu.show.tests', 'Toggle Tests'), + // mdIcon: 'labs', + icon: icons.labs, + handler: () => { + UI.getTestsButton()?.dispatchEvent(new Event('touchstart')); + }, + }, + { + id: 'Maximize Tests', + title: window.deps.translateString('commandMenu.show.maximizeTests', 'Maximize Tests'), + // mdIcon: 'labs', + icon: icons.labs, + handler: () => { + UI.getTestsButton()?.dispatchEvent(new Event('dblclick')); + }, + }, + { + id: 'Toggle Result Zoom', + title: window.deps.translateString('commandMenu.show.zoom', 'Toggle Result Zoom'), + hotkey: 'ctrl+alt+z', + mdIcon: 'zoom_in', + handler: () => { + UI.getZoomButton()?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Toggle Full Screen', + title: window.deps.translateString('commandMenu.show.fullscreen', 'Toggle Full Screen'), + hotkey: 'f11', + mdIcon: 'zoom_out_map', + handler: () => { + UI.getFullscreenButton()?.click(); + }, + }, + { + id: 'Toggle Focus Mode', + title: window.deps.translateString('commandMenu.show.focusMode', 'Toggle Focus Mode'), + hotkey: 'ctrl+alt+F', + mdIcon: 'crop_free', + handler: () => { + UI.getFocusButton()?.click(); + }, + }, + ], + }, + { + id: 'Select Language', + title: window.deps.translateString('commandMenu.selectLanguage', 'Select Language'), + mdIcon: 'code', + children: languages + .filter((l) => languageIsEnabled(l.name, getConfig())) + .sort((a, b) => a.title.localeCompare(b.title)) + .map((lang) => ({ + id: 'Language: ' + lang.title, + title: lang.longTitle ?? lang.title, + keywords: [lang.name, lang.title, lang.longTitle, ...lang.extensions].join(', '), + handler: async () => { + document + .querySelector('a[data-editor][data-lang="' + lang.name + '"]') + ?.dispatchEvent(new Event('mousedown')); + }, + })), + }, + { + id: 'Processors', + title: window.deps.translateString('commandMenu.processors', 'Processors'), + // mdIcon: 'manufacturing', + icon: icons.manufacturing, + children: processors + .filter((p) => !p.hidden && processorIsEnabled(p.name, getConfig())) + .map((processor) => ({ + id: 'Processor: ' + processor.title, + title: + window.deps.translateString('commandMenu.toggle', 'Toggle: ') + + (processor.longTitle ?? processor.title), + keywords: [ + processor.name, + processor.title, + processor.longTitle, + processor.isPostcssPlugin ? 'postcss' : '', + ].join(' '), + handler: async () => { + document + .querySelector( + '.processor-item input[data-processor="' + processor.name + '"]', + ) + ?.dispatchEvent(new Event('mousedown', { bubbles: true })); + }, + })), + }, + { + id: 'Starter Templates', + title: window.deps.translateString('commandMenu.starterTemplates', 'Starter Templates'), + mdIcon: 'library_books', + children: stringUnionToArray()( + 'blank', + 'javascript', + 'typescript', + 'react', + 'react-native', + 'vue2', + 'vue', + 'angular', + 'preact', + 'svelte', + 'solid', + 'lit', + 'stencil', + 'mdx', + 'astro', + 'riot', + 'malina', + 'jquery', + 'backbone', + 'knockout', + 'jest', + 'jest-react', + 'bootstrap', + 'tailwindcss', + 'd3', + 'phaser', + 'coffeescript', + 'livescript', + 'civet', + 'clio', + 'imba', + 'rescript', + 'reason', + 'ocaml', + 'python', + 'pyodide', + 'python-wasm', + 'r', + 'ruby', + 'ruby-wasm', + 'go', + 'php', + 'php-wasm', + 'cpp', + 'clang', + 'cpp-wasm', + 'perl', + 'lua', + 'lua-wasm', + 'teal', + 'fennel', + 'julia', + 'scheme', + 'commonlisp', + 'clojurescript', + 'gleam', + 'tcl', + 'markdown', + 'assemblyscript', + 'wat', + 'sql', + 'postgresql', + 'prolog', + 'blockly', + 'diagrams', + ).map((template) => ({ + id: 'Starter template: ' + template, + title: window.deps.translateString('commandMenu.template', 'Template') + ': ' + template, + handler: async () => { + await loadStarterTemplate(template); + }, + })), + }, + { + id: 'Run', + title: window.deps.translateString('commandMenu.run', 'Run'), + hotkey: 'shift+Enter', + mdIcon: 'play_arrow', + handler: () => { + UI.getRunButton()?.click(); + }, + }, + { + id: 'Share', + title: window.deps.translateString('menu.share', 'Share …'), + hotkey: 'ctrl+alt+S', + mdIcon: 'share', + handler: () => { + UI.getShareLink()?.click(); + }, + }, + { + id: 'New', + title: window.deps.translateString('menu.new', 'New …'), + hotkey: 'ctrl+alt+N', + mdIcon: 'note_add', + handler: () => { + UI.getNewLink()?.click(); + }, + }, + { + id: 'Open', + title: window.deps.translateString('menu.open', 'Open …'), + hotkey: 'ctrl+O', + mdIcon: 'file_open', + handler: () => { + UI.getOpenLink()?.click(); + }, + }, + { + id: 'Save', + title: window.deps.translateString('menu.save', 'Save'), + hotkey: 'ctrl+S', + mdIcon: 'save', + handler: () => { + UI.getSaveLink()?.click(); + }, + }, + { + id: 'Save As', + title: window.deps.translateString('menu.saveAs.heading', 'Save as …'), + mdIcon: 'save_as', + children: [ + { + id: 'Save as a fork', + title: window.deps.translateString( + 'commandMenu.saveAsFork', + 'Save as a Fork (New Project)', + ), + hotkey: 'ctrl+shift+S', + mdIcon: 'save_as', + handler: () => { + UI.getForkLink()?.click(); + }, + }, + { + id: 'Save as a template', + title: window.deps.translateString('commandMenu.saveAsTemplate', 'Save as a Template'), + mdIcon: 'library_add', + handler: async () => { + UI.getSaveAsTemplateLink()?.click(); + }, + }, + ], + }, + { + id: 'Import', + title: window.deps.translateString('menu.import', 'Import …'), + hotkey: 'ctrl+alt+I', + mdIcon: 'upload', + handler: () => { + UI.getImportLink()?.click(); + }, + }, + { + id: 'Export', + title: window.deps.translateString('menu.export.heading', 'Export'), + mdIcon: 'download', + children: [ + { + id: 'Export as JSON', + title: window.deps.translateString('menu.export.json', 'Export Project (JSON)'), + mdIcon: 'data_object', + handler: () => { + UI.getExportJSONLink()?.click(); + }, + }, + { + id: 'Export as HTML', + title: window.deps.translateString('menu.export.result', 'Export Result (HTML)'), + mdIcon: 'html', + handler: () => { + UI.getExportResultLink()?.click(); + }, + }, + { + id: 'Export as ZIP', + title: window.deps.translateString('menu.export.src', 'Export Source (ZIP)'), + mdIcon: 'archive', + handler: () => { + UI.getExportSourceLink()?.click(); + }, + }, + { + id: 'Export to GitHub Gist', + title: window.deps.translateString('menu.export.gist', 'Export to GitHub Gist'), + mdIcon: 'north_east', + handler: () => { + UI.getExportGithubGistLink()?.click(); + }, + }, + { + id: 'Export to Codepen', + title: window.deps.translateString('menu.export.codepen', 'Edit in CodePen'), + mdIcon: 'north_east', + handler: () => { + UI.getExportCodepenLink()?.click(); + }, + }, + { + id: 'Export to Fiddle', + title: window.deps.translateString('menu.export.jsfiddle', 'Edit in JSFiddle'), + mdIcon: 'north_east', + handler: () => { + UI.getExportJsfiddleLink()?.click(); + }, + }, + ], + }, + { + id: 'Deploy', + title: window.deps.translateString('menu.deploy', 'Deploy …'), + mdIcon: 'rocket_launch', + handler: () => { + UI.getDeployLink()?.click(); + }, + }, + { + id: 'Broadcast', + title: window.deps.translateString('menu.broadcast', 'Broadcast …'), + mdIcon: 'cell_tower', + handler: () => { + UI.getBroadcastLink()?.click(); + }, + }, + { + id: 'Embed', + title: window.deps.translateString('menu.embed', 'Embed …'), + mdIcon: 'aspect_ratio', + handler: () => { + UI.getEmbedLink()?.click(); + }, + }, + { + id: 'Project Info', + title: window.deps.translateString('menu.project', 'Project Info …'), + mdIcon: 'info', + handler: () => { + UI.getProjectInfoLink()?.click(); + }, + }, + { + id: 'Custom Settings', + title: window.deps.translateString('menu.customSettings', 'Custom Settings …'), + mdIcon: 'data_object', + handler: () => { + UI.getCustomSettingsLink()?.click(); + }, + }, + { + id: 'External Resources', + title: window.deps.translateString('menu.resources', 'External Resources …'), + mdIcon: 'file_present', + handler: () => { + UI.getExternalResourcesLink()?.click(); + }, + }, + { + id: 'Assets', + title: window.deps.translateString('menu.assets', 'Assets …'), + mdIcon: 'perm_media', + handler: () => { + UI.getAssetsLink()?.click(); + }, + }, + { + id: 'Code Snippets', + title: window.deps.translateString('menu.snippets', 'Code Snippets …'), + mdIcon: 'text_snippet', + handler: () => { + UI.getSnippetsLink()?.click(); + }, + }, + { + id: 'Backup / Restore', + title: window.deps.translateString('menu.backup', 'Backup / Restore …'), + // mdIcon: 'deployed_code_update', + icon: icons.deployed_code_update, + handler: () => { + UI.getBackupLink()?.click(); + }, + }, + { + id: 'Sync', + title: window.deps.translateString('commandMenu.sync', 'Sync (beta) …'), + mdIcon: 'sync', + handler: () => { + UI.getBackupLink()?.click(); + }, + }, + { + id: 'Welcome Screen', + title: window.deps.translateString('menu.welcome.heading', 'Welcome …'), + mdIcon: 'dashboard', + handler: () => { + UI.getWelcomeLink()?.click(); + }, + }, + { + id: 'Settings', + title: window.deps.translateString('menu.appSettings.heading', 'Settings'), + mdIcon: 'settings', + children: [ + { + id: 'Editor Settings', + title: window.deps.translateString('menu.editorSettings', 'Editor Settings …'), + mdIcon: 'settings', + handler: () => { + UI.getEditorSettingsLink()?.click(); + }, + }, + { + id: 'Enable AI Code Assistant', + title: window.deps.translateString('commandMenu.enableAI', 'Enable AI Code Assistant'), + mdIcon: 'toggle_on', + handler: () => { + changeEditorSettings({ enableAI: true }); + }, + }, + { + id: 'Disable AI Code Assistant', + title: window.deps.translateString('commandMenu.disableAI', 'Disable AI Code Assistant'), + mdIcon: 'toggle_off', + handler: () => { + changeEditorSettings({ enableAI: false }); + }, + }, + { + id: 'Enable Auto Update', + title: window.deps.translateString('commandMenu.enableAutoUpdate', 'Enable Auto Update'), + keywords: 'autoupdate', + mdIcon: 'update', + handler: () => { + changeMenuSetting('autoupdate', true); + }, + }, + { + id: 'Disable Auto Update', + title: window.deps.translateString( + 'commandMenu.disableAutoUpdate', + 'Disable Auto Update', + ), + keywords: 'autoupdate', + mdIcon: 'update_disabled', + handler: () => { + changeMenuSetting('autoupdate', false); + }, + }, + { + id: 'Enable Auto Save', + title: window.deps.translateString('commandMenu.enableAutoSave', 'Enable Auto Save'), + keywords: 'toggle_on', + mdIcon: 'label', + handler: () => { + changeMenuSetting('autosave', true); + }, + }, + { + id: 'Disable Auto Save', + title: window.deps.translateString('commandMenu.disableAutoSave', 'Disable Auto Save'), + keywords: 'autosave', + mdIcon: 'label_off', + handler: () => { + changeMenuSetting('autosave', false); + }, + }, + { + id: 'Enable Format On-Save', + title: window.deps.translateString( + 'commandMenu.enableFormatOnSave', + 'Enable Format On-Save', + ), + keywords: 'onsave', + mdIcon: 'format_align_left', + handler: () => { + changeMenuSetting('formatOnsave', true); + }, + }, + { + id: 'Disable Format On-Save', + title: window.deps.translateString( + 'commandMenu.disableFormatOnSave', + 'Disable Format On-Save', + ), + keywords: 'onsave', + mdIcon: 'filter_list_off', + handler: () => { + changeMenuSetting('formatOnsave', false); + }, + }, + { + id: 'Enable Recover Unsaved', + title: window.deps.translateString( + 'commandMenu.enableRecoverUnsaved', + 'Enable Recover Unsaved', + ), + mdIcon: 'update', + handler: () => { + changeMenuSetting('recoverUnsaved', true); + }, + }, + { + id: 'Disable Recover Unsaved', + title: window.deps.translateString( + 'commandMenu.disableRecoverUnsaved', + 'Disable Recover Unsaved', + ), + mdIcon: 'update_disabled', + handler: () => { + changeMenuSetting('recoverUnsaved', false); + }, + }, + { + id: 'Enable Vim Mode', + title: window.deps.translateString('commandMenu.enableVim', 'Enable Vim Mode'), + mdIcon: 'edit', + handler: () => { + changeEditorSettings({ editorMode: 'vim' }); + }, + }, + { + id: 'Disable Vim Mode', + title: window.deps.translateString('commandMenu.disableVim', 'Disable Vim Mode'), + mdIcon: 'edit_off', + handler: () => { + changeEditorSettings({ editorMode: undefined }); + }, + }, + { + id: 'Enable Emacs Mode', + title: window.deps.translateString('commandMenu.enableEmacs', 'Enable Emacs Mode'), + mdIcon: 'edit', + handler: () => { + changeEditorSettings({ editorMode: 'emacs' }); + }, + }, + { + id: 'Disable Emacs Mode', + title: window.deps.translateString('commandMenu.disableEmacs', 'Disable Emacs Mode'), + mdIcon: 'edit_off', + handler: () => { + changeEditorSettings({ editorMode: undefined }); + }, + }, + { + id: 'Responsive Layout', + title: window.deps.translateString('commandMenu.responsiveLayout', 'Responsive Layout'), + // mdIcon: 'responsive_layout', + icon: icons.responsive_layout, + handler: () => { + changeLayout('responsive'); + }, + }, + { + id: 'Vertical Layout', + title: window.deps.translateString('commandMenu.verticalLayout', 'Vertical Layout'), + mdIcon: 'stay_current_portrait', + handler: () => { + changeLayout('vertical'); + }, + }, + { + id: 'Horizontal Layout', + title: window.deps.translateString('commandMenu.horizontalLayout', 'Horizontal Layout'), + mdIcon: 'stay_current_landscape', + handler: () => { + changeLayout('horizontal'); + }, + }, + ], + }, + { + id: 'Format Code', + title: window.deps.translateString('commandMenu.formatCode', 'Format Code'), + hotkey: 'shift+alt+f', + mdIcon: 'format_align_left', + handler: () => { + UI.getFormatButton()?.click(); + }, + }, + { + id: 'Copy Code', + title: window.deps.translateString('commandMenu.copy', 'Copy Code'), + mdIcon: 'content_copy', + handler: () => { + UI.getCopyButton()?.click(); + }, + }, + { + id: 'Copy Code as Data URL', + title: window.deps.translateString('commandMenu.copyAsDataUrl', 'Copy Code as Data URL'), + keywords: 'base64', + mdIcon: 'dataset_linked', + handler: () => { + UI.getCopyAsUrlButton()?.click(); + }, + }, + { + id: 'Code to Image', + title: window.deps.translateString('app.codeToImage.hint', 'Code to Image'), + keywords: 'picture screenshot', + mdIcon: 'camera', + handler: () => { + UI.getCodeToImageButton()?.click(); + }, + }, + { + id: 'Run Tests', + title: window.deps.translateString('commandMenu.show.runTests', 'Run Tests'), + hotkey: 'ctrl+alt+T', + // mdIcon: 'labs', + icon: icons.labs, + handler: async () => { + UI.getRunTestsButton()?.click(); + }, + }, + { + id: 'Show result in new window', + title: window.deps.translateString('core.result.hint', 'Show result in new window'), + keywords: 'popup', + mdIcon: 'open_in_new', + handler: () => { + UI.getResultPopupButton()?.click(); + }, + }, + { + id: 'Focus Editor', + title: window.deps.translateString('commandMenu.focus.editor', 'Focus Editor'), + hotkey: 'ctrl+alt+e', + mdIcon: 'filter_center_focus', + handler: () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'E', code: 'KeyE', ctrlKey: true, altKey: true }), + ); + }, + }, + { + id: 'Move Focus out of Editor', + title: window.deps.translateString( + 'commandMenu.focus.outOfEditor', + 'Move Focus out of Editor', + ), + hotkey: 'esc+esc', + // mdIcon: 'reset_focus', + icon: icons.reset_focus, + handler: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + }, + }, + { + id: 'Move Focus to Home', + title: window.deps.translateString('commandMenu.focus.home', 'Move Focus to Home'), + hotkey: 'esc+esc+esc', + // mdIcon: 'pip_exit', + icon: icons.pip_exit, + handler: () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape' })); + }, + }, + { + id: 'Change UI Language', + title: window.deps.translateString('commandMenu.changeUILanguage', 'Change UI Language'), + mdIcon: 'language', + children: Object.entries(appLanguages).map(([key, lang]) => ({ + id: 'UI Language: ' + key, + title: lang, + keywords: key, + matcher: ( + _action: INinjaAction, + { searchString }: { searchString: string; searchRegex: RegExp }, + ) => key.includes(searchString.toLowerCase()) || lang.includes(searchString), + handler: async () => { + document + .querySelector('#app-menu-i18n a[data-lang="' + key + '"]') + ?.click(); + }, + })), + }, + { + id: 'Change Theme', + title: window.deps.translateString('commandMenu.changeTheme.title', 'Change Theme'), + mdIcon: 'palette', + children: [ + { + id: 'Light Theme', + title: window.deps.translateString( + 'commandMenu.changeTheme.light', + 'Change to Light Theme', + ), + mdIcon: 'light_mode', + handler: () => { + UI.getDarkThemeButton()?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Dark Theme', + title: window.deps.translateString( + 'commandMenu.changeTheme.dark', + 'Change to Dark Theme', + ), + mdIcon: 'dark_mode', + handler: () => { + UI.getLightThemeButton()?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Set Theme Color', + title: window.deps.translateString('commandMenu.theme.color', 'Set Theme Color'), + mdIcon: 'palette', + handler: () => { + UI.getCustomThemeColorInput()?.click(); + }, + }, + { + id: 'Set Default Theme Color', + title: window.deps.translateString( + 'commandMenu.theme.defaultColor', + 'Set Default Theme Color', + ), + mdIcon: 'palette', + handler: () => { + UI.getThemeColorContainer()?.querySelector('input')?.click(); + }, + }, + ], + }, + { + id: 'Documentation', + title: window.deps.translateString('menu.docs', 'Documentation'), + mdIcon: 'menu_book', + children: [ + { + id: 'Documentation Home', + title: window.deps.translateString('menu.docs', 'Documentation'), + mdIcon: 'menu_book', + handler: () => { + UI.getHelpMenu()?.querySelector('a[data-i18n="menu.docs"]')?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Getting Started', + title: window.deps.translateString('menu.getstart', 'Getting Started'), + mdIcon: 'start', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.getstart"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Features', + title: window.deps.translateString('menu.features', 'Features'), + mdIcon: 'widgets', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.features"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Configuration', + title: window.deps.translateString('menu.config', 'Configuration'), + mdIcon: 'tune', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.config"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'SDK', + title: window.deps.translateString('menu.sdk', 'SDK'), + // mdIcon: 'deployed_code', + icon: icons.deployed_code, + handler: () => { + UI.getHelpMenu()?.querySelector('a[data-i18n="menu.sdk"]')?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'LiveCodes Blog', + title: window.deps.translateString('menu.blog', 'LiveCodes Blog'), + mdIcon: 'newspaper', + handler: () => { + UI.getHelpMenu()?.querySelector('a[data-i18n="menu.blog"]')?.click(); + return { keepOpen: true }; + }, + }, + ], + }, + { + id: 'Contribute', + title: window.deps.translateString('commandMenu.contribute', 'Contribute'), + mdIcon: 'construction', + children: [ + { + id: 'Source code on GitHub', + title: window.deps.translateString('menu.source', 'Source code on GitHub'), + mdIcon: 'star', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.source"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Report an issue', + title: window.deps.translateString('menu.report', 'Report an issue'), + mdIcon: 'bug_report', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.report"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'License', + title: window.deps.translateString('menu.license', 'License'), + mdIcon: 'receipt_long', + handler: () => { + UI.getHelpMenu() + ?.querySelector('a[data-i18n="menu.license"]') + ?.click(); + return { keepOpen: true }; + }, + }, + { + id: 'Sponsor', + title: window.deps.translateString('about.sponsor.text', 'Sponsor'), + mdIcon: 'handshake', + handler: () => { + window.open(predefinedValues.DOCS_BASE_URL + 'sponsor', '_blank'); + return { keepOpen: true }; + }, + }, + ], + }, + ]; + + const loginAction: INinjaAction = { + id: 'Login', + title: window.deps.translateString('commandMenu.login', 'Login'), + mdIcon: 'login', + handler: () => { + UI.getLoginLink()?.click(); + }, + }; + + const logoutAction: INinjaAction = { + id: 'Logout', + title: window.deps.translateString('commandMenu.logout', 'Logout'), + mdIcon: 'logout', + handler: () => { + UI.getLogoutLink()?.click(); + }, + }; + + const aboutAction: INinjaAction = { + id: 'About', + title: window.deps.translateString('menu.about', 'About ...'), + mdIcon: 'contact_support', + handler: () => { + UI.getAboutLink()?.click(); + }, + }; + + const changeMenuSetting = (setting: keyof Config, checked: boolean) => { + const toggle = [...UI.getSettingToggles()].find((t) => t.dataset.config === setting); + if (toggle) { + toggle.checked = checked; + toggle.dispatchEvent(new Event('change')); + } + }; + + const isActionList = (list: INinjaAction[] | string[] | undefined): list is INinjaAction[] => + Boolean(list?.every((a) => typeof a !== 'string')); + + const traverseMenu = ( + data: INinjaAction[], + transform: (action: INinjaAction) => INinjaAction, + ): INinjaAction[] => + data.map((action) => ({ + ...transform(action), + children: isActionList(action.children) + ? traverseMenu(action.children, transform) + : action.children, + })); + + const transformAction = (action: INinjaAction): INinjaAction => ({ + ...action, + title: !action.children?.length + ? action.title.replace(' …', '').replace(' ...', '') + : action.title.endsWith(' …') || action.title.endsWith(' ...') + ? action.title + : action.title + ' …', + hotkey: isMac() ? action.hotkey?.replace(/ctrl/g, '⌘') : action.hotkey, + }); + + const flatten = (list: INinjaAction[]): INinjaAction[] => + list.flatMap((a) => [a, ...(isActionList(a.children) ? flatten(a.children) : [])]); + + const getKeyboardShortcutList = (list: INinjaAction[]): INinjaAction[] => + flatten(list).filter((a) => a.hotkey); + + const keyboardShortcuts: INinjaAction[] = [ + { + id: 'Editor Keyboard Shortcuts', + title: window.deps.translateString( + 'commandMenu.editorKeyboardShortcuts', + 'Editor Keyboard Shortcuts', + ), + mdIcon: 'north_east', + handler: () => { + window.open( + 'https://code.visualstudio.com/docs/getstarted/keybindings#_basic-editing', + '_blank', + 'noopener,noreferrer', + ); + }, + }, + ...getKeyboardShortcutList(actions), + { + id: 'Command Menu Home', + title: window.deps.translateString('commandMenu.home', 'Home'), + mdIcon: 'home', + handler: () => { + UI.getCommandMenuLink()?.click(); + }, + }, + ]; + + const keyboardShortcutsAction: INinjaAction = { + id: 'Keyboard', + title: window.deps.translateString('commandMenu.keyboardShortcuts', 'Keyboard Shortcuts'), + mdIcon: 'keyboard', + handler: () => { + UI.getKeyboardShortcutsMenuLink()?.click(); + }, + }; + + return { + actions: traverseMenu([...actions, keyboardShortcutsAction, aboutAction], transformAction), + keyboardShortcuts: traverseMenu(keyboardShortcuts, transformAction), + loginAction, + logoutAction, + }; +}; + +const icons = { + split_scene: ``, + code_blocks: ``, + labs: ``, + deployed_code_update: ``, + reset_focus: ``, + pip_exit: ``, + responsive_layout: ``, + deployed_code: ``, + manufacturing: ``, +}; diff --git a/src/livecodes/UI/selectors.ts b/src/livecodes/UI/selectors.ts index 2fb1d59d7..0efffe927 100644 --- a/src/livecodes/UI/selectors.ts +++ b/src/livecodes/UI/selectors.ts @@ -32,6 +32,7 @@ export const getGutterElement = /* @__PURE__ */ () => export const getLogoLink = /* @__PURE__ */ () => document.querySelector('a#logo') as HTMLAnchorElement; + export const getRunButton = /* @__PURE__ */ () => document.querySelector('#run-button') as HTMLElement; @@ -44,6 +45,15 @@ export const getDarkThemeButton = /* @__PURE__ */ () => export const getI18nMenuContainer = /* @__PURE__ */ () => document.querySelector('#app-menu-container-i18n') as HTMLElement; +export const getMarkupEditorTitle = /* @__PURE__ */ () => + document.querySelector('#markup-selector') as HTMLElement; + +export const getStyleEditorTitle = /* @__PURE__ */ () => + document.querySelector('#style-selector') as HTMLElement; + +export const getScriptEditorTitle = /* @__PURE__ */ () => + document.querySelector('#script-selector') as HTMLElement; + export const getEditorToolbar = /* @__PURE__ */ () => document.querySelector('#editor-tools') as HTMLElement; @@ -99,8 +109,10 @@ export const getFullscreenButton = /* @__PURE__ */ () => export const getEditorTitles = /* @__PURE__ */ () => document.querySelectorAll('.editor-title:not(.hidden)'); + export const getEditorDivs = /* @__PURE__ */ () => document.querySelectorAll('#editors > .editor'); + export const getToolspaneElement = /* @__PURE__ */ () => document.querySelector('#output #tools-pane') as HTMLElement; @@ -112,10 +124,28 @@ export const getToolspaneButtons = /* @__PURE__ */ () => export const getToolspaneTitles = /* @__PURE__ */ () => document.querySelector('#tools-pane-titles'); + +export const getConsoleButton = /* @__PURE__ */ () => + document.querySelector('#tools-pane-titles > .console'); + +export const getCompiledButton = /* @__PURE__ */ () => + document.querySelector('#tools-pane-titles > .compiled'); + +export const getTestsButton = /* @__PURE__ */ () => + document.querySelector('#tools-pane-titles > .tests'); + export const getToolspaneLoader = /* @__PURE__ */ () => document.querySelector('#tools-pane-loading'); + +export const getZoomButton = /* @__PURE__ */ () => + document.querySelector('#zoom-button'); + export const getZoomButtonValue = /* @__PURE__ */ () => document.querySelector('#zoom-button #zoom-value'); + +export const getResultPopupButton = /* @__PURE__ */ () => + document.querySelector('#result-popup-btn'); + export const getModalSaveButton = /* @__PURE__ */ () => document.querySelector('#modal #prompt-save-btn') as HTMLElement; @@ -163,16 +193,19 @@ export const getCssPresetLinks = /* @__PURE__ */ () => export const getAppMenuProjectScroller = /* @__PURE__ */ () => document.querySelector('#app-menu-container-project'); + export const getAppMenuProjectButton = /* @__PURE__ */ () => document.querySelector('#app-menu-button-project'); export const getAppMenuSettingsScroller = /* @__PURE__ */ () => document.querySelector('#app-menu-container-settings'); + export const getAppMenuSettingsButton = /* @__PURE__ */ () => document.querySelector('#app-menu-button-settings'); export const getAppMenuHelpScroller = /* @__PURE__ */ () => document.querySelector('#app-menu-container-help'); + export const getAppMenuHelpButton = /* @__PURE__ */ () => document.querySelector('#app-menu-button-help'); @@ -254,6 +287,12 @@ export const getWelcomeLink = /* @__PURE__ */ () => export const getAboutLink = /* @__PURE__ */ () => document.querySelector('#about-link'); +export const getCommandMenuLink = /* @__PURE__ */ () => + document.querySelector('#command-menu-link'); + +export const getKeyboardShortcutsMenuLink = /* @__PURE__ */ () => + document.querySelector('#keyboard-shortcuts-menu-link'); + export const getAutoupdateToggle = /* @__PURE__ */ () => document.querySelector('#app-menu-settings input#autoupdate') as HTMLInputElement; @@ -290,6 +329,12 @@ export const getShowWelcomeToggle = /* @__PURE__ */ () => export const getRecoverToggle = /* @__PURE__ */ () => document.querySelector('#app-menu-settings input#recover-unsaved') as HTMLInputElement; +export const getThemeColorContainer = /* @__PURE__ */ () => + document.querySelector('#theme-color-selector') as HTMLElement; + +export const getCustomThemeColorInput = /* @__PURE__ */ () => + document.querySelector('#theme-color-custom') as HTMLInputElement; + export const getSpacingToggle = /* @__PURE__ */ () => document.querySelector('#app-menu-settings input#show-spacing') as HTMLInputElement; @@ -305,6 +350,9 @@ export const getAssetsLink = /* @__PURE__ */ () => export const getSnippetsLink = /* @__PURE__ */ () => document.querySelector('#app-menu-settings #snippets-link') as HTMLInputElement; +export const getHelpMenu = /* @__PURE__ */ () => + document.querySelector('#app-menu-help') as HTMLElement; + export const getInfoTitleInput = /* @__PURE__ */ () => document.querySelector('#info-container input#title-input') as HTMLInputElement; @@ -349,26 +397,34 @@ export const getWatchTestsButton = /* @__PURE__ */ () => export const getUrlImportForm = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#url-import-form'); + export const getUrlImportButton = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#url-import-btn') as HTMLButtonElement; + export const getUrlImportInput = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#code-url') as HTMLInputElement; + export const getCodeImportInput = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#local-code-input') as HTMLInputElement; export const getImportJsonUrlForm = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#json-url-import-form') as HTMLInputElement; + export const getImportJsonUrlButton = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#json-url-import-btn') as HTMLInputElement; + export const getImportJsonUrlInput = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#json-url') as HTMLInputElement; export const getBulkImportJsonUrlForm = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#bulk-json-url-import-form') as HTMLInputElement; + export const getBulkImportJsonUrlButton = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#bulk-json-url-import-btn') as HTMLInputElement; + export const getBulkImportJsonUrlInput = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#bulk-json-url') as HTMLInputElement; + export const getLinkToSavedProjects = /* @__PURE__ */ (importContainer: HTMLElement) => importContainer.querySelector('#link-to-saved-projects') as HTMLAnchorElement; @@ -383,30 +439,40 @@ export const getBulkImportFileInput = /* @__PURE__ */ (importContainer: HTMLElem export const getNewRepoForm = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-form'); + export const getNewRepoButton = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-btn') as HTMLButtonElement; + export const getNewRepoNameInput = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-name') as HTMLInputElement; + export const getNewRepoNameError = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-name-error') as HTMLElement; export const getNewRepoMessageInput = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-message') as HTMLInputElement; + export const getNewRepoCommitSource = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-source') as HTMLInputElement; + export const getNewRepoAutoSync = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#new-repo-autosync') as HTMLInputElement; export const getExistingRepoForm = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-form'); + export const getExistingRepoButton = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-btn') as HTMLButtonElement; + export const getExistingRepoNameInput = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-name') as HTMLInputElement; + export const getExistingRepoMessageInput = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-message') as HTMLInputElement; + export const getExistingRepoCommitSource = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-source') as HTMLInputElement; + export const getExistingRepoAutoSync = /* @__PURE__ */ (deployContainer: HTMLElement) => deployContainer.querySelector('#existing-repo-autosync') as HTMLInputElement; @@ -414,8 +480,10 @@ export const getStarterTemplatesTab = /* @__PURE__ */ (templatesContainer: HTMLE templatesContainer.querySelector( '#templates-tabs [data-target="templates-starter"]', ); + export const getStarterTemplatesList = /* @__PURE__ */ (templatesContainer: HTMLElement) => templatesContainer.querySelector('#starter-templates-list'); + export const getUserTemplatesScreen = /* @__PURE__ */ (templatesContainer: HTMLElement) => templatesContainer.querySelector('#templates-user .modal-screen') as HTMLElement; @@ -567,5 +635,7 @@ export const getModalWelcomeRecentList = /* @__PURE__ */ (welcomeContainer: HTML export const getModalWelcomeTemplateList = /* @__PURE__ */ (welcomeContainer: HTMLElement) => welcomeContainer.querySelector('#modal #welcome-template-list') as HTMLElement; +export const getNinjaKeys = /* @__PURE__ */ () => document.querySelector('ninja-keys') as any; + export const getResultModeDrawer = /* @__PURE__ */ () => document.querySelector('#result-mode-drawer') as HTMLElement; diff --git a/src/livecodes/core.ts b/src/livecodes/core.ts index edbf6f91c..be6065104 100644 --- a/src/livecodes/core.ts +++ b/src/livecodes/core.ts @@ -111,6 +111,7 @@ import { predefinedValues, colorToHex, capitalize, + isMac, } from './utils'; import { compress } from './utils/compression'; import { getCompiler, getAllCompilers, cjs2esm, getCompileResult } from './compiler'; @@ -120,12 +121,15 @@ import * as UI from './UI/selectors'; import { createAuthService, getAppCDN, sandboxService, shareService } from './services'; import { cacheIsValid, getCache, getCachedCode, setCache, updateCache } from './cache'; import { + fontInterUrl, + fontMaterialIconsUrl, fscreenUrl, jestTypesUrl, lunaConsoleStylesUrl, lunaDataGridStylesUrl, lunaDomViewerStylesUrl, lunaObjViewerStylesUrl, + ninjaKeysUrl, snackbarUrl, } from './vendors'; import { createToolsPane } from './toolspane'; @@ -155,6 +159,7 @@ import type { } from './i18n'; import { appLanguages } from './i18n/app-languages'; import { themeColors } from './UI/theme-colors'; +import { getCommandMenuActions } from './UI/command-menu-actions'; // declare global dependencies declare global { @@ -180,7 +185,7 @@ declare global { const stores: Stores = createStores(); const eventsManager = createEventsManager(); let notifications: ReturnType; -let modal: ReturnType; +export let modal: ReturnType; let i18n: Await> | undefined; let split: ReturnType | null = null; let typeLoader: ReturnType; @@ -197,7 +202,7 @@ let formatter: Formatter; let editors: Editors; let customEditors: CustomEditors; let toolsPane: ToolsPane | undefined; -let authService: ReturnType | undefined; +export let authService: ReturnType | undefined; let editorLanguages: EditorLanguages | undefined; let resultLanguages: Language[] = []; let projectId: string; @@ -385,19 +390,25 @@ const highlightSelectedLanguage = (editorId: EditorId, language: Language) => { }; const setEditorTitle = (editorId: EditorId, title: string) => { + const editorIds: EditorId[] = ['markup', 'style', 'script']; const editorTitle = document.querySelector(`#${editorId}-selector span`) as HTMLElement; const language = getLanguageByAlias(title); if (!editorTitle || !language) return; highlightSelectedLanguage(editorId, language); + const shortcut = ` (Ctrl/⌘ + Alt + ${editorIds.indexOf(editorId) + 1})`; const customTitle = getConfig()[editorId].title; if (customTitle) { editorTitle.textContent = customTitle; - editorTitle.title = `${capitalize(editorId)}: ${customTitle}`; + if (!isEmbed) { + editorTitle.title = `${capitalize(editorId)}: ${customTitle}${shortcut}`; + } return; } const lang = languages.find((lang) => lang.name === language); editorTitle.textContent = lang?.title ?? ''; - editorTitle.title = `${capitalize(editorId)}: ${lang?.longTitle ?? lang?.title ?? ''}`; + if (!isEmbed) { + editorTitle.title = `${capitalize(editorId)}: ${lang?.longTitle ?? lang?.title ?? ''}${shortcut}`; + } }; const createCopyButtons = () => { @@ -619,6 +630,9 @@ const showMode = (mode?: Config['mode']) => { if ((mode === 'full' || mode === 'simple') && !split) { split = createSplitPanes(); } + if (mode === 'focus') { + toolsPane?.setActiveTool('console'); + } window.dispatchEvent(new Event(customEvents.resizeEditor)); }; @@ -1780,6 +1794,7 @@ const setTheme = (theme: Theme, editorTheme: Config['editorTheme']) => { customEditors[editor?.getLanguage()]?.setTheme(theme); }); toolsPane?.console?.setTheme?.(theme); + UI.getNinjaKeys()?.classList.toggle('dark', theme === 'dark'); }; const changeThemeColor = () => { @@ -2346,63 +2361,280 @@ const handleChangeContent = () => { }); }; -const handleHotKeys = () => { - const ctrl = (e: KeyboardEvent) => (navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey); - const hotKeys = async (e: KeyboardEvent) => { - if (!e) return; +const handleKeyboardShortcuts = () => { + let lastkeys = ''; + const ctrl = (e: KeyboardEvent) => (isMac() ? e.metaKey : e.ctrlKey); - // Ctrl + p opens the command palette + const hotKeys = async (e: KeyboardEvent) => { + // Ctrl + P opens the command palette const activeEditor = getActiveEditor(); - if (ctrl(e) && e.key.toLowerCase() === 'p' && activeEditor.monaco) { + if (ctrl(e) && e.code === 'KeyP' && activeEditor.monaco) { e.preventDefault(); activeEditor.monaco.trigger('anyString', 'editor.action.quickCommand'); + lastkeys = 'Ctrl + P'; return; } - // Ctrl + d prevents browser bookmark dialog - if (ctrl(e) && e.key.toLowerCase() === 'd') { + // Ctrl + D prevents browser bookmark dialog + if (ctrl(e) && e.code === 'KeyD') { e.preventDefault(); + lastkeys = 'Ctrl + D'; return; } - if (isEmbed) return; - - // Ctrl + Shift + S forks the project (save as...) - if (ctrl(e) && e.shiftKey && e.key.toLowerCase() === 's') { + // Ctrl + Alt + C: toggle console + if (ctrl(e) && e.altKey && e.code === 'KeyC') { e.preventDefault(); - await fork(); + lastkeys = 'Ctrl + Alt + C'; + UI.getConsoleButton()?.dispatchEvent(new Event('touchstart')); return; } - // Ctrl + S saves the project - if (ctrl(e) && e.key.toLowerCase() === 's') { + // Ctrl + Alt + C, F: maximize console + if (ctrl(e) && e.altKey && e.code === 'KeyF' && lastkeys === 'Ctrl + Alt + C') { e.preventDefault(); - await save(true); + lastkeys = 'Ctrl + Alt + C, F'; + UI.getConsoleButton()?.dispatchEvent(new Event('dblclick')); return; } // Ctrl + Alt + T runs tests - if (ctrl(e) && e.altKey && e.key.toLowerCase() === 't') { + if (ctrl(e) && e.altKey && e.code === 'KeyT') { e.preventDefault(); - split?.show('output'); - toolsPane?.setActiveTool('tests'); - if (toolsPane?.getStatus() === 'closed') { - toolsPane?.open(); - } - await runTests(); + UI.getRunTestsButton()?.click(); + lastkeys = 'Ctrl + Alt + T'; return; } // Shift + Enter triggers run if (e.shiftKey && e.key === 'Enter') { e.preventDefault(); - split?.show('output'); - await run(); + UI.getRunButton()?.click(); + lastkeys = 'Shift + Enter'; + return; + } + + // Ctrl + Alt + R toggles result page + if (ctrl(e) && e.altKey && e.code === 'KeyR') { + e.preventDefault(); + UI.getResultButton()?.click(); + lastkeys = 'Ctrl + Alt + R'; + return; + } + + // Ctrl + Alt + Z toggles result zoom + if (ctrl(e) && e.altKey && e.code === 'KeyZ') { + e.preventDefault(); + UI.getZoomButton()?.click(); + lastkeys = 'Ctrl + Alt + Z'; + return; + } + + // Ctrl + Alt + E focuses active editor + if (ctrl(e) && e.altKey && e.code === 'KeyE') { + e.preventDefault(); + getActiveEditor().focus(); + lastkeys = 'Ctrl + Alt + E'; + return; + } + + // Esc + Esc moves focus out of editor + // Esc + Esc + Esc moves focus to logo + if (e.code === 'Escape') { + if (lastkeys === 'Esc') { + e.preventDefault(); + UI.getFocusButton()?.focus(); + lastkeys = 'Esc + Esc'; + return; + } + if (lastkeys === 'Esc + Esc') { + e.preventDefault(); + UI.getLogoLink()?.focus(); + lastkeys = 'Esc + Esc + Esc'; + return; + } + lastkeys = 'Esc'; + return; + } + + // Ctrl + Alt + (1-3) activates editor 1-3 + // Ctrl + Alt + (ArrowLeft/ArrowRight) activates previous/next editor + const editorIds = ['markup', 'style', 'script']; + if (ctrl(e) && e.altKey && ['1', '2', '3', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault(); + split?.show('code'); + const index = ['1', '2', '3'].includes(e.key) + ? Number(e.key) - 1 + : e.key === 'ArrowLeft' + ? editorIds.findIndex((id) => id === getConfig().activeEditor) - 1 || 0 + : e.key === 'ArrowRight' + ? editorIds.findIndex((id) => id === getConfig().activeEditor) + 1 || 0 + : 0; + const editorIndex = index === 3 ? 0 : index === -1 ? 2 : index; + showEditor(editorIds[editorIndex] as EditorId); + lastkeys = 'Ctrl + Alt + ' + e.key; + return; + } + + if (isEmbed) return; + + // Ctrl + Alt + N: new project + if (ctrl(e) && e.altKey && e.code === 'KeyN') { + e.preventDefault(); + UI.getNewLink()?.click(); + lastkeys = 'Ctrl + Alt + N'; + return; + } + + // Ctrl + O: open project + if (ctrl(e) && e.code === 'KeyO') { + e.preventDefault(); + UI.getOpenLink()?.click(); + lastkeys = 'Ctrl + O'; + return; + } + + // Ctrl + Alt + I: import + if (ctrl(e) && e.altKey && e.code === 'KeyI') { + e.preventDefault(); + UI.getImportLink()?.click(); + lastkeys = 'Ctrl + Alt + I'; + return; + } + + // Ctrl + Alt + S: share + if (ctrl(e) && e.altKey && e.code === 'KeyS') { + e.preventDefault(); + UI.getShareLink()?.click(); + lastkeys = 'Ctrl + Alt + S'; + return; + } + + // Ctrl + Shift + S forks the project (save as...) + if (ctrl(e) && e.shiftKey && e.code === 'KeyS') { + e.preventDefault(); + UI.getForkLink()?.click(); + lastkeys = 'Ctrl + Shift + S'; + return; + } + + // Ctrl + S saves the project + if (ctrl(e) && e.code === 'KeyS') { + e.preventDefault(); + UI.getSaveLink()?.click(); + lastkeys = 'Ctrl + S'; + return; + } + + // Ctrl + Alt + F toggles focus mode + if (ctrl(e) && e.altKey && e.code === 'KeyF') { + e.preventDefault(); + UI.getFocusButton()?.click(); + lastkeys = 'Ctrl + Alt + F'; + return; + } + + if (!ctrl(e) && !e.altKey && !e.shiftKey) { + lastkeys = e.key; return; } }; - eventsManager.addEventListener(window, 'keydown', hotKeys as any, true); + eventsManager.addEventListener(window, 'keydown', hotKeys, true); +}; + +const handleCommandMenu = async () => { + if (isEmbed) return; + + const loadNinjaKeys = () => import(ninjaKeysUrl); + loadStylesheet(fontInterUrl, 'font-inter'); + loadStylesheet(fontMaterialIconsUrl, 'material-icons'); + await loadNinjaKeys(); + + const ninja = UI.getNinjaKeys() as any; + if (!ninja) return; + + const header = ninja.shadowRoot.querySelector('ninja-header'); + const HomeBreadcrumb = header?.shadowRoot.querySelector('.breadcrumb-list .breadcrumb'); + + const closeBtn = header?.shadowRoot.querySelector('.breadcrumb-list .breadcrumb--close'); + if (closeBtn) { + closeBtn.hidden = true; + } + + const footer = ninja.shadowRoot.querySelector('.modal-footer'); + if (footer) { + footer.innerHTML = footer.innerHTML + .replace('to select', window.deps.translateString('commandMenu.toSelect', 'to select')) + .replace('to navigate', window.deps.translateString('commandMenu.toNavigate', 'to navigate')) + .replace('to close', window.deps.translateString('commandMenu.toClose', 'to close')) + .replace( + 'move to parent', + window.deps.translateString('commandMenu.moveToParent', 'move to parent'), + ); + } + + const changeLayout = (layout: Config['layout']) => { + setUserConfig({ layout }); + setLayout(layout); + }; + + const openCommandMenu = (parent?: string) => { + modal.close(); + ninja.close(); + const { actions, keyboardShortcuts, loginAction, logoutAction } = getCommandMenuActions({ + deps: { + getConfig, + loadStarterTemplate, + changeEditorSettings, + changeLayout, + }, + }); + if (parent === 'Keyboard Shortcuts') { + ninja.data = keyboardShortcuts; + if (HomeBreadcrumb) { + HomeBreadcrumb.innerText = window.deps.translateString( + 'commandMenu.keyboardShortcuts', + 'Keyboard Shortcuts', + ); + } + } else { + const authAction = authService?.isLoggedIn() ? logoutAction : loginAction; + ninja.data = [...actions, authAction]; + if (parent) { + ninja.setParent(parent); + } + if (HomeBreadcrumb) { + HomeBreadcrumb.innerText = window.deps.translateString('commandMenu.home', 'Home'); + } + } + requestAnimationFrame(() => ninja.open()); + }; + + const onHotkey = async (e: KeyboardEvent) => { + const ctrl = (e: KeyboardEvent) => (isMac() ? e.metaKey : e.ctrlKey); + if (ctrl(e) && e.code === 'KeyK') { + e.preventDefault(); + // eslint-disable-next-line no-underscore-dangle + if (ninja.__visible == null) { + await loadNinjaKeys(); + } + // eslint-disable-next-line no-underscore-dangle + if (ninja.__visible === false) { + ninja.focus(); + requestAnimationFrame(() => openCommandMenu()); + } + } + }; + + eventsManager.addEventListener(window, 'keydown', onHotkey, true); + eventsManager.addEventListener(UI.getCommandMenuLink(), 'click', () => openCommandMenu(), true); + eventsManager.addEventListener( + UI.getKeyboardShortcutsMenuLink(), + 'click', + () => openCommandMenu('Keyboard Shortcuts'), + true, + ); }; const handleLogoLink = () => { @@ -2441,6 +2673,7 @@ const handleI18nMenu = () => { const link = document.createElement('a'); link.href = `#`; link.textContent = langLabel; + link.dataset.lang = langCode; eventsManager.addEventListener(link, 'click', (ev) => { ev.preventDefault(); if (langCode === getConfig().appLanguage) return; @@ -2485,15 +2718,11 @@ const handleEditorTools = () => { eventsManager.addEventListener(UI.getFocusButton(), 'click', () => { const config = getConfig(); - const currentMode = config.mode; - const newMode = currentMode === 'full' ? 'focus' : 'full'; + const newMode = config.mode === 'full' ? 'focus' : 'full'; setConfig({ ...config, mode: newMode, }); - if (newMode === 'focus') { - toolsPane?.setActiveTool('console'); - } showMode(newMode); }); @@ -2619,7 +2848,11 @@ const handleAppMenuProject = () => { const menuProjectContainer = UI.getAppMenuProjectScroller(); const menuProjectButton = UI.getAppMenuProjectButton(); if (!menuProjectContainer || !menuProjectButton) return; - menuProjectContainer.innerHTML = menuProjectHTML; // settingsMenuHTML; + + const html = isMac() + ? menuProjectHTML.replace(/Ctrl<\/kbd>/g, '') + : menuProjectHTML; + menuProjectContainer.innerHTML = html; translateElement(menuProjectContainer); // adjustFontSize(menuProjectContainer); @@ -2642,7 +2875,11 @@ const handleAppMenuSettings = () => { const menuSettingsContainer = UI.getAppMenuSettingsScroller(); const menuSettingsButton = UI.getAppMenuSettingsButton(); if (!menuSettingsContainer || !menuSettingsButton) return; - menuSettingsContainer.innerHTML = menuSettingsHTML; // settingsMenuHTML; + + const html = isMac() + ? menuSettingsHTML.replace(/Ctrl<\/kbd>/g, '') + : menuSettingsHTML; + menuSettingsContainer.innerHTML = html; translateElement(menuSettingsContainer); adjustFontSize(menuSettingsContainer); @@ -2666,7 +2903,9 @@ const handleAppMenuHelp = () => { const menuHelpContainer = UI.getAppMenuHelpScroller(); const menuHelpButton = UI.getAppMenuHelpButton(); if (!menuHelpContainer || !menuHelpButton) return; - menuHelpContainer.innerHTML = menuHelpHTML; + + const html = isMac() ? menuHelpHTML.replace(/Ctrl<\/kbd>/g, '') : menuHelpHTML; + menuHelpContainer.innerHTML = html; translateElement(menuHelpContainer); // adjustFontSize(menuHelpContainer); @@ -3736,22 +3975,25 @@ const handleEmbed = () => { registerScreen('embed', createEmbedUI); }; -const handleEditorSettings = () => { - const changeSettings = (newConfig: Partial | null) => { - if (!newConfig) return; - const shouldReload = newConfig.editor !== getConfig().editor; - - setUserConfig(newConfig); - const updatedConfig = getConfig(); - setTheme(updatedConfig.theme, updatedConfig.editorTheme); - if (shouldReload) { - reloadEditors(updatedConfig); - } else { - getAllEditors().forEach((editor) => editor.changeSettings(updatedConfig)); - } - showEditorModeStatus(updatedConfig.activeEditor || 'markup'); - }; +const changeEditorSettings = (newConfig: Partial | null) => { + if (!newConfig) return; + const shouldReload = newConfig.editor != null && newConfig.editor !== getConfig().editor; + setUserConfig(newConfig); + const updatedConfig = getConfig(); + setTheme(updatedConfig.theme, updatedConfig.editorTheme); + if (shouldReload) { + reloadEditors(updatedConfig); + } else { + getAllEditors().forEach((editor) => { + editor.changeSettings(updatedConfig); + }); + } + showEditorModeStatus(updatedConfig.activeEditor || 'markup'); + getActiveEditor().focus(); +}; + +const handleEditorSettings = () => { const createEditorSettingsUI = async ({ scrollToSelector = '', }: { scrollToSelector?: string } = {}) => { @@ -3770,7 +4012,7 @@ const handleEditorSettings = () => { createEditor, loadTypes: async (code: string) => typeLoader.load(code, {}), getFormatFn: () => formatter.getFormatFn('jsx'), - changeSettings, + changeSettings: changeEditorSettings, }, }); }; @@ -4155,6 +4397,13 @@ const handleTests = () => { 'click', (ev: Event) => { ev.preventDefault(); + if (!toolsPane?.tests) return; + // in case it is triggered by keyboard shortcut or command menu + split?.show('output'); + toolsPane.setActiveTool('tests'); + if (toolsPane.getStatus() === 'closed') { + toolsPane.open(); + } runTests(); }, false, @@ -4330,7 +4579,7 @@ const handleResultZoom = () => { const zoomBtn = document.createElement('div'); zoomBtn.id = 'zoom-button'; zoomBtn.classList.add('tool-buttons'); - zoomBtn.title = window.deps.translateString('core.zoom.hint', 'Zoom'); + zoomBtn.title = window.deps.translateString('core.zoom.hint', 'Zoom') + ' (Ctrl/Cmd + Alt + Z)'; zoomBtn.style.pointerEvents = 'all'; // override setting to 'none' on toolspane bar zoomBtn.innerHTML = `