mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-30 13:35:04 +02:00
Windows app title bar menu. (#9542)
Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
Generated
+60
-3
@@ -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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
projects: [
|
||||
{
|
||||
displayName: 'node',
|
||||
testEnvironment: 'node',
|
||||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/src/__test__/main/**/*.test.ts']
|
||||
},
|
||||
{
|
||||
displayName: 'jsdom',
|
||||
testEnvironment: 'jsdom',
|
||||
preset: 'ts-jest',
|
||||
testMatch: ['<rootDir>/src/__test__/ui/**/*.test.ts']
|
||||
}
|
||||
],
|
||||
roots: ["./src", "./tests"],
|
||||
coverageReporters: ["text-summary", "html"],
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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('Ф');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
]
|
||||
+74
-21
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
}
|
||||
})
|
||||
|
||||
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<void> => {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,239 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<script>
|
||||
|
||||
window.desktopPlatform=true
|
||||
|
||||
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
|
||||
window.windowsPlatform = true;
|
||||
<% } else { %>
|
||||
window.windowsPlatform = false;
|
||||
<% } %>
|
||||
|
||||
</script>
|
||||
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
|
||||
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-secondary: #f3f3f3;
|
||||
--bg-tertiary: #ffffff;
|
||||
--bg-hover: #e8e8e8;
|
||||
--bg-separator: #d4d4d4;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--border-color: #e5e5e5;
|
||||
--accent-color: #0078d4;
|
||||
--close-hover: #e81123;
|
||||
--huly-top-indent: 32px;
|
||||
--huly-history-box-left-indent: 0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-secondary: #323233;
|
||||
--bg-tertiary: #252526;
|
||||
--bg-hover: #2a2d2e;
|
||||
--bg-separator: #454545;
|
||||
--text-primary: #cccccc;
|
||||
--text-secondary: #888888;
|
||||
--border-color: #2d2d30;
|
||||
--accent-color: #007acc;
|
||||
--close-hover: #c42b1c;
|
||||
}
|
||||
|
||||
.desktop-app-titlebar {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
height: 32px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
-webkit-app-region: drag; /* allows to drag window around */
|
||||
}
|
||||
|
||||
.desktop-app-menu-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.desktop-app-menu-bar {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.desktop-app-menu-item {
|
||||
position: relative;
|
||||
align-items: normal; /* explicitly set this up to prevent values from above */
|
||||
}
|
||||
|
||||
.desktop-app-top-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
align-items: normal; /* explicitly set this up to prevent values from above */
|
||||
}
|
||||
|
||||
.desktop-app-top-menu-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.desktop-app-top-menu-button:focus {
|
||||
outline: 1px solid var(--accent-color);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.desktop-app-top-menu-button.desktop-app-alt-mode {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.desktop-app-accelerator {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
align-items: normal; /* explicitly set this up to prevent values from above */
|
||||
}
|
||||
|
||||
.desktop-app-alt-active .desktop-app-accelerator {
|
||||
border-bottom-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
border: 1px solid var(--bg-separator);
|
||||
min-width: 180px;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .desktop-app-dropdown-menu {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-item:hover,
|
||||
.desktop-app-dropdown-item.desktop-app-keyboard-selected {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-item .desktop-app-shortcut {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-item .desktop-app-accelerator {
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.desktop-app-alt-active .desktop-app-dropdown-item .desktop-app-accelerator {
|
||||
border-bottom-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.desktop-app-dropdown-separator {
|
||||
height: 1px;
|
||||
background-color: var(--bg-separator);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.desktop-app-window-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.desktop-app-window-controls {
|
||||
display: flex;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.desktop-app-control-button {
|
||||
width: 46px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.desktop-app-control-button:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.desktop-app-control-button.desktop-app-close:hover {
|
||||
background-color: var(--close-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
</style>
|
||||
<% } %>
|
||||
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; overflow: hidden;">
|
||||
|
||||
<% if (typeof isWindows !== 'undefined' && isWindows) { %>
|
||||
<div id="desktop-app-titlebar-root">
|
||||
<div class="desktop-app-titlebar">
|
||||
<div class="desktop-app-menu-container">
|
||||
</div>
|
||||
|
||||
<div class="desktop-app-window-title" id="application-title-bar-caption">Huly</div>
|
||||
|
||||
<div class="desktop-app-window-controls">
|
||||
<button class="desktop-app-control-button desktop-app-minimize" id="minimize-button">─</button>
|
||||
<button class="desktop-app-control-button desktop-app-maximize" id="maximize-button">□</button>
|
||||
<button class="desktop-app-control-button desktop-app-close" id="close-button">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+38
-9
@@ -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] })
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<PlatformParameters> {
|
||||
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<void> {
|
||||
}
|
||||
|
||||
console.log('Initial location is: ', getCurrentLocation())
|
||||
|
||||
return new PlatformParameters(new PlatformBranding(title))
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLElement>(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<HTMLElement> {
|
||||
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<HTMLElement>(this.DropdownItemStyle)
|
||||
}
|
||||
return document.createDocumentFragment().querySelectorAll<HTMLElement>('*')
|
||||
}
|
||||
|
||||
private async executeMenuAction(ipcMain: IPCMainExposed, action: MenuBarAction): Promise<void> {
|
||||
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<HTMLButtonElement>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+18
-9
@@ -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<string, Branding>
|
||||
|
||||
/**
|
||||
* @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<boolean>
|
||||
executeMenuBarAction: (action: MenuBarAction) => void
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
<div
|
||||
style:position="fixed"
|
||||
style:left={$deviceInfo.docWidth <= 480 ? '.75rem' : '1.75rem'}
|
||||
style:top={'3rem'}
|
||||
style:top={'calc(3rem + var(--huly-top-indent, 0rem))'}
|
||||
class="flex-row-center"
|
||||
>
|
||||
<LoginIcon /><span class="fs-title ml-2">{getMetadata(workbench.metadata.PlatformTitle)}</span>
|
||||
|
||||
Reference in New Issue
Block a user