From cde9d062beed3c4bd9c9668df4a5b9d69ff31733 Mon Sep 17 00:00:00 2001 From: Denis 'GeneralGDA' Gladkiy Date: Wed, 16 Jul 2025 12:02:04 +0600 Subject: [PATCH] Windows app title bar menu. (#9542) Signed-off-by: Denis Gladkiy --- README.md | 4 +- common/config/rush/pnpm-lock.yaml | 63 ++- desktop/jest.config.js | 18 + desktop/package.json | 9 +- .../__test__/main/selfCheckingNode.test.ts | 22 + desktop/src/__test__/ui/menuBuilder.test.ts | 365 ++++++++++++ .../src/__test__/ui/selfCheckingDom.test.ts | 24 + .../src/__test__/ui/titleBarMenuState.test.ts | 301 ++++++++++ desktop/src/__test__/ui/typesUtils.test.ts | 29 + desktop/src/main/customMenu.ts | 92 +++ desktop/src/main/{menu.ts => standardMenu.ts} | 11 +- desktop/src/main/start.ts | 95 +++- desktop/src/ui/index.ejs | 225 ++++++++ desktop/src/ui/index.ts | 47 +- desktop/src/ui/notifications.ts | 9 +- desktop/src/ui/platform.ts | 25 +- desktop/src/ui/preload.ts | 26 +- desktop/src/ui/screenShare.ts | 6 +- desktop/src/ui/titleBarMenu.ts | 525 ++++++++++++++++++ desktop/src/ui/titleBarMenuState.ts | 122 ++++ desktop/src/ui/types.ts | 27 +- desktop/src/ui/typesUtils.ts | 24 + desktop/webpack.config.js | 11 + packages/theme/styles/global.scss | 15 +- .../ui/src/components/internal/Root.svelte | 33 +- .../src/components/LoginApp.svelte | 2 +- 26 files changed, 2047 insertions(+), 83 deletions(-) create mode 100644 desktop/jest.config.js create mode 100644 desktop/src/__test__/main/selfCheckingNode.test.ts create mode 100644 desktop/src/__test__/ui/menuBuilder.test.ts create mode 100644 desktop/src/__test__/ui/selfCheckingDom.test.ts create mode 100644 desktop/src/__test__/ui/titleBarMenuState.test.ts create mode 100644 desktop/src/__test__/ui/typesUtils.test.ts create mode 100644 desktop/src/main/customMenu.ts rename desktop/src/main/{menu.ts => standardMenu.ts} (71%) create mode 100644 desktop/src/ui/titleBarMenu.ts create mode 100644 desktop/src/ui/titleBarMenuState.ts create mode 100644 desktop/src/ui/typesUtils.ts diff --git a/README.md b/README.md index 19df2773d7..4dd9d92217 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ sudo npm install -g @microsoft/rush #### WSL Configuration -Edit the `/etc/wsl.conf` file in WSL (e.g., `sudo nano /etc/wsl.conf`) and add the following content if it doesn't exist: +If the source code is located on a Windows NTFS drive, then edit the `/etc/wsl.conf` file in WSL (e.g., `sudo nano /etc/wsl.conf`) and add the following content if it doesn't exist: ```ini [automount] @@ -332,6 +332,8 @@ options = "metadata,umask=22,fmask=11" appendWindowsPath = false ``` +However, we recommend storing the repository on a WSL disk, as this dramatically improves build and maintenance operations. + ### Running the Application After these preparations, the build instructions should work without issues. diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 18be30f76f..f0469c9585 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -282,7 +282,7 @@ importers: version: file:projects/datalake.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2) '@rush-temp/desktop': specifier: file:./projects/desktop.tgz - version: file:projects/desktop.tgz(@babel/core@7.23.9)(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4) + version: file:projects/desktop.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4) '@rush-temp/desktop-1': specifier: file:./projects/desktop-1.tgz version: file:projects/desktop-1.tgz(webpack@5.97.1) @@ -1423,6 +1423,9 @@ importers: '@telegraf/entity': specifier: ^0.5.0 version: 0.5.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 '@tiptap/core': specifier: ^2.11.7 version: 2.11.7(@tiptap/pm@2.11.7) @@ -2303,6 +2306,9 @@ packages: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} + '@adobe/css-tools@4.4.3': + resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -4216,7 +4222,7 @@ packages: version: 0.0.0 '@rush-temp/desktop@file:projects/desktop.tgz': - resolution: {integrity: sha512-VqYfpEML7x0UunCGG5DzQaRWyrTPlfazb33/1O3HtQJWutFSqZOwTrqZlOjaK9JASToMcjZWn3QVvS0r4NzJkQ==, tarball: file:projects/desktop.tgz} + resolution: {integrity: sha512-Sb8w7pX5RxmFjOGtXHVJJc1AAqeqMpJi7v1/SHEQXmcK+SDghXtatRyCynNNEYPmwQgRIxs2u35Jjga5LVo0/w==, tarball: file:projects/desktop.tgz} version: 0.0.0 '@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz': @@ -5985,6 +5991,10 @@ packages: '@telegraf/types@7.1.0': resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@tiptap/core@2.11.7': resolution: {integrity: sha512-zN+NFFxLsxNEL8Qioc+DL6b8+Tt2bmRbXH22Gk6F6nD30x83eaUSFlSv3wqvgyCq3I1i1NO394So+Agmayx6rQ==} peerDependencies: @@ -7558,6 +7568,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -7943,6 +7957,9 @@ packages: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -8403,6 +8420,9 @@ packages: docx4js@3.2.20: resolution: {integrity: sha512-u7kfMRYAHsczusgnrRAnZ0bXSF1HqiQmKtAI5LNIoQfrYXUHmaukcXnJR98KEFW3fSVdYKuLyHP9vKhrJKzetw==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-converter@0.2.0: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} @@ -12156,6 +12176,10 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -13893,6 +13917,8 @@ snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} + '@adobe/css-tools@4.4.3': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.2.1': @@ -18466,8 +18492,10 @@ snapshots: - supports-color - ts-node - '@rush-temp/desktop@file:projects/desktop.tgz(@babel/core@7.23.9)(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)': + '@rush-temp/desktop@file:projects/desktop.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4)': dependencies: + '@testing-library/jest-dom': 6.6.3 + '@types/jest': 29.5.12 '@types/node': 22.15.29 '@types/ws': 8.5.11 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) @@ -18499,6 +18527,7 @@ snapshots: file-loader: 6.2.0(webpack@5.97.1) fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.8.3)(webpack@5.97.1) html-webpack-plugin: 5.6.0(webpack@5.97.1) + jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) livekit-client: 2.13.3 mini-css-extract-plugin: 2.8.0(webpack@5.97.1) node-loader: 2.0.0(webpack@5.97.1) @@ -18512,6 +18541,7 @@ snapshots: svelte-loader: 3.2.0(svelte@4.2.19) svelte-preprocess: 5.1.3(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(sass@1.71.1)(svelte@4.2.19)(typescript@5.8.3) svgo-loader: 3.0.3 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) ts-loader: 9.5.1(typescript@5.8.3)(webpack@5.97.1) ts-node: 10.9.2(@types/node@22.15.29)(typescript@5.8.3) ts-node-dev: 2.0.0(@types/node@22.15.29)(typescript@5.8.3) @@ -18524,11 +18554,14 @@ snapshots: ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@6.0.4) transitivePeerDependencies: - '@babel/core' + - '@jest/types' - '@rspack/core' - '@swc/core' - '@swc/wasm' - '@types/dom-mediacapture-record' - '@webpack-cli/generators' + - babel-jest + - babel-plugin-macros - bufferutil - coffeescript - debug @@ -28870,6 +28903,16 @@ snapshots: '@telegraf/types@7.1.0': {} + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.3 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + '@tiptap/core@2.11.7(@tiptap/pm@2.11.7)': dependencies: '@tiptap/pm': 2.11.7 @@ -30763,6 +30806,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -31207,6 +31255,8 @@ snapshots: css-what@6.1.0: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} csso@4.2.0: @@ -31661,6 +31711,8 @@ snapshots: htmlparser2: 3.10.1 jszip: 2.7.0 + dom-accessibility-api@0.6.3: {} + dom-converter@0.2.0: dependencies: utila: 0.4.0 @@ -36144,6 +36196,11 @@ snapshots: dependencies: resolve: 1.22.8 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.6: dependencies: call-bind: 1.0.7 diff --git a/desktop/jest.config.js b/desktop/jest.config.js new file mode 100644 index 0000000000..a447f7dd26 --- /dev/null +++ b/desktop/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + projects: [ + { + displayName: 'node', + testEnvironment: 'node', + preset: 'ts-jest', + testMatch: ['/src/__test__/main/**/*.test.ts'] + }, + { + displayName: 'jsdom', + testEnvironment: 'jsdom', + preset: 'ts-jest', + testMatch: ['/src/__test__/ui/**/*.test.ts'] + } + ], + roots: ["./src", "./tests"], + coverageReporters: ["text-summary", "html"], +} diff --git a/desktop/package.json b/desktop/package.json index 18d4258ecf..ce2725dfc8 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -8,12 +8,15 @@ "build:watch": "tsc", "_phase:package": "rushx package", "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent --forceExit --detectOpenHandles", "package": "rushx bump && cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production NODE_OPTIONS='--max-old-space-size=4094' webpack --stats-error-details && echo 'done'", "webpack": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --stats-error-details --progress -w", "devp": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production CLIENT_TYPE=dev webpack --progress -w", "dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development webpack --progress -w", "start": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=production electron --no-sandbox .", "start-dev": "cross-env MODEL_VERSION=$(node ../common/scripts/show_version.js) VERSION=$(node ../common/scripts/show_tag.js) NODE_ENV=development electron --no-sandbox .", + "test": "jest --passWithNoTests --silent --forceExit --verbose --detectOpenHandles", + "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand --no-cache", "format": "format", "bump": "bump-package-version" }, @@ -61,7 +64,11 @@ "esbuild": "^0.24.2", "esbuild-loader": "^4.0.3", "svelte-preprocess": "^5.1.3", - "@types/ws": "^8.5.11" + "@types/ws": "^8.5.11", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@testing-library/jest-dom": "^6.6.3" }, "dependencies": { "@hcengineering/platform": "^0.6.11", diff --git a/desktop/src/__test__/main/selfCheckingNode.test.ts b/desktop/src/__test__/main/selfCheckingNode.test.ts new file mode 100644 index 0000000000..b21a301256 --- /dev/null +++ b/desktop/src/__test__/main/selfCheckingNode.test.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +describe('node environment self checking', () => { + test('check Jest\'s node environment', () => { + const isNodeAvailable = typeof process !== 'undefined'; + expect(isNodeAvailable).toBe(true); + }) + +}) diff --git a/desktop/src/__test__/ui/menuBuilder.test.ts b/desktop/src/__test__/ui/menuBuilder.test.ts new file mode 100644 index 0000000000..165c7e53cb --- /dev/null +++ b/desktop/src/__test__/ui/menuBuilder.test.ts @@ -0,0 +1,365 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import '@testing-library/jest-dom'; + +import { MenuBuilder } from '../../ui/titleBarMenu'; +import { MenuBarAction } from '../../ui/types'; + +describe('MenuBuilder', () => { + let systemUnderTest: MenuBuilder; + + beforeEach(() => { + systemUnderTest = new MenuBuilder(); + document.body.innerHTML = ''; + }); + + function verifyTopLevelMenu(menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string) { + const menuButton = menuBar.children[topLevelMenu].children[0] as HTMLElement; + expect(menuButton.dataset.menu).toBe(expectedMenuName); + expect(menuButton.dataset.accelerator).toBe(expectedAccelerator); + } + + describe('addTopLevelMenu', () => { + test('add a top-level menu', () => { + systemUnderTest.addTopLevelMenu('File', 'f'); + + const builtMenu = systemUnderTest.build(); + + expect(builtMenu.children).toHaveLength(1); + verifyTopLevelMenu(builtMenu, 0, 'file', 'f'); + }); + + test('method chaining', () => { + const result = systemUnderTest + .addTopLevelMenu('File', 'f') + .addTopLevelMenu('Edit', 'e'); + + expect(result).toBe(systemUnderTest); + }); + + test('add multiple top-level menus', () => { + systemUnderTest + .addTopLevelMenu('File', 'f') + .addTopLevelMenu('Edit', 'e') + .addTopLevelMenu('View', 'v'); + + const builtMenu = systemUnderTest.build(); + + expect(builtMenu.children).toHaveLength(3); + verifyTopLevelMenu(builtMenu, 0, 'file', 'f'); + verifyTopLevelMenu(builtMenu, 1, 'edit', 'e'); + verifyTopLevelMenu(builtMenu, 2, 'view', 'v'); + }); + }); + + describe('addMenuItem', () => { + beforeEach(() => { + systemUnderTest.addTopLevelMenu('File', 'f'); + }); + + test('add a menu item to existing top-level menu', () => { + systemUnderTest.addMenuItem(0, 'New', 'redo', 'Ctrl+N'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown.children).toHaveLength(1); + + const menuItem = dropdown.children[0] as HTMLElement; + expect(menuItem.dataset.action).toBe('redo'); + }); + + test('add menu item with custom accelerator character', () => { + systemUnderTest.addMenuItem(0, 'Save As', 'select-all', 'Ctrl+Shift+S', 'a'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + const menuItem = dropdown.children[0] as HTMLElement; + expect(menuItem.dataset.accelerator).toBe('a'); + }); + + test('default accelerator', () => { + systemUnderTest.addMenuItem(0, 'Open', 'undo', 'Ctrl+O'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + const menuItem = dropdown.children[0] as HTMLElement; + expect(menuItem.dataset.accelerator).toBe('o'); + }); + + test.each([[999], [-1],])("invalid top-level menu index", (input: number) => { + systemUnderTest.addMenuItem(input, 'Invalid', 'invalid' as MenuBarAction, 'Ctrl+I'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown.children).toHaveLength(0); + }); + + test('add multiple menu items to the same top-level menu', () => { + systemUnderTest + .addMenuItem(0, 'New', 'paste', 'Ctrl+N') + .addMenuItem(0, 'Open', 'cut', 'Ctrl+O') + .addMenuItem(0, 'Save', 'copy', 'Ctrl+S'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown.children).toHaveLength(3); + }); + }); + + describe('addSeparator', () => { + beforeEach(() => { + systemUnderTest.addTopLevelMenu('File', 'f'); + }); + + test('add a separator', () => { + systemUnderTest.addSeparator(0); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown.children).toHaveLength(1); + expect(dropdown.children[0]).toHaveClass('desktop-app-dropdown-separator'); + }); + + test.each([[999], [-1],])("invalid top-level menu index", (input: number) => { + systemUnderTest.addSeparator(999); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown.children).toHaveLength(0); + }); + }); + + describe('build', () => { + test('build empty menu bar', () => { + const builtMenu = systemUnderTest.build(); + + expect(builtMenu).toHaveClass('desktop-app-menu-bar'); + expect(builtMenu.children).toHaveLength(0); + }); + + test('menu bar with complex structure of a single top-level menu', () => { + const topLevelMenu = 0; + systemUnderTest + .addTopLevelMenu('File', 'f') + .addMenuItem(topLevelMenu, 'New', 'paste', 'Ctrl+N') + .addSeparator(topLevelMenu) + .addMenuItem(topLevelMenu, 'Exit', 'exit', 'Alt+F4'); + + const builtMenu = systemUnderTest.build(); + + expect(builtMenu).toHaveClass('desktop-app-menu-bar'); + expect(builtMenu.children).toHaveLength(1); + + const topLevelMenuItem = builtMenu.children[0]; + expect(topLevelMenuItem).toHaveClass('desktop-app-menu-item'); + expect(topLevelMenuItem.children).toHaveLength(2); + + const topButton = topLevelMenuItem.children[0]; + expect(topButton).toHaveClass('desktop-app-top-menu-button'); + + const dropdown = topLevelMenuItem.children[1]; + expect(dropdown).toHaveClass('desktop-app-dropdown-menu'); + expect(dropdown.id).toBe('file-menu'); + expect(dropdown.children).toHaveLength(3); // New, separator, Exit + }); + + test('accelerator at the beginning of label', () => { + systemUnderTest.addTopLevelMenu('File', 'f'); + + const builtMenu = systemUnderTest.build(); + + const topButton = builtMenu.children[0].children[0]; + expect(topButton.textContent).toBe('File'); + + const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator'); + expect(acceleratorSpan).toBeTruthy(); + expect(acceleratorSpan?.textContent).toBe('F'); + }); + + test('accelerator in the middle of label', () => { + systemUnderTest.addTopLevelMenu('Edit', 'i'); // 'i' is in the middle of 'Edit' + + const builtMenu = systemUnderTest.build(); + + const topButton = builtMenu.children[0].children[0]; + expect(topButton.textContent).toBe('Edit'); + + const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator'); + expect(acceleratorSpan).toBeTruthy(); + expect(acceleratorSpan?.textContent).toBe('i'); + }); + + test('accelerator not found in label', () => { + systemUnderTest.addTopLevelMenu('File', 'z'); // 'z' is not in 'File' + + const builtMenu = systemUnderTest.build(); + + const topButton = builtMenu.children[0].children[0]; + expect(topButton.textContent).toBe('File'); + expect(topButton.querySelector('.desktop-app-accelerator')).toBeNull(); + }); + + test('dropdown items with shortcuts', () => { + systemUnderTest + .addTopLevelMenu('File', 'f') + .addMenuItem(0, 'New', 'paste', 'Ctrl+N'); + + const builtMenu = systemUnderTest.build(); + + const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement; + expect(dropdownItem).toHaveClass('desktop-app-dropdown-item'); + expect(dropdownItem.dataset.action).toBe('paste'); + + const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut'); + expect(shortcutSpan).toBeTruthy(); + expect(shortcutSpan?.textContent).toBe('Ctrl+N'); + }); + + test('without shortcuts when empty', () => { + systemUnderTest + .addTopLevelMenu('File', 'f') + .addMenuItem(0, 'New', 'paste', ''); + + const builtMenu = systemUnderTest.build(); + + const dropdownItem = builtMenu.children[0].children[1].children[0]; + const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut'); + expect(shortcutSpan).toBeNull(); + }); + + test('accelerator characters in dropdown items', () => { + systemUnderTest + .addTopLevelMenu('File', 'f') + .addMenuItem(0, 'New', 'paste', 'Ctrl+N') + .addMenuItem(0, 'Open', 'cut', 'Ctrl+O', 'p'); + + const builtMenu = systemUnderTest.build(); + + const dropdown = builtMenu.children[0].children[1]; + const newItem = dropdown.children[0] as HTMLElement; + const openItem = dropdown.children[1] as HTMLElement; + + expect(newItem.dataset.accelerator).toBe('n'); + expect(openItem.dataset.accelerator).toBe('p'); + + const newAccelerator = newItem.querySelector('.desktop-app-accelerator'); + const openAccelerator = openItem.querySelector('.desktop-app-accelerator'); + + expect(newAccelerator?.textContent).toBe('N'); + expect(openAccelerator?.textContent).toBe('p'); + }); + + test('complex menu structure', () => { + systemUnderTest + .addTopLevelMenu('File', 'f') + .addMenuItem(0, 'New', 'paste', 'Ctrl+N') + .addMenuItem(0, 'Open', 'cut', 'Ctrl+O') + .addSeparator(0) + .addMenuItem(0, 'Save', 'cut', 'Ctrl+S') + .addMenuItem(0, 'Save As', 'cut', 'Ctrl+Shift+S') + .addSeparator(0) + .addMenuItem(0, 'Exit', 'cut', 'Alt+F4') + .addTopLevelMenu('Edit', 'e') + .addMenuItem(1, 'Undo', 'cut', 'Ctrl+Z') + .addMenuItem(1, 'Redo', 'cut', 'Ctrl+Y'); + + const builtMenu = systemUnderTest.build(); + + // Should have 2 top-level menus + expect(builtMenu.children).toHaveLength(2); + + // File menu should have 7 items (5 menu items + 2 separators) + const fileDropdown = builtMenu.children[0].children[1]; + expect(fileDropdown.children).toHaveLength(7); + + // Edit menu should have 2 items + const editDropdown = builtMenu.children[1].children[1]; + expect(editDropdown.children).toHaveLength(2); + }); + }); + + describe('edge cases', () => { + test('empty labels', () => { + systemUnderTest + .addTopLevelMenu('', 'f') + .addMenuItem(0, '', 'cut', 'shortcut'); + + const builtMenu = systemUnderTest.build(); + + expect(builtMenu.children).toHaveLength(1); + + const dropdown = builtMenu.children[0].children[1]; + expect(dropdown).toHaveClass('desktop-app-dropdown-menu'); + + expect(dropdown.children).toHaveLength(1); + }); + + test('special characters in labels', () => { + systemUnderTest + .addTopLevelMenu('File & Edit', 'f') + .addMenuItem(0, 'Save & Exit', 'cut', 'Ctrl+S'); + + const builtMenu = systemUnderTest.build(); + + expect(builtMenu.children).toHaveLength(1); + + const topButton = builtMenu.children[0].children[0]; + expect(topButton.textContent).toBe('File & Edit'); + }); + + test('case-insensitive accelerator matching', () => { + systemUnderTest + .addTopLevelMenu('File', 'F') // uppercase F + .addMenuItem(0, 'New', 'paste', 'Ctrl+N', 'N'); // uppercase N + + const builtMenu = systemUnderTest.build(); + + const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement; + expect(dropdownItem.dataset.accelerator).toBe('n'); + + const topButton = builtMenu.children[0].children[0] as HTMLElement; + expect(topButton.dataset.accelerator).toBe('f'); + + // Check that the actual character from the label is preserved + const topAccelerator = topButton.querySelector('.desktop-app-accelerator'); + const itemAccelerator = dropdownItem.querySelector('.desktop-app-accelerator'); + + expect(topAccelerator?.textContent).toBe('F'); + expect(itemAccelerator?.textContent).toBe('N'); + }); + + test('unicode characters', () => { + systemUnderTest + .addTopLevelMenu('Файл', 'ф') + .addMenuItem(0, 'Новый', 'undo', 'Ctrl+N'); + + const builtMenu = systemUnderTest.build(); + + const topButton = builtMenu.children[0].children[0]; + expect(topButton.textContent).toBe('Файл'); + + const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator'); + expect(acceleratorSpan?.textContent).toBe('Ф'); + }); + }); +}); \ No newline at end of file diff --git a/desktop/src/__test__/ui/selfCheckingDom.test.ts b/desktop/src/__test__/ui/selfCheckingDom.test.ts new file mode 100644 index 0000000000..be1eadddcc --- /dev/null +++ b/desktop/src/__test__/ui/selfCheckingDom.test.ts @@ -0,0 +1,24 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +describe('jsdom environment self checking', () => { + test('check Jest\'s jsdom environment', () => { + const isDOMAvailable = typeof document !== 'undefined'; + expect(isDOMAvailable).toBe(true); + + const isWindowAvailable = typeof window !== 'undefined'; + expect(isWindowAvailable).toBe(true); + }) +}) diff --git a/desktop/src/__test__/ui/titleBarMenuState.test.ts b/desktop/src/__test__/ui/titleBarMenuState.test.ts new file mode 100644 index 0000000000..de40646645 --- /dev/null +++ b/desktop/src/__test__/ui/titleBarMenuState.test.ts @@ -0,0 +1,301 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { TitleBarMenuState } from '../../ui/titleBarMenuState' + +describe('TitleBarMenuState', () => { + let systemUnderTest: TitleBarMenuState + let mockTopLevelMenuCount: jest.Mock + let mockMenuItemsCount: jest.Mock + + const TopLevelMenuCount = 3 + const MenuItemCount = 5 + + beforeEach(() => { + mockTopLevelMenuCount = jest.fn().mockReturnValue(TopLevelMenuCount) + mockMenuItemsCount = jest.fn().mockReturnValue(MenuItemCount) + systemUnderTest = new TitleBarMenuState(mockTopLevelMenuCount, mockMenuItemsCount) + }) + + describe('construction', () => { + test('initializes with default values', () => { + expect(systemUnderTest.isAltModeActive).toBe(false) + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + }) + }) + + describe('focusChildMenu', () => { + test.each([[0],[1]])('when top level menu %i is expanded', (topLevelMenu: number) => { + systemUnderTest.expandTopLevelMenu(topLevelMenu) + systemUnderTest.focusChildMenu() + + expect(systemUnderTest.FocusedChildMenuIndex).toBe(0) + }) + + test('when alt mode and top level menu is not expanded', () => { + systemUnderTest.enterAltMode(1) + systemUnderTest.focusChildMenu() + + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + }) + + test('when no top level menu is focused', () => { + systemUnderTest.focusChildMenu() + + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + }) + + describe('expandTopLevelMenu', () => { + test('normal expansion', () => { + const targetMenuIndex = 1 + systemUnderTest.expandTopLevelMenu(targetMenuIndex) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(targetMenuIndex) + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(true) + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + + test.each([[-1],[TopLevelMenuCount],[TopLevelMenuCount+1]])('invalid index %i', (invalidIndex: number) => { + systemUnderTest.expandTopLevelMenu(invalidIndex) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + }) + }) + + describe('enterAltMode', () => { + test('normal enter', () => { + systemUnderTest.enterAltMode(2) + + expect(systemUnderTest.isAltModeActive).toBe(true) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(2) + }) + + test.each([[-1],[TopLevelMenuCount],[TopLevelMenuCount+1]])('invalid index %i', (invalidIndex: number) => { + systemUnderTest.enterAltMode(invalidIndex) + + expect(systemUnderTest.isAltModeActive).toBe(true) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + }) + }) + + describe('closeAll', () => { + test('resets all state to initial values', () => { + systemUnderTest.enterAltMode(1) + systemUnderTest.expandTopLevelMenu(1) + systemUnderTest.focusChildMenu() + + systemUnderTest.closeAll() + + expect(systemUnderTest.isAltModeActive).toBe(false) + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + }) + }) + + describe('defocus', () => { + test('when top level menu expanded', () => { + systemUnderTest.expandTopLevelMenu(1) + systemUnderTest.focusChildMenu() + + systemUnderTest.defocus() + + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(1) + + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + + test('when top level menu is not expanded', () => { + systemUnderTest.enterAltMode(1) + + systemUnderTest.defocus() + + expect(systemUnderTest.isAltModeActive).toBe(false) + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + }) + + describe('moveFocusHorizontal', () => { + const DefaultTopLevelFocus = 1 + beforeEach(() => { + systemUnderTest.enterAltMode(DefaultTopLevelFocus) + }) + + test.each([ + [+1, DefaultTopLevelFocus + 1], + [-1, DefaultTopLevelFocus - 1], + ])('with increment %i, and top menu collapsed', (incremet: number, expectedTopLevelFocus: number) => { + const focusedChildBefore = systemUnderTest.FocusedChildMenuIndex + systemUnderTest.moveFocusHorizontal(incremet) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(expectedTopLevelFocus) + expect(systemUnderTest.FocusedChildMenuIndex).toBe(focusedChildBefore) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test.each([ + [+1, DefaultTopLevelFocus + 1], + [-1, DefaultTopLevelFocus - 1], + ])('with increment %i, and top menu expanded', (incremet: number, expectedTopLevelFocus: number) => { + systemUnderTest.expandTopLevelMenu(DefaultTopLevelFocus) + systemUnderTest.focusChildMenu() + systemUnderTest.moveFocusVertical(1) + + systemUnderTest.moveFocusHorizontal(incremet) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(expectedTopLevelFocus) + expect(systemUnderTest.FocusedChildMenuIndex).toBe(0) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('positive wrap around', () => { + systemUnderTest.enterAltMode(TopLevelMenuCount-1) + systemUnderTest.moveFocusHorizontal(1) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(0) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('negative wrap around', () => { + systemUnderTest.enterAltMode(0) + systemUnderTest.moveFocusHorizontal(-1) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(TopLevelMenuCount-1) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test.each([[-2],[2],[0]])('invalid increment %i', (invalidIncrement: number) => { + const originalIndex = systemUnderTest.FocusedTopLevelMenuIndex + + systemUnderTest.moveFocusHorizontal(invalidIncrement) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBe(originalIndex) + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('handles null focused index', () => { + systemUnderTest.closeAll() + systemUnderTest.moveFocusHorizontal(1) + + expect(systemUnderTest.FocusedTopLevelMenuIndex).toBeNull() + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + }) + + describe('moveFocusVertical', () => { + beforeEach(() => { + systemUnderTest.enterAltMode(1) + }) + + test('when top level menu is not expanded', () => { + systemUnderTest.moveFocusVertical(1) + + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(true) + expect(systemUnderTest.FocusedChildMenuIndex).toBe(0) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('negative wrap around', () => { + systemUnderTest.expandTopLevelMenu(1) + systemUnderTest.focusChildMenu() + + systemUnderTest.moveFocusVertical(-1) + + expect(systemUnderTest.FocusedChildMenuIndex).toBe(MenuItemCount-1) + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('positive wrap around', () => { + systemUnderTest.expandTopLevelMenu(1) + systemUnderTest.focusChildMenu() + for (let i = 0; i < MenuItemCount; i++) { + systemUnderTest.moveFocusVertical(1) + } + + expect(systemUnderTest.FocusedChildMenuIndex).toBe(0) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('invalid increment', () => { + systemUnderTest.expandTopLevelMenu(1) + systemUnderTest.focusChildMenu() + const originalIndex = systemUnderTest.FocusedChildMenuIndex + + systemUnderTest.moveFocusVertical(2) + + expect(systemUnderTest.FocusedChildMenuIndex).toBe(originalIndex) + + expect(systemUnderTest.isAltModeActive).toBe(true) + }) + + test('when no top level menu is focused', () => { + systemUnderTest.closeAll() + + systemUnderTest.moveFocusVertical(1) + + expect(systemUnderTest.isTopLevelMenuExpanded).toBe(false) + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + + expect(systemUnderTest.isAltModeActive).toBe(false) + }) + }) + + describe('edge cases', () => { + test('zero menu count', () => { + mockTopLevelMenuCount.mockReturnValue(0) + const emptyMenuState = new TitleBarMenuState(mockTopLevelMenuCount, mockMenuItemsCount) + + emptyMenuState.enterAltMode(0) + + expect(emptyMenuState.isAltModeActive).toBe(true) + + expect(emptyMenuState.isTopLevelMenuExpanded).toBe(false) + expect(emptyMenuState.FocusedChildMenuIndex).toBeNull + expect(emptyMenuState.FocusedTopLevelMenuIndex).toBeNull + }) + + test('zero menu items count', () => { + mockMenuItemsCount.mockReturnValue(0) + systemUnderTest.enterAltMode(1) + + systemUnderTest.moveFocusVertical(1) + + expect(systemUnderTest.FocusedChildMenuIndex).toBeNull() + }) + }) +}) \ No newline at end of file diff --git a/desktop/src/__test__/ui/typesUtils.test.ts b/desktop/src/__test__/ui/typesUtils.test.ts new file mode 100644 index 0000000000..12dcb9f69e --- /dev/null +++ b/desktop/src/__test__/ui/typesUtils.test.ts @@ -0,0 +1,29 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { isMenuBarAction } from '../../ui/typesUtils' +import { MenuBarActions } from '../../ui/types' + +describe('isMenuBarAction', () => { + test('yes', () => { + for (const action of MenuBarActions) { + expect(isMenuBarAction(action)).toBe(true) + } + }) + + test('no', () => { + expect(isMenuBarAction('some random string')).toBe(false) + }) +}) diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts new file mode 100644 index 0000000000..3e0339c22e --- /dev/null +++ b/desktop/src/main/customMenu.ts @@ -0,0 +1,92 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { app, BrowserWindow } from 'electron' +import { MenuBarAction, StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from '../ui/types' + +export function dipatchMenuBarAction(mainWindow: BrowserWindow | undefined, action: MenuBarAction) { + if (mainWindow == null) { + return + } + + function performZoom(increment: number): void { + if (mainWindow == null) { + return + } + const currentZoom = mainWindow.webContents.getZoomFactor(); + mainWindow.webContents.setZoomFactor(currentZoom + increment); + } + + const zoomStep = 0.1; + + switch (action) { + case 'settings': + mainWindow.webContents.send(StandardMenuCommandOpenSettings) + break; + case 'select-workspace': + mainWindow.webContents.send(StandardMenuCommandSelectWorkspace) + break; + case 'logout': + mainWindow.webContents.send(StandardMenuCommandLogout) + break; + case 'exit': + app.quit(); + break; + case 'undo': + mainWindow.webContents.undo(); + break; + case 'redo': + mainWindow.webContents.redo(); + break; + case 'cut': + mainWindow.webContents.cut(); + break; + case 'copy': + mainWindow.webContents.copy(); + break; + case 'paste': + mainWindow.webContents.paste(); + break; + case 'delete': + mainWindow.webContents.delete(); + break; + case 'select-all': + mainWindow.webContents.selectAll(); + break; + case 'reload': + mainWindow?.reload(); + break; + case 'force-reload': + mainWindow.webContents.reloadIgnoringCache(); + break; + case 'toggle-devtools': + mainWindow.webContents.toggleDevTools(); + break; + case 'zoom-in': + performZoom(+zoomStep); + break; + case 'zoom-out': + performZoom(-zoomStep); + break; + case 'restore-size': + mainWindow.webContents.setZoomFactor(1.0); + break; + case 'toggle-fullscreen': + mainWindow.setFullScreen(!mainWindow.isFullScreen()); + break; + default: + console.log('unknown menu action:', action); + } +} \ No newline at end of file diff --git a/desktop/src/main/menu.ts b/desktop/src/main/standardMenu.ts similarity index 71% rename from desktop/src/main/menu.ts rename to desktop/src/main/standardMenu.ts index 0c964ae510..0fa82745a4 100644 --- a/desktop/src/main/menu.ts +++ b/desktop/src/main/standardMenu.ts @@ -13,12 +13,13 @@ // limitations under the License. // -import { Menu, MenuItemConstructorOptions, type BrowserWindow } from 'electron' +import { Menu, MenuItemConstructorOptions } from 'electron' +import { StandardMenuCommand, StandardMenuCommandOpenSettings, StandardMenuCommandSelectWorkspace, StandardMenuCommandLogout, } from '../ui/types' const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' -export const addMenus = (getWindow: () => BrowserWindow, sendCommand: (cmd: string, ...args: any[]) => void): void => { +export const addMenus = (sendCommand: (cmd: StandardMenuCommand, ...args: any[]) => void): void => { const template: MenuItemConstructorOptions[] = [ { label: 'File', @@ -26,15 +27,15 @@ export const addMenus = (getWindow: () => BrowserWindow, sendCommand: (cmd: stri { label: 'Settings', accelerator: isLinux ? 'Ctrl+,' : 'Meta+,', - click: () => { sendCommand('open-settings') } + click: () => { sendCommand(StandardMenuCommandOpenSettings) } }, { label: 'Select workspace', - click: () => { sendCommand('select-workspace') } + click: () => { sendCommand(StandardMenuCommandSelectWorkspace) } }, { label: 'Logout', - click: () => { sendCommand('logout') } + click: () => { sendCommand(StandardMenuCommandLogout) } }, { role: isMac ? 'close' : 'quit' } ] diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index 7a78e0bdb7..5fc95bb05f 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -14,7 +14,7 @@ // import { config as dotenvConfig } from 'dotenv' -import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences } from 'electron' +import { BrowserWindow, CookiesSetDetails, Notification, app, desktopCapturer, dialog, ipcMain, nativeImage, session, shell, systemPreferences, nativeTheme } from 'electron' import contextMenu from 'electron-context-menu' import log from 'electron-log' import Store from 'electron-store' @@ -22,9 +22,10 @@ import { ProgressInfo, UpdateInfo } from 'electron-updater' import WinBadge from 'electron-windows-badge' import * as path from 'path' -import { Config, NotificationParams } from '../ui/types' +import { Config, MenuBarAction, NotificationParams, StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from '../ui/types' import { getOptions } from './args' -import { addMenus } from './menu' +import { addMenus } from './standardMenu' +import { dipatchMenuBarAction } from './customMenu' import { addPermissionHandlers } from './permissions' import autoUpdater from './updater' import { generateId } from '@hcengineering/core' @@ -39,6 +40,7 @@ const isDev = process.env.NODE_ENV === 'development' const sessionPartition = !isDev ? 'persist:huly' : 'persist:huly_dev' const iconKey = path.join(app.getAppPath(), 'dist', 'ui', 'public', 'AppIcon.png') +const preloadScriptPath = path.join(app.getAppPath(), 'dist', 'main', 'preload.js') const defaultWidth = 1440 const defaultHeight = 960 @@ -50,6 +52,9 @@ dotenvConfig({ }) const options = getOptions() +const containerPageFileName = isWindows ? 'index.windows.html' : 'index.html' +const containerPagePath = path.join('dist', 'ui', containerPageFileName) + // Note: using electron-store here as local storage is not available in the main process // before the window is created const settings = new Store() @@ -87,6 +92,15 @@ const disabledFeatures = [ app.commandLine.appendSwitch('disable-features', disabledFeatures.join(',')) +function setupWindowTitleBar(windowOptions: Electron.BrowserWindowConstructorOptions): void { + if (isWindows) { + // on Windows we use frameless window with custom hand-made title bar + windowOptions.frame = false + } else { + windowOptions.titleBarStyle = isMac ? 'hidden' : 'default' + } +} + function hookOpenWindow (window: BrowserWindow): void { window.webContents.setWindowOpenHandler(({ url }) => { console.log('opening window', url) @@ -104,12 +118,11 @@ function hookOpenWindow (window: BrowserWindow): void { } else { void (async (): Promise => { const bounds = mainWindow?.getBounds() - const childWindow = new BrowserWindow({ + const windowOptions: Electron.BrowserWindowConstructorOptions = { width: bounds?.width ?? defaultWidth, height: bounds?.height ?? defaultHeight, x: (bounds?.x ?? 0) + 25, y: (bounds?.y ?? 0) + 25, - titleBarStyle: isMac ? 'hidden' : 'default', trafficLightPosition: { x: 10, y: 10 }, icon: nativeImage.createFromPath(iconKey), webPreferences: { @@ -117,7 +130,7 @@ function hookOpenWindow (window: BrowserWindow): void { sandbox: false, partition: sessionPartition, nodeIntegration: true, - preload: path.join(app.getAppPath(), 'dist', 'main', 'preload.js'), + preload: preloadScriptPath, additionalArguments: [ `--open=${encodeURI( new URL(url).pathname @@ -127,8 +140,10 @@ function hookOpenWindow (window: BrowserWindow): void { )}` ] } - }) - await childWindow.loadFile(path.join('dist', 'ui', 'index.html')) + } + setupWindowTitleBar(windowOptions) + const childWindow = new BrowserWindow(windowOptions) + await childWindow.loadFile(containerPagePath) hookOpenWindow(childWindow) })() } @@ -177,7 +192,7 @@ function handleAuthRedirects (window: BrowserWindow): void { event.preventDefault() void (async (): Promise => { - await window.loadFile(path.join('dist', 'ui', 'index.html')) + await window.loadFile(containerPagePath) window.webContents.send('handle-auth', urlObj.searchParams.get('token')) })() } @@ -211,12 +226,11 @@ function handleWillDownload (window: BrowserWindow): void { const createWindow = async (): Promise => { // Restore window position if available const restoredBounds: any = settings.get('windowBounds') - mainWindow = new BrowserWindow({ + const windowOptions: Electron.BrowserWindowConstructorOptions = { width: restoredBounds?.width ?? defaultWidth, height: restoredBounds?.height ?? defaultHeight, x: restoredBounds?.x ?? undefined, y: restoredBounds?.y ?? undefined, - titleBarStyle: isMac ? 'hidden' : 'default', trafficLightPosition: { x: 10, y: 10 }, roundedCorners: true, icon: nativeImage.createFromPath(iconKey), @@ -226,15 +240,17 @@ const createWindow = async (): Promise => { nodeIntegration: true, // backgroundThrottling: false, partition: sessionPartition, - preload: path.join(app.getAppPath(), 'dist', 'main', 'preload.js') + preload: preloadScriptPath } - }) + } + setupWindowTitleBar(windowOptions) + mainWindow = new BrowserWindow(windowOptions) app.dock?.setIcon(nativeImage.createFromPath(iconKey)) // await mainWindow.webContents.openDevTools() if (isDev) { mainWindow.webContents.openDevTools() } - await mainWindow.loadFile(path.join('dist', 'ui', 'index.html')) + await mainWindow.loadFile(containerPagePath) addPermissionHandlers(mainWindow.webContents.session) handleAuthRedirects(mainWindow) handleWillDownload(mainWindow) @@ -251,6 +267,14 @@ const createWindow = async (): Promise => { } }) + mainWindow.on('maximize', () => { + mainWindow?.webContents.send('window-state-changed', 'maximized'); + }); + + mainWindow.on('unmaximize', () => { + mainWindow?.webContents.send('window-state-changed', 'unmaximized'); + }); + if (isMac) { mainWindow.on('close', (event) => { // Prevent the default behavior (which would quit the app) @@ -265,9 +289,11 @@ const createWindow = async (): Promise => { } } -addMenus(() => mainWindow as BrowserWindow, (cmd: string, ...args: any[]) => { - mainWindow?.webContents.send(cmd, ...args) -}) +if (false == isWindows) { + addMenus((cmd: string, ...args: any[]) => { + mainWindow?.webContents.send(cmd, ...args) + }) +} contextMenu({ showSaveImageAs: false, @@ -303,8 +329,8 @@ ipcMain.on('send-notification', (event, notificationParams: NotificationParams) ipcMain.on('set-title', (event, title) => { const webContents = event.sender - const win = BrowserWindow.fromWebContents(webContents) - win?.setTitle(title) + const window = BrowserWindow.fromWebContents(webContents) + window?.setTitle(title) }) ipcMain.on('set-combined-config', (event, config: Config) => { @@ -353,6 +379,32 @@ ipcMain.on('set-front-cookie', function (event, host: string, name: string, valu void win?.webContents?.session.cookies.set(cv) }) +ipcMain.handle('window-minimize', () => { + mainWindow?.minimize(); +}); + +ipcMain.handle('window-maximize', () => { + if (mainWindow) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + } +}); + +ipcMain.handle('window-close', () => { + mainWindow?.close(); +}); + +ipcMain.handle('get-is-os-using-dark-theme', () => { + return nativeTheme.shouldUseDarkColors; +}); + +ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => { + dipatchMenuBarAction(mainWindow, action) +}); + const gotTheLock = app.requestSingleInstanceLock() if (!gotTheLock) { @@ -440,8 +492,9 @@ autoUpdater.on('download-progress', (progressObj: ProgressInfo) => { }) function setDownloadProgress (percent: number): void { - if (mainWindow === undefined) return - + if (mainWindow === undefined) { + return + } mainWindow.setProgressBar(percent / 100) mainWindow.webContents.send('handle-update-download-progress', percent) } diff --git a/desktop/src/ui/index.ejs b/desktop/src/ui/index.ejs index 5e3978d5d0..2ab6900c73 100644 --- a/desktop/src/ui/index.ejs +++ b/desktop/src/ui/index.ejs @@ -1,14 +1,239 @@ + + + <% if (typeof isWindows !== 'undefined' && isWindows) { %> + + <% } %> + + + <% if (typeof isWindows !== 'undefined' && isWindows) { %> +
+
+
+
+ +
Huly
+ +
+ + + +
+
+
+ <% } %> + \ No newline at end of file diff --git a/desktop/src/ui/index.ts b/desktop/src/ui/index.ts index 128a90fed5..a9221191b7 100644 --- a/desktop/src/ui/index.ts +++ b/desktop/src/ui/index.ts @@ -19,20 +19,49 @@ import { workbenchId, logOut } from '@hcengineering/workbench' import { isOwnerOrMaintainer } from '@hcengineering/core' import { configurePlatform } from './platform' +import { setupTitleBarMenu } from './titleBarMenu' import { defineScreenShare, defineGetDisplayMedia } from './screenShare' -import { IPCMainExposed } from './types' +import { StandardMenuCommandLogout, StandardMenuCommandSelectWorkspace, StandardMenuCommandOpenSettings } from './types' +import { ipcMainExposed } from './typesUtils' +import { themeStore } from '@hcengineering/theme' defineScreenShare() defineGetDisplayMedia() -void configurePlatform().then(() => { - createApp(document.body) -}) - window.addEventListener('DOMContentLoaded', () => { - const ipcMain = (window as any).electron as IPCMainExposed + + const ipcMain = ipcMainExposed() + + if ((window as any).windowsPlatform === true) { + const titleBarRoot = document.getElementById('desktop-app-titlebar-root') + if (titleBarRoot) { + const menuBar = setupTitleBarMenu(ipcMain, titleBarRoot) - ipcMain.on('open-settings', () => { + themeStore.subscribe((themeOptions) => { + if (themeOptions != null) { + const isDarkTheme = themeOptions.dark + menuBar.setTheme(isDarkTheme ? 'dark' : 'light') + } + }) + + void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { + menuBar.setTheme(isDarkTheme ? 'dark' : 'light') + }).catch(() => { + menuBar.setTheme('light'); // fallback + }) + } + } + + void configurePlatform().then((parameters) => { + const windowTitle = document.getElementById('application-title-bar-caption') + if (windowTitle) { + windowTitle.textContent = parameters.getBranding().getTitle() + } + + createApp(document.body) + }) + + ipcMain.on(StandardMenuCommandOpenSettings, () => { closePopup() closePanel() const loc = getCurrentResolvedLocation() @@ -43,7 +72,7 @@ window.addEventListener('DOMContentLoaded', () => { navigate(loc) }) - ipcMain.on('select-workspace', () => { + ipcMain.on(StandardMenuCommandSelectWorkspace, () => { closePopup() closePanel() const loc = getCurrentResolvedLocation() @@ -55,7 +84,7 @@ window.addEventListener('DOMContentLoaded', () => { navigate(loc) }) - ipcMain.on('logout', () => { + ipcMain.on(StandardMenuCommandLogout, () => { void logOut().then(() => { navigate({ path: [loginId] }) }) diff --git a/desktop/src/ui/notifications.ts b/desktop/src/ui/notifications.ts index ec2acf651f..9062ba31be 100644 --- a/desktop/src/ui/notifications.ts +++ b/desktop/src/ui/notifications.ts @@ -16,8 +16,7 @@ import { activePreferences } from '@hcengineering/desktop-preferences-resources' import { getDisplayInboxData, InboxNotificationsClientImpl } from '@hcengineering/notification-resources' import { inboxId } from '@hcengineering/inbox' import communication from '@hcengineering/communication' - -import { IPCMainExposed } from './types' +import { ipcMainExposed } from './typesUtils' let client: TxOperations @@ -126,7 +125,7 @@ export function configureNotifications (): void { addEventListener(workbench.event.NotifyConnection, async () => { client = getClient() - const electronAPI: IPCMainExposed = (window as any).electron + const electronAPI = ipcMainExposed() const inboxClient = InboxNotificationsClientImpl.getClient() const notificationsQuery = createNotificationsQuery(true) @@ -247,13 +246,13 @@ export function configureNotifications (): void { }) addEventListener(workbench.event.NotifyTitle, async (event, title: string) => { - ;((window as any).electron as IPCMainExposed).setTitle(title) + ipcMainExposed().setTitle(title) }) location.subscribe((location) => { if (!(location.path[0] === workbenchId || location.path[0] === workbench.component.WorkbenchApp)) { // We need to clear badge - ;((window as any).electron as IPCMainExposed).setBadge(0) + ipcMainExposed().setBadge(0) } }) } diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 83bffc5241..f49f15f354 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -137,7 +137,8 @@ import textEditor, { textEditorId } from '@hcengineering/text-editor' import { initThemeStore, setDefaultLanguage } from '@hcengineering/theme' import { configureNotifications } from './notifications' import { configureAnalyticsProviders } from '@hcengineering/analytics-providers' -import { Branding, Config, IPCMainExposed } from './types' +import { Branding, Config, } from './types' +import { ipcMainExposed } from './typesUtils' import github, { githubId } from '@hcengineering/github' import '@hcengineering/github-assets' @@ -247,10 +248,26 @@ function configureI18n (): void { addStringsLoader(billingId, async (lang: string) => await import(`@hcengineering/billing-assets/lang/${lang}.json`)) } -export async function configurePlatform (): Promise { +export class PlatformBranding { + constructor(private title: string) { + } + public getTitle(): string { + return this.title; + } +} + +export class PlatformParameters { + constructor(private branding: PlatformBranding) { + } + public getBranding(): PlatformBranding { + return this.branding; + } +} + +export async function configurePlatform (): Promise { configureI18n() - const ipcMain = (window as any).electron as IPCMainExposed + const ipcMain = ipcMainExposed() const config: Config = await ipcMain.config() const myBranding: Branding = await ipcMain.branding() // await (await fetch(devConfig? '/config-dev.json' : '/config.json')).json() @@ -459,4 +476,6 @@ export async function configurePlatform (): Promise { } console.log('Initial location is: ', getCurrentLocation()) + + return new PlatformParameters(new PlatformBranding(title)) } diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index ddb422b99c..a1a48286b6 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -1,7 +1,7 @@ // preload.js import { contextBridge, ipcRenderer } from 'electron' -import { BrandingMap, Config, IPCMainExposed, NotificationParams } from './types' +import { BrandingMap, Config, IPCMainExposed, MenuBarAction, NotificationParams } from './types' /** * @public @@ -62,6 +62,30 @@ const expose: IPCMainExposed = { ipcRenderer.send('send-notification', notificationParams) }, + minimizeWindow: () => { + ipcRenderer.invoke('window-minimize') + }, + + maximizeWindow: () => { + ipcRenderer.invoke('window-maximize') + }, + + closeWindow: () => { + ipcRenderer.invoke('window-close') + }, + + onWindowStateChange: (callback) => { + ipcRenderer.on('window-state-changed', callback) + }, + + isOsUsingDarkTheme: async () => { + return await ipcRenderer.invoke('get-is-os-using-dark-theme') + }, + + executeMenuBarAction: (action: MenuBarAction) => { + ipcRenderer.invoke('menu-action', action) + }, + config: async () => { if (configPromise === undefined) { configPromise = new Promise((resolve, reject) => { diff --git a/desktop/src/ui/screenShare.ts b/desktop/src/ui/screenShare.ts index b442defa90..10554b617c 100644 --- a/desktop/src/ui/screenShare.ts +++ b/desktop/src/ui/screenShare.ts @@ -3,7 +3,7 @@ import love from '@hcengineering/love' import { setCustomCreateScreenTracks } from '@hcengineering/love-resources' import { showPopup } from '@hcengineering/ui' import { Track, LocalTrack, LocalAudioTrack, LocalVideoTrack, ParticipantEvent, TrackInvalidError, ScreenShareCaptureOptions, DeviceUnsupportedError, ScreenSharePresets } from 'livekit-client' -import { IPCMainExposed } from './types' +import { ipcMainExposed } from './typesUtils' export function defineGetDisplayMedia (): void { if (navigator?.mediaDevices === undefined) { @@ -20,7 +20,7 @@ export function defineGetDisplayMedia (): void { throw new Error('opts must be provided') } - const ipcMain = (window as any).electron as IPCMainExposed + const ipcMain = ipcMainExposed() const sources = await ipcMain.getScreenSources() const hasAccess = await ipcMain.getScreenAccess() @@ -65,7 +65,7 @@ export function defineGetDisplayMedia (): void { export function defineScreenShare (): void { setCustomCreateScreenTracks(async function electronCreateScreenTracks (options?: ScreenShareCaptureOptions) { - const ipcMain = (window as any).electron as IPCMainExposed + const ipcMain = ipcMainExposed() const sources = await ipcMain.getScreenSources() const hasAccess = await ipcMain.getScreenAccess() diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts new file mode 100644 index 0000000000..856f8d6e79 --- /dev/null +++ b/desktop/src/ui/titleBarMenu.ts @@ -0,0 +1,525 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { IPCMainExposed, MenuBarAction } from './types' +import { isMenuBarAction } from './typesUtils' +import { TitleBarMenuState } from './titleBarMenuState' + +export function setupTitleBarMenu(ipcMain: IPCMainExposed, root: HTMLElement): MenuBar { + const themeManager = new ThemeManager('light') + const menuManager = new MenuBarManager(ipcMain, root) + + const menuBar = menuManager.getView(); + + const menuContainer = root.querySelector('.desktop-app-menu-container') + if (menuContainer) { + const existingMenuBar = menuContainer.querySelector('.desktop-app-menu-bar') + if (existingMenuBar) { + existingMenuBar.remove() + } + menuContainer.appendChild(menuBar) + } + + menuManager.attachEventListeners(ipcMain) + + ipcMain.onWindowStateChange((_event, state) => { + const maximizeButton = root.querySelector('#maximize-button') + if (maximizeButton) { + if (state === 'maximized') { + maximizeButton.textContent = '❐' + } else { + maximizeButton.textContent = '□' + } + } + }) + + return new MenuBar(themeManager) +} + +type TitleBarTheme = 'dark' | 'light'; + +export class MenuBar { + constructor(private readonly theme: ThemeManager) { + } + + public setTheme(theme: TitleBarTheme): void { + this.theme.setTheme(theme) + } +} + +export function buildHulyApplicationMenu(): HTMLElement { + const menuBuilder = new MenuBuilder() + + const MenuFileIndex = 0 + menuBuilder.addTopLevelMenu('File', 'f') + .addMenuItem(MenuFileIndex, 'Settings', 'settings', undefined, 's') + .addMenuItem(MenuFileIndex, 'Select Workspace', 'select-workspace', undefined, 'w') + .addMenuItem(MenuFileIndex, 'Logout', 'logout', undefined, 'l') + .addSeparator(MenuFileIndex) + .addMenuItem(MenuFileIndex, 'Exit', 'exit', 'Alt+F4', 'x') + + const MenuEditIndex = 1 + menuBuilder.addTopLevelMenu('Edit', 'e') + .addMenuItem(MenuEditIndex, 'Undo', 'undo', 'Ctrl+Z', 'u') + .addMenuItem(MenuEditIndex, 'Redo', 'redo', 'Ctrl+Y', 'r') + .addSeparator(MenuEditIndex) + .addMenuItem(MenuEditIndex, 'Cut', 'cut', 'Ctrl+X', 't') + .addMenuItem(MenuEditIndex, 'Copy', 'copy', 'Ctrl+C', 'c') + .addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p') + .addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd') + .addSeparator(MenuEditIndex) + .addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a') + + const MenuViewIndex = 2 + menuBuilder.addTopLevelMenu('View', 'v') + .addMenuItem(MenuViewIndex, 'Reload', 'reload', 'Ctrl+R', 'r') + .addMenuItem(MenuViewIndex, 'Force Reload', 'force-reload', 'Ctrl+Shift+R', 'o') + .addMenuItem(MenuViewIndex, 'Toggle Developer Tools', 'toggle-devtools', 'Ctrl+Shift+I', 'd') + .addSeparator(MenuViewIndex) + .addMenuItem(MenuViewIndex, 'Zoom In', 'zoom-in', 'Ctrl+\'+\'', 'i') + .addMenuItem(MenuViewIndex, 'Zoom Out', 'zoom-out', 'Ctrl+\'-\'', 'u') + .addMenuItem(MenuViewIndex, 'Actual Size', 'restore-size', 'Ctrl+0', 'a') + .addSeparator(MenuViewIndex) + .addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l') + + return menuBuilder.build() +} + +class ThemeManager { + private readonly domThemeKey = 'data-theme' + + constructor(theme: TitleBarTheme) { + this.setTheme(theme) + } + + public setTheme(theme: TitleBarTheme): void { + document.body.setAttribute(this.domThemeKey, theme) + } +} + +interface MenuItem { + type: 'item' | 'separator' + label?: string + action?: MenuBarAction + shortcut?: string + acceleratorChar?: string +} + +interface TopLevelMenu { + label: string + accelerator: string + subMenus: MenuItem[] +} + +export class MenuBuilder { + private menus: TopLevelMenu[] = [] + + public addTopLevelMenu(label: string, accelerator: string): this { + const menu: TopLevelMenu = { + label, + accelerator: accelerator.toLowerCase(), + subMenus: [] + } + this.menus.push(menu) + return this + } + + public addMenuItem( + topLevelMenuIndex: number, + label: string, + action: MenuBarAction, + shortcut?: string, + acceleratorChar: string | null = null + ): this { + if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { + const item: MenuItem = { + type: 'item', + label, + action, + shortcut, + acceleratorChar: (acceleratorChar || label.charAt(0)).toLowerCase(), + } + this.menus[topLevelMenuIndex].subMenus.push(item) + } + return this + } + + public addSeparator(topLevelMenuIndex: number): this { + if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { + this.menus[topLevelMenuIndex].subMenus.push({ type: 'separator' }) + } + return this + } + + public build(): HTMLElement { + const menuBar = document.createElement('ul') + menuBar.className = 'desktop-app-menu-bar' + + this.menus.forEach((topLevelMenu) => { + const topLevelMenuView = document.createElement('li') + topLevelMenuView.className = 'desktop-app-menu-item' + + const menuButton = document.createElement('button') + menuButton.className = 'desktop-app-top-menu-button' + menuButton.dataset.menu = topLevelMenu.label.toLowerCase() + menuButton.dataset.accelerator = topLevelMenu.accelerator + + const acceleratorSpan = document.createElement('span') + acceleratorSpan.className = 'desktop-app-accelerator' + acceleratorSpan.dataset.menu = topLevelMenu.label.toLowerCase() + + const labelText = topLevelMenu.label + const acceleratorIndex = labelText.toLowerCase().indexOf(topLevelMenu.accelerator) + + if (acceleratorIndex === 0) { + acceleratorSpan.textContent = topLevelMenu.accelerator.toUpperCase() + menuButton.appendChild(acceleratorSpan) + menuButton.appendChild(document.createTextNode(labelText.substring(1))) + } else if (acceleratorIndex > 0) { + acceleratorSpan.textContent = topLevelMenu.accelerator.toLowerCase() + menuButton.appendChild(document.createTextNode(labelText.substring(0, acceleratorIndex))) + menuButton.appendChild(acceleratorSpan) + menuButton.appendChild(document.createTextNode(labelText.substring(acceleratorIndex + 1))) + } else { + menuButton.textContent = labelText + } + + const dropdown = document.createElement('div') + dropdown.className = 'desktop-app-dropdown-menu' + dropdown.id = `${topLevelMenu.label.toLowerCase()}-menu` + + topLevelMenu.subMenus.forEach(item => { + if (item.type === 'separator') { + const separator = document.createElement('div') + separator.className = 'desktop-app-dropdown-separator' + dropdown.appendChild(separator) + } else if (item.type === 'item' && item.label != null) { + const menuItemButton = document.createElement('button') + menuItemButton.className = 'desktop-app-dropdown-item' + menuItemButton.dataset.accelerator = item.acceleratorChar + menuItemButton.dataset.action = item.action + + const labelSpan = document.createElement('span') + + const itemAcceleratorSpan = document.createElement('span') + itemAcceleratorSpan.className = 'desktop-app-accelerator' + + if (item.acceleratorChar) { + const labelParts = this.splitLabelByAccelerator(item.label, item.acceleratorChar) + + const actualChar = item.label.charAt( + item.label.toLowerCase().indexOf(item.acceleratorChar.toLowerCase()) + ) + itemAcceleratorSpan.textContent = actualChar + + if (labelParts.before) { + labelSpan.appendChild(document.createTextNode(labelParts.before)) + } + labelSpan.appendChild(itemAcceleratorSpan) + if (labelParts.after) { + labelSpan.appendChild(document.createTextNode(labelParts.after)) + } + } + + menuItemButton.appendChild(labelSpan) + + if (item.shortcut) { + const shortcutSpan = document.createElement('span') + shortcutSpan.className = 'desktop-app-shortcut' + shortcutSpan.textContent = item.shortcut + menuItemButton.appendChild(shortcutSpan) + } + + dropdown.appendChild(menuItemButton) + } + }) + + topLevelMenuView.appendChild(menuButton) + topLevelMenuView.appendChild(dropdown) + menuBar.appendChild(topLevelMenuView) + }) + + return menuBar + } + + private splitLabelByAccelerator(label: string, acceleratorChar: string): { before: string; after: string } { + const index = label.toLowerCase().indexOf(acceleratorChar.toLowerCase()) + if (index === -1) { + return { before: label, after: '' } + } + return { + before: label.substring(0, index), + after: label.substring(index + 1) + } + } +} + +class MenuBarManager { + private readonly state: TitleBarMenuState + private readonly view: HTMLElement + + private readonly TopMenuStyle = '.desktop-app-top-menu-button' + private readonly DropdownMenuStyle = '.desktop-app-dropdown-menu' + private readonly DropdownItemStyle = '.desktop-app-dropdown-item' + private readonly MenuItemStyle = '.desktop-app-menu-item' + + private readonly StateStyleAltMode = 'desktop-app-alt-mode' + private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected' + private readonly StateStyleAltModeActive = 'desktop-app-alt-active' + + constructor(ipcMain: IPCMainExposed, private readonly root: HTMLElement) { + this.state = new TitleBarMenuState( + () => this.topLevelMenus().length, + (topLevelMenuIndex: number) => { + const children = this.childrenOfTopLevelMenu(topLevelMenuIndex) + return children.length + } + ) + + this.view = buildHulyApplicationMenu() + } + + public getView(): HTMLElement { + return this.view + } + + private topLevelMenus() { + return this.root.querySelectorAll(this.TopMenuStyle) + } + + private onButtonClick(id: string, callback: () => void): void { + const button = this.root.querySelector(`#${id}`) + if (button) { + button.addEventListener('click', callback) + } + } + + public attachEventListeners(ipcMain: IPCMainExposed): void { + this.onButtonClick('minimize-button', () => { + ipcMain.minimizeWindow() + }) + + this.onButtonClick('maximize-button', () => { + ipcMain.maximizeWindow() + }) + + this.onButtonClick('close-button', () => { + ipcMain.closeWindow() + }) + + document.addEventListener('keydown', (e) => this.handleKeyDown(ipcMain, e)) + document.addEventListener('keyup', (e) => this.handleKeyUp(e)) + + this.topLevelMenus().forEach((button, index) => { + button.addEventListener('click', (e) => this.handleTopLevelMenuButtonClick(e, index)) + }) + + document.addEventListener('click', (e) => this.handleDocumentClick(e)) + + document.querySelectorAll(this.DropdownItemStyle + '[data-action]').forEach(item => { + item.addEventListener('click', async () => { + const action = item.dataset.action + if (action) { + if (isMenuBarAction(action)) { + await this.executeMenuAction(ipcMain, action) + } + this.state.closeAll() + this.renderState() + } + }) + }) + } + + private renderState() { + if (this.state.isAltModeActive) { + this.root.classList.add(this.StateStyleAltModeActive) + } else { + this.root.classList.remove(this.StateStyleAltModeActive) + } + + this.root.querySelectorAll(this.DropdownMenuStyle).forEach(menu => { + menu.style.display = 'none' + }) + + const topLevelMenus = this.topLevelMenus() + + topLevelMenus.forEach((button, index) => { + button.classList.remove(this.StateStyleAltMode) + + if (index === this.state.FocusedTopLevelMenuIndex) { + button.classList.add(this.StateStyleAltMode) + button.focus() + + if (this.state.isTopLevelMenuExpanded && button.dataset.menu) { + const menuType = button.dataset.menu + const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null + if (dropdown) { + dropdown.style.display = 'block' + } + } + } else { + button.blur() + } + }) + + if (this.state.FocusedTopLevelMenuIndex != null && this.state.isTopLevelMenuExpanded) { + const candidates = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + candidates.forEach((menu, index) => { + if (index === this.state.FocusedChildMenuIndex) { + menu.classList.add(this.StateStyleKeyboardSelected) + } else { + menu.classList.remove(this.StateStyleKeyboardSelected) + } + }) + } + } + + private childrenOfTopLevelMenu(index: number): NodeListOf { + const topLevelMenus = this.topLevelMenus() + const menuButton = topLevelMenus[index] + const menuType = menuButton.dataset.menu + const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null + if (dropdown) { + return dropdown.querySelectorAll(this.DropdownItemStyle) + } + return document.createDocumentFragment().querySelectorAll('*') + } + + private async executeMenuAction(ipcMain: IPCMainExposed, action: MenuBarAction): Promise { + try { + await ipcMain.executeMenuBarAction(action); + } catch (error) { + console.error('error executing action:', error) + } + } + + private handleKeyDown(ipcMain: IPCMainExposed, e: KeyboardEvent): void { + if (e.altKey) { + if (this.state.isAltModeActive) { + if (this.state.FocusedTopLevelMenuIndex != null) { + this.state.closeAll() + this.renderState() + return + } + } else { + this.state.enterAltMode(null) + this.renderState() + } + } + + switch (e.key) { + case 'ArrowLeft': + this.state.moveFocusHorizontal(-1) + this.renderState() + break + + case 'ArrowRight': + this.state.moveFocusHorizontal(+1) + this.renderState() + break + + case 'ArrowDown': + this.state.moveFocusVertical(+1) + this.renderState() + break + + case 'ArrowUp': + this.state.moveFocusVertical(-1) + this.renderState() + break + } + + switch (e.key) { + case 'Escape': + this.state.defocus() + this.renderState() + break + + case 'Enter': + case ' ': + if (this.state.FocusedTopLevelMenuIndex != null){ + if (this.state.isTopLevelMenuExpanded) { + if (this.state.FocusedChildMenuIndex != null) { + const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + const focused = children[this.state.FocusedChildMenuIndex] + this.state.closeAll() + this.renderState() + if (focused.dataset.action && isMenuBarAction(focused.dataset.action)) { + this.executeMenuAction(ipcMain, focused.dataset.action) + } + this.renderState() + } + } + } + break + + default: + const key = e.key.toLowerCase() + if (false == this.state.isAltModeActive) { + return + } + + if (this.state.isTopLevelMenuExpanded && this.state.FocusedTopLevelMenuIndex != null) { + const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + for (let i = 0; i < children.length; i++) { + if (children[i].dataset.accelerator === key) { + const action = children[i].dataset.action + if (action) { + if (isMenuBarAction(action)) { + this.executeMenuAction(ipcMain, action) + } + this.state.closeAll() + this.renderState() + return + } + } + } + } + + const menuButtons = this.root.querySelectorAll(this.TopMenuStyle + '[data-accelerator]') + for (let i = 0; i < menuButtons.length; i++) { + if (menuButtons[i].dataset.accelerator === key) { + this.state.expandTopLevelMenu(i) + this.state.focusChildMenu() + this.renderState() + return + } + } + break + } + } + + private handleKeyUp(e: KeyboardEvent): void { + if (e.key === 'Alt') { + if (this.state.FocusedTopLevelMenuIndex == null) { + this.state.enterAltMode(0) + this.renderState() + } + } + } + + private handleTopLevelMenuButtonClick(e: Event, index: number): void { + this.state.expandTopLevelMenu(index) + this.renderState() + } + + private handleDocumentClick(e: Event): void { + const target = e.target as HTMLElement + if (!target.closest(this.MenuItemStyle)) { + this.state.closeAll() + this.renderState() + } + } +} diff --git a/desktop/src/ui/titleBarMenuState.ts b/desktop/src/ui/titleBarMenuState.ts new file mode 100644 index 0000000000..e76fe7c877 --- /dev/null +++ b/desktop/src/ui/titleBarMenuState.ts @@ -0,0 +1,122 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +export class TitleBarMenuState { + private altModeActive: boolean = false + private topLevelMenuExpanded: boolean = false + private focusedTopLevelMenuIndex: number | null = null + private focusedChildMenuIndex: number | null = null + + private readonly topLevelMenuCount: () => number; + private readonly menuItemsCount: (topLevelMenuIndex: number) => number; + + public constructor( + topLevelMenuCount: () => number, + menuItemsCount: (topLevelMenuIndex: number) => number + ) { + this.topLevelMenuCount = topLevelMenuCount + this.menuItemsCount = menuItemsCount + } + + public focusChildMenu() { + if (null != this.focusedTopLevelMenuIndex && this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public expandTopLevelMenu(topLevelMenuIndex: number): void { + if (0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { + return + } + + if (this.focusedTopLevelMenuIndex === topLevelMenuIndex && this.topLevelMenuExpanded) { + this.closeAll() + } else { + this.focusedTopLevelMenuIndex = topLevelMenuIndex + this.topLevelMenuExpanded = true + } + } + + public enterAltMode(topLevelMenuIndex: number | null): void { + this.altModeActive = true + + if (null == topLevelMenuIndex || 0 > topLevelMenuIndex || topLevelMenuIndex >= this.topLevelMenuCount()) { + return + } + + this.focusedTopLevelMenuIndex = topLevelMenuIndex + } + + public closeAll(): void { + this.altModeActive = false + this.topLevelMenuExpanded = false + this.focusedTopLevelMenuIndex = null + this.focusedChildMenuIndex = null + } + + public defocus(): void { + if (this.topLevelMenuExpanded) { + this.topLevelMenuExpanded = false + this.focusedChildMenuIndex = null + } else { + this.closeAll(); + } + } + + public moveFocusHorizontal(increment: number): void { + if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { + return; + } + + const topLevelMenuCount = this.topLevelMenuCount() + this.focusedTopLevelMenuIndex = (this.focusedTopLevelMenuIndex + topLevelMenuCount + increment) % topLevelMenuCount + + if (this.topLevelMenuExpanded) { + this.focusedChildMenuIndex = 0 + } + } + + public moveFocusVertical(increment: number): void { + if (Math.abs(increment) != 1 || null == this.focusedTopLevelMenuIndex) { + return; + } + + const menuItemsCount = this.menuItemsCount(this.focusedTopLevelMenuIndex) + + this.topLevelMenuExpanded = true + + if (menuItemsCount === 0) { + return; + } + + this.focusedChildMenuIndex = ((this.focusedChildMenuIndex ?? -1) + menuItemsCount + increment) % menuItemsCount + } + + public get isAltModeActive(): boolean { + return this.altModeActive + } + + public get isTopLevelMenuExpanded(): boolean { + return this.topLevelMenuExpanded + } + + public get FocusedTopLevelMenuIndex(): number | null { + return this.focusedTopLevelMenuIndex + } + + public get FocusedChildMenuIndex(): number | null { + return this.focusedChildMenuIndex + } +} \ No newline at end of file diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 1239c8f2c7..db1bf257c2 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -1,10 +1,8 @@ import { DownloadItem } from '@hcengineering/desktop-downloads' import { ScreenSource } from '@hcengineering/love' import { Plugin } from '@hcengineering/platform' +import { IpcRendererEvent } from 'electron' -/** - * @public - */ export interface Config { ACCOUNTS_URL: string AI_URL?: string @@ -75,9 +73,11 @@ export interface Branding { export type BrandingMap = Record -/** - * @public - */ +export const StandardMenuCommandOpenSettings = 'open-settings' as const +export const StandardMenuCommandSelectWorkspace = 'select-workspace' as const +export const StandardMenuCommandLogout = 'logout' as const +export type StandardMenuCommand = typeof StandardMenuCommandOpenSettings | typeof StandardMenuCommandSelectWorkspace | typeof StandardMenuCommandLogout + export interface NotificationParams { title: string body: string @@ -85,9 +85,11 @@ export interface NotificationParams { application: Plugin } -/** - * @public - */ +export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools' + , 'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const; + +export type MenuBarAction = typeof MenuBarActions[number]; + export interface IPCMainExposed { setBadge: (badge: number) => void setTitle: (title: string) => void @@ -107,4 +109,11 @@ export interface IPCMainExposed { cancelBackup: () => void startBackup: (token: string, endpoint: string, workspace: string) => void + + minimizeWindow: () => void + maximizeWindow: () => void + closeWindow: () => void + onWindowStateChange: (callback: (event: IpcRendererEvent, newState: string) => void) => void + isOsUsingDarkTheme: () => Promise + executeMenuBarAction: (action: MenuBarAction) => void } diff --git a/desktop/src/ui/typesUtils.ts b/desktop/src/ui/typesUtils.ts new file mode 100644 index 0000000000..ee7f569b2d --- /dev/null +++ b/desktop/src/ui/typesUtils.ts @@ -0,0 +1,24 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { IPCMainExposed, MenuBarAction, MenuBarActions } from './types' + +export function ipcMainExposed(): IPCMainExposed { + return (window as any).electron as IPCMainExposed +} + +export function isMenuBarAction(value: string): value is MenuBarAction { + return MenuBarActions.includes(value as MenuBarAction); +} diff --git a/desktop/webpack.config.js b/desktop/webpack.config.js index 39aea2d8e7..3242747514 100644 --- a/desktop/webpack.config.js +++ b/desktop/webpack.config.js @@ -318,6 +318,17 @@ module.exports = [ }, publicPath: '' }), + new HtmlWebpackPlugin({ + template: './src/ui/index.ejs', + filename: 'index.windows.html', + meta: { + viewport: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=1' + }, + publicPath: '', + templateParameters: { + isWindows: true + } + }), ...(!dev ? [new CompressionPlugin()] : []), // new MiniCssExtractPlugin({ // filename: '[name].[id][contenthash].css' diff --git a/packages/theme/styles/global.scss b/packages/theme/styles/global.scss index 1cfe1d7364..793cfa3086 100644 --- a/packages/theme/styles/global.scss +++ b/packages/theme/styles/global.scss @@ -77,8 +77,6 @@ scrollbar-color: var(--theme-navpanel-divider) transparent; scrollbar-width: none; --body-font-size: .875rem; - --status-bar-height: 36px; - --status-bar-normal-height: 36px; --panel-aside-width: 25rem; // 20rem; --font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto; --mono-font: 'IBM Plex Mono', monospace; @@ -90,10 +88,6 @@ &::after, &::before { box-sizing: border-box; } - - @media (max-width: 480px) { - --status-bar-height: 70px; - } } :root { .plyr__volume { @@ -161,6 +155,15 @@ body { color: var(--content-color); background-color: var(--theme-bg-color); user-select: none; + + --status-bar-height: 36px; + --status-bar-normal-height: 36px; + + @media (max-width: 480px) { + &:has(.workbench-container) { + --status-bar-height: 70px; + } + } } html, diff --git a/packages/ui/src/components/internal/Root.svelte b/packages/ui/src/components/internal/Root.svelte index 402858f694..118befe28c 100644 --- a/packages/ui/src/components/internal/Root.svelte +++ b/packages/ui/src/components/internal/Root.svelte @@ -303,8 +303,8 @@ display: flex; flex-direction: column; // height: 100vh; - height: 100%; - height: 100dvh; + height: calc(100% - var(--huly-top-indent, 0rem)); + height: calc(100dvh - var(--huly-top-indent, 0rem)); // height: var(--app-height); .antiStatusBar { @@ -316,11 +316,11 @@ font-size: 0.75rem; line-height: 150%; background-color: var(--theme-statusbar-color); - // border-bottom: 1px solid var(--theme-navpanel-divider); + z-index: 1; .history-box { -webkit-app-region: no-drag; - margin-left: 4.625rem; + margin-left: var(--huly-history-box-left-indent, 4.625rem); } .maintenanceScheduled { padding: 0 0.5rem; @@ -346,17 +346,6 @@ display: none; } - @media (max-width: 480px) { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px 0; - width: 100%; - - .second-row { - display: flex; - } - } @media print { display: none; } @@ -380,4 +369,18 @@ .left-items { overflow-x: auto; } + + @media (max-width: 480px) { + #ui-root:has(.workbench-container) .antiStatusBar { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 0; + width: 100%; + + .second-row { + display: flex; + } + } + } diff --git a/plugins/login-resources/src/components/LoginApp.svelte b/plugins/login-resources/src/components/LoginApp.svelte index ea6b2748c6..7dd0ee08c3 100644 --- a/plugins/login-resources/src/components/LoginApp.svelte +++ b/plugins/login-resources/src/components/LoginApp.svelte @@ -142,7 +142,7 @@
{getMetadata(workbench.metadata.PlatformTitle)}