mirror of
https://github.com/hcengineering/platform.git
synced 2026-10-01 22:15:04 +02:00
Drawing board undo/redo and bug fixes (#9726)
* Undo-redo for screenshot drawer, several bugfixes. Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Undo-redo for collaborative drawer. Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Drawing board refinement: bugfixes, component refactoring and unit testing Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * More tests, canvas resize fix, types for different points Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Version lock Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Names refactoring Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Formatting Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> * Formatting Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com> --------- Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
This commit is contained in:
Generated
+10
-8
@@ -4462,7 +4462,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/chat-resources@file:projects/chat-resources.tgz':
|
||||
resolution: {integrity: sha512-MGvlWuCLcODD1UenrHwYOU2VkQmEdu2OfDx5v0+oxMiEEUJ3Ds+3KM+qY/bMHU9yWlK0EueaRZikzhxmLDjowg==, tarball: file:projects/chat-resources.tgz}
|
||||
resolution: {integrity: sha512-q6Vm3eJ2uF6dGabNBr9nckiHkBz5yBFVvOqVWqKGidqhEzCV4nVuGF9Ydqonx1CxNrM2IBX4d8UOdh3DqQjyoQ==, tarball: file:projects/chat-resources.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/chat@file:projects/chat.tgz':
|
||||
@@ -4610,7 +4610,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/desktop@file:projects/desktop.tgz':
|
||||
resolution: {integrity: sha512-4cnp4PcBSOLE4lMLAU3fjhTU3WviHDFCZk30KSDeCVM+uVNu20aMhOiMEgq9LOYG9vKkN2MZMHbTew5lCj4nkg==, tarball: file:projects/desktop.tgz}
|
||||
resolution: {integrity: sha512-UeeIHpzWxlnM18HaA0gM0KyCUakZGSUhRmgNuwGGqR0nvD8N0NunLjVztSrefFpXuyNcSiN7Yl0pPin+J782cg==, tarball: file:projects/desktop.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz':
|
||||
@@ -4902,7 +4902,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-all@file:projects/model-all.tgz':
|
||||
resolution: {integrity: sha512-96rUPDZ9rzWvX+6dLxraMStJcDMvgz3Ykasn9OuijQjiKuo0343WyMzj/0pODIsYuQoVsSRDkgdkE/EsAgaSkg==, tarball: file:projects/model-all.tgz}
|
||||
resolution: {integrity: sha512-t5iuzjIpCXYbDndJi8f0C3hB0nFK+Cb5LWuymlj92UqXLQp54gaoAX8XZD65TuoeUGCS1CXafAZoo08varKX/A==, tarball: file:projects/model-all.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-analytics-collector@file:projects/model-analytics-collector.tgz':
|
||||
@@ -4998,7 +4998,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-huly-mail@file:projects/model-huly-mail.tgz':
|
||||
resolution: {integrity: sha512-kmPJ8j1znoHWNdEg2AcCtobcTy9LnF7NZAFH3Ed1PoFYxp8f2bsNsdc7NrLAsNhTx67o1GmewPnlThHijoHc8w==, tarball: file:projects/model-huly-mail.tgz}
|
||||
resolution: {integrity: sha512-PeVlQexeVv6XeaGH0zZUzvClDSEBzPuAWRXNtRDtg4exHaNVSXPTZJbnDGVDlsGreqyWMT4m1nkorsifshHb2Q==, tarball: file:projects/model-huly-mail.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-inbox@file:projects/model-inbox.tgz':
|
||||
@@ -5086,7 +5086,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-server-card@file:projects/model-server-card.tgz':
|
||||
resolution: {integrity: sha512-HqMdfGbOq9xHHde5zD2CI3R60ftPv9HoQxA2xeCdqRpecvmJMiTHOilRKKbCV82ibWUhrgG7uvwLmuZWaYKZlQ==, tarball: file:projects/model-server-card.tgz}
|
||||
resolution: {integrity: sha512-NPNWunZ9U4w0zuaSI1t2cbcCeeAAgdP4TZ3rr3V295JcdJRzoFhspWwclyCVf7E3lBgIAD7gtqFgeQ0xayxhRw==, tarball: file:projects/model-server-card.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-server-chunter@file:projects/model-server-chunter.tgz':
|
||||
@@ -5446,7 +5446,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/presentation@file:projects/presentation.tgz':
|
||||
resolution: {integrity: sha512-3GXelK24zvVFtIfKALBxN76biAVNO6/Lql+VnntoYLPbd3fXoW+Or8YXfd8NkzCeCNPSv8peCCanLDn6+3KztA==, tarball: file:projects/presentation.tgz}
|
||||
resolution: {integrity: sha512-j00+daHWZ+3GFc+1dXsCeABWAuTZbIG8mvvvPIiLGIfqVSmkH4trxbsP0appWm+/bkPfwtP0wJjhsQ4d7kgzMQ==, tarball: file:projects/presentation.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/print-assets@file:projects/print-assets.tgz':
|
||||
@@ -5474,7 +5474,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/prod@file:projects/prod.tgz':
|
||||
resolution: {integrity: sha512-1T4lh2EXGJzKr9TVDZjM43NGxyRvl3urz5oLbnGVCCHranalzlpfl4eN56DMxKfBvd0wG0KPi7lPWRdJlFL4WA==, tarball: file:projects/prod.tgz}
|
||||
resolution: {integrity: sha512-hS9pkkwUUUYlP3t7orkFEAS8lv3TkOZ2iTgxaikc2u7AQSI+SfuD+HaNQ26onEwftVJOhrcXEYapPq1eCHMoPg==, tarball: file:projects/prod.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/products-assets@file:projects/products-assets.tgz':
|
||||
@@ -5626,7 +5626,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/server-card-resources@file:projects/server-card-resources.tgz':
|
||||
resolution: {integrity: sha512-rwCiQt+1Iojo7i2KGIbcWm3XELpy6kln+CT1tkpfwagiwd45uHFQ9+fMNXr1cLfFQbCblRvMn1hlA6j/7MjqWw==, tarball: file:projects/server-card-resources.tgz}
|
||||
resolution: {integrity: sha512-sChLj/NXSWBaxQz3dYkLpJuIUBgxyzawYNOa9vgfB3rLEB0Vaf1KAB/FqLbIcUGXkOPbNKR2qxaU/2glaNv3SA==, tarball: file:projects/server-card-resources.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/server-card@file:projects/server-card.tgz':
|
||||
@@ -24808,6 +24808,7 @@ snapshots:
|
||||
|
||||
'@rush-temp/presentation@file:projects/presentation.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(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)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@testing-library/jest-dom': 6.6.3
|
||||
'@types/jest': 29.5.12
|
||||
'@types/png-chunks-extract': 1.0.2
|
||||
'@types/uuid': 8.3.4
|
||||
@@ -24833,6 +24834,7 @@ snapshots:
|
||||
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)
|
||||
typescript: 5.8.3
|
||||
uuid: 8.3.2
|
||||
yjs: 13.6.23
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@jest/types'
|
||||
|
||||
@@ -13,353 +13,353 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { MenuBuilder } from '../../ui/titleBarMenu';
|
||||
import { MenuBarAction } from '../../ui/types';
|
||||
import { MenuBuilder } from '../../ui/titleBarMenu'
|
||||
import { MenuBarAction } from '../../ui/types'
|
||||
|
||||
describe('MenuBuilder', () => {
|
||||
let systemUnderTest: MenuBuilder;
|
||||
let systemUnderTest: MenuBuilder
|
||||
|
||||
beforeEach(() => {
|
||||
systemUnderTest = new MenuBuilder()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function verifyTopLevelMenu (menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string): void {
|
||||
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 = 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('Ф');
|
||||
});
|
||||
});
|
||||
});
|
||||
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('Ф')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,77 +16,77 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } 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);
|
||||
}
|
||||
export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction): void {
|
||||
if (mainWindow == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const zoomStep = 0.1;
|
||||
|
||||
switch (action) {
|
||||
case 'settings':
|
||||
mainWindow.webContents.send(CommandOpenSettings)
|
||||
break;
|
||||
case 'select-workspace':
|
||||
mainWindow.webContents.send(CommandSelectWorkspace)
|
||||
break;
|
||||
case 'logout':
|
||||
mainWindow.webContents.send(CommandLogout)
|
||||
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);
|
||||
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(CommandOpenSettings)
|
||||
break
|
||||
case 'select-workspace':
|
||||
mainWindow.webContents.send(CommandSelectWorkspace)
|
||||
break
|
||||
case 'logout':
|
||||
mainWindow.webContents.send(CommandLogout)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//
|
||||
|
||||
import { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout, } from '../ui/types'
|
||||
import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout } from '../ui/types'
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
@@ -25,7 +25,7 @@ import * as path from 'path'
|
||||
import { Config, MenuBarAction, NotificationParams, JumpListSpares } from '../ui/types'
|
||||
import { getOptions } from './args'
|
||||
import { addMenus } from './standardMenu'
|
||||
import { dipatchMenuBarAction } from './customMenu'
|
||||
import { dispatchMenuBarAction } from './customMenu'
|
||||
import { addPermissionHandlers } from './permissions'
|
||||
import autoUpdater from './updater'
|
||||
import { generateId } from '@hcengineering/core'
|
||||
@@ -455,7 +455,7 @@ ipcMain.handle('get-is-os-using-dark-theme', () => {
|
||||
})
|
||||
|
||||
ipcMain.handle('menu-action', async (_event: any, action: MenuBarAction) => {
|
||||
dipatchMenuBarAction(mainWindow, action)
|
||||
dispatchMenuBarAction(mainWindow, action)
|
||||
})
|
||||
|
||||
if (isWindows) {
|
||||
|
||||
@@ -139,7 +139,7 @@ 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, } from './types'
|
||||
import { Branding, Config } from './types'
|
||||
import { ipcMainExposed } from './typesUtils'
|
||||
|
||||
import github, { githubId } from '@hcengineering/github'
|
||||
@@ -252,18 +252,20 @@ function configureI18n (): void {
|
||||
}
|
||||
|
||||
export class PlatformBranding {
|
||||
constructor(private title: string) {
|
||||
constructor (private readonly title: string) {
|
||||
}
|
||||
public getTitle(): string {
|
||||
return this.title;
|
||||
|
||||
public getTitle (): string {
|
||||
return this.title
|
||||
}
|
||||
}
|
||||
|
||||
export class PlatformParameters {
|
||||
constructor(private branding: PlatformBranding) {
|
||||
constructor (private readonly branding: PlatformBranding) {
|
||||
}
|
||||
public getBranding(): PlatformBranding {
|
||||
return this.branding;
|
||||
|
||||
public getBranding (): PlatformBranding {
|
||||
return this.branding
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,11 +65,11 @@ const expose: IPCMainExposed = {
|
||||
minimizeWindow: () => {
|
||||
ipcRenderer.invoke('window-minimize')
|
||||
},
|
||||
|
||||
|
||||
maximizeWindow: () => {
|
||||
ipcRenderer.invoke('window-maximize')
|
||||
},
|
||||
|
||||
|
||||
closeWindow: () => {
|
||||
ipcRenderer.invoke('window-close')
|
||||
},
|
||||
@@ -82,7 +82,7 @@ const expose: IPCMainExposed = {
|
||||
ipcRenderer.on('window-focus-loss', callback)
|
||||
},
|
||||
|
||||
isOsUsingDarkTheme: async () => {
|
||||
isOsUsingDarkTheme: async () => {
|
||||
return await ipcRenderer.invoke('get-is-os-using-dark-theme')
|
||||
},
|
||||
|
||||
@@ -181,6 +181,6 @@ const expose: IPCMainExposed = {
|
||||
cancelBackup: () => { ipcRenderer.send('cancel-backup') },
|
||||
startBackup: (token, endpoint, wsIds) => { ipcRenderer.send('start-backup', token, endpoint, wsIds) },
|
||||
|
||||
rebuildJumpList: (spares: JumpListSpares) => ipcRenderer.send('rebuild-user-jump-list', spares)
|
||||
rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) }
|
||||
}
|
||||
contextBridge.exposeInMainWorld('electron', expose)
|
||||
|
||||
+13
-13
@@ -83,12 +83,12 @@ export const CommandSelectWorkspace = 'select-workspace' as const
|
||||
export const CommandLogout = 'logout' as const
|
||||
export const CommandOpenApplication = 'open-application' as const
|
||||
|
||||
export type Command =
|
||||
typeof CommandOpenSettings |
|
||||
typeof CommandOpenInbox |
|
||||
typeof CommandOpenOffice |
|
||||
typeof CommandOpenPlanner |
|
||||
typeof CommandSelectWorkspace |
|
||||
export type Command =
|
||||
typeof CommandOpenSettings |
|
||||
typeof CommandOpenInbox |
|
||||
typeof CommandOpenOffice |
|
||||
typeof CommandOpenPlanner |
|
||||
typeof CommandSelectWorkspace |
|
||||
typeof CommandLogout |
|
||||
typeof CommandOpenApplication
|
||||
|
||||
@@ -102,15 +102,15 @@ export interface NotificationParams {
|
||||
objectClass?: Ref<Class<Doc>>
|
||||
}
|
||||
|
||||
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 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 type MenuBarAction = typeof MenuBarActions[number]
|
||||
|
||||
export interface JumpListSpares {
|
||||
applications: LaunchApplication[],
|
||||
settingsLabel: string,
|
||||
inboxLabel: string,
|
||||
applications: LaunchApplication[]
|
||||
settingsLabel: string
|
||||
inboxLabel: string
|
||||
}
|
||||
|
||||
export interface LaunchApplication {
|
||||
@@ -144,7 +144,7 @@ export interface IPCMainExposed {
|
||||
closeWindow: () => void
|
||||
onWindowStateChange: (callback: (event: IpcRendererEvent, newState: string) => void) => void
|
||||
onWindowFocusLoss: (callback: () => void) => void
|
||||
|
||||
|
||||
isOsUsingDarkTheme: () => Promise<boolean>
|
||||
executeMenuBarAction: (action: MenuBarAction) => void
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
import { IPCMainExposed, MenuBarAction, MenuBarActions } from './types'
|
||||
|
||||
export function ipcMainExposed(): IPCMainExposed {
|
||||
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);
|
||||
export function isMenuBarAction (value: string): value is MenuBarAction {
|
||||
return MenuBarActions.includes(value as MenuBarAction)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)']
|
||||
projects: [
|
||||
// Default configuration for most tests (node environment)
|
||||
{
|
||||
displayName: 'node',
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
|
||||
testPathIgnorePatterns: ['.*drawing\\.test\\.ts$']
|
||||
},
|
||||
// Configuration for drawing tests (jsdom environment)
|
||||
{
|
||||
displayName: 'jsdom',
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['**/drawing.test.ts']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Uložit",
|
||||
"Saved": "Uloženo...",
|
||||
"Download": "Stáhnout",
|
||||
"DownloadOriginal": "Stáhnout originál",
|
||||
"Delete": "Smazat",
|
||||
"Close": "Zavřít",
|
||||
"NotSelected": "Nevybráno",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Zkopírováno!",
|
||||
"CopyLink": "Zkopírovat odkaz",
|
||||
"AccessDenied": "Objekt neexistuje nebo k němu nemáte oprávnění.",
|
||||
"UnableToFollowMention": "Nelze otevřít zmíněný objekt"
|
||||
"UnableToFollowMention": "Nelze otevřít zmíněný objekt",
|
||||
"Undo": "Zpět",
|
||||
"Redo": "Znovu",
|
||||
"ClearCanvas": "Vyčistit plátno",
|
||||
"PenTool": "Nástroj pero",
|
||||
"EraserTool": "Nástroj guma",
|
||||
"PanTool": "Nástroj posun",
|
||||
"TextTool": "Nástroj text",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Spravovat barevné předvolby"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Soubor je příliš velký"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Speichern",
|
||||
"Saved": "Gespeichert...",
|
||||
"Download": "Herunterladen",
|
||||
"DownloadOriginal": "Original herunterladen",
|
||||
"Delete": "Löschen",
|
||||
"Close": "Schließen",
|
||||
"NotSelected": "Nicht ausgewählt",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Kopiert!",
|
||||
"CopyLink": "Link kopieren",
|
||||
"AccessDenied": "Das Objekt existiert nicht oder Sie haben keinen Zugriff darauf.",
|
||||
"UnableToFollowMention": "Das erwähnte Objekt kann nicht geöffnet werden"
|
||||
"UnableToFollowMention": "Das erwähnte Objekt kann nicht geöffnet werden",
|
||||
"Undo": "Rückgängig",
|
||||
"Redo": "Wiederholen",
|
||||
"ClearCanvas": "Leinwand löschen",
|
||||
"PenTool": "Stift-Werkzeug",
|
||||
"EraserTool": "Radiergummi-Werkzeug",
|
||||
"PanTool": "Verschieben-Werkzeug",
|
||||
"TextTool": "Text-Werkzeug",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Farbpresets verwalten"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Datei zu groß"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Save",
|
||||
"Saved": "Saved...",
|
||||
"Download": "Download",
|
||||
"DownloadOriginal": "Download original",
|
||||
"Delete": "Delete",
|
||||
"Close": "Close",
|
||||
"NotSelected": "Not selected",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Copied!",
|
||||
"CopyLink": "Copy link",
|
||||
"AccessDenied": "Object doesn't exist or you are not permitted to access it.",
|
||||
"UnableToFollowMention": "Unable to open mentioned object"
|
||||
"UnableToFollowMention": "Unable to open mentioned object",
|
||||
"Undo": "Undo",
|
||||
"Redo": "Redo",
|
||||
"ClearCanvas": "Clear canvas",
|
||||
"PenTool": "Pen tool",
|
||||
"EraserTool": "Eraser tool",
|
||||
"PanTool": "Pan tool",
|
||||
"TextTool": "Text tool",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Manage color presets"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File too large"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Guardar",
|
||||
"Saved": "Guardado...",
|
||||
"Download": "Descargar",
|
||||
"DownloadOriginal": "Descargar original",
|
||||
"Delete": "Eliminar",
|
||||
"Close": "Cerrar",
|
||||
"NotSelected": "No seleccionado",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "¡Copiado!",
|
||||
"CopyLink": "Copiar enlace",
|
||||
"AccessDenied": "El objeto no existe o no tienes permiso para acceder a él.",
|
||||
"UnableToFollowMention": "No se puede abrir el objeto mencionado"
|
||||
"UnableToFollowMention": "No se puede abrir el objeto mencionado",
|
||||
"Undo": "Deshacer",
|
||||
"Redo": "Rehacer",
|
||||
"ClearCanvas": "Limpiar lienzo",
|
||||
"PenTool": "Herramienta lápiz",
|
||||
"EraserTool": "Herramienta borrador",
|
||||
"PanTool": "Herramienta mover",
|
||||
"TextTool": "Herramienta texto",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Gestionar preajustes de color"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Archivo demasiado grande"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Enregistrer",
|
||||
"Saved": "Enregistré...",
|
||||
"Download": "Télécharger",
|
||||
"DownloadOriginal": "Télécharger l'original",
|
||||
"Delete": "Supprimer",
|
||||
"Close": "Fermer",
|
||||
"NotSelected": "Non sélectionné",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Copié !",
|
||||
"CopyLink": "Copier le lien",
|
||||
"AccessDenied": "L’objet n’existe pas ou vous n’êtes pas autorisé à y accéder.",
|
||||
"UnableToFollowMention": "Impossible d’ouvrir l’objet mentionné"
|
||||
"UnableToFollowMention": "Impossible d’ouvrir l’objet mentionné",
|
||||
"Undo": "Annuler",
|
||||
"Redo": "Rétablir",
|
||||
"ClearCanvas": "Effacer la toile",
|
||||
"PenTool": "Outil stylo",
|
||||
"EraserTool": "Outil gomme",
|
||||
"PanTool": "Outil déplacement",
|
||||
"TextTool": "Outil texte",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Gérer les préréglages de couleur"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Fichier trop volumineux"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Salva",
|
||||
"Saved": "Salvato...",
|
||||
"Download": "Scarica",
|
||||
"DownloadOriginal": "Scarica originale",
|
||||
"Delete": "Elimina",
|
||||
"Close": "Chiudi",
|
||||
"NotSelected": "Non selezionato",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Copiato!",
|
||||
"CopyLink": "Copia collegamento",
|
||||
"AccessDenied": "L'oggetto non esiste oppure non hai i permessi per accedervi.",
|
||||
"UnableToFollowMention": "Impossibile aprire l'oggetto menzionato"
|
||||
"UnableToFollowMention": "Impossibile aprire l'oggetto menzionato",
|
||||
"Undo": "Annulla",
|
||||
"Redo": "Ripristina",
|
||||
"ClearCanvas": "Cancella la tela",
|
||||
"PenTool": "Strumento penna",
|
||||
"EraserTool": "Strumento gomma",
|
||||
"PanTool": "Strumento sposta",
|
||||
"TextTool": "Strumento testo",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Gestisci i preset di colore"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "File troppo grande"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "保存",
|
||||
"Saved": "保存しました",
|
||||
"Download": "ダウンロード",
|
||||
"DownloadOriginal": "オリジナルをダウンロード",
|
||||
"Delete": "削除",
|
||||
"Close": "閉じる",
|
||||
"NotSelected": "未選択",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "コピーしました",
|
||||
"CopyLink": "リンクをコピー",
|
||||
"AccessDenied": "オブジェクトが存在しないか、アクセス権限がありません。",
|
||||
"UnableToFollowMention": "言及されたオブジェクトを開くことができません"
|
||||
"UnableToFollowMention": "言及されたオブジェクトを開くことができません",
|
||||
"Undo": "元に戻す",
|
||||
"Redo": "やり直し",
|
||||
"ClearCanvas": "キャンバスをクリア",
|
||||
"PenTool": "ペンツール",
|
||||
"EraserTool": "消しゴムツール",
|
||||
"PanTool": "パンツール",
|
||||
"TextTool": "テキストツール",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "カラープリセットを管理"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "ファイルサイズが大きすぎます"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Guardar",
|
||||
"Saved": "Guardado...",
|
||||
"Download": "Transferir",
|
||||
"DownloadOriginal": "Transferir original",
|
||||
"Delete": "Eliminar",
|
||||
"Close": "Fechar",
|
||||
"NotSelected": "Não selecionado",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Copiado!",
|
||||
"CopyLink": "Copiar link",
|
||||
"AccessDenied": "O objeto não existe ou você não tem permissão para acessá-lo.",
|
||||
"UnableToFollowMention": "Não foi possível abrir o objeto mencionado"
|
||||
"UnableToFollowMention": "Não foi possível abrir o objeto mencionado",
|
||||
"Undo": "Desfazer",
|
||||
"Redo": "Refazer",
|
||||
"ClearCanvas": "Limpar tela",
|
||||
"PenTool": "Ferramenta caneta",
|
||||
"EraserTool": "Ferramenta borracha",
|
||||
"PanTool": "Ferramenta mover",
|
||||
"TextTool": "Ferramenta texto",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Gerenciar predefinições de cor"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Ficheiro demasiado grande"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "Сохранить",
|
||||
"Saved": "Сохранено...",
|
||||
"Download": "Скачать",
|
||||
"DownloadOriginal": "Скачать оригинал",
|
||||
"Delete": "Удалить",
|
||||
"Close": "Закрыть",
|
||||
"NotSelected": "Не выбрано",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "Скопировано!",
|
||||
"CopyLink": "Копировать ссылку",
|
||||
"AccessDenied": "Объект не существует или у вас нет прав доступа.",
|
||||
"UnableToFollowMention": "Не удалось открыть упомянутый объект"
|
||||
"UnableToFollowMention": "Не удалось открыть упомянутый объект",
|
||||
"Undo": "Отменить",
|
||||
"Redo": "Повторить",
|
||||
"ClearCanvas": "Очистить холст",
|
||||
"PenTool": "Инструмент перо",
|
||||
"EraserTool": "Инструмент ластик",
|
||||
"PanTool": "Инструмент перемещения",
|
||||
"TextTool": "Инструмент текст",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "Управление цветовыми пресетами"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "Файл слишком большой"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Save": "保存",
|
||||
"Saved": "已保存...",
|
||||
"Download": "下载",
|
||||
"DownloadOriginal": "下载原文件",
|
||||
"Delete": "删除",
|
||||
"Close": "关闭",
|
||||
"NotSelected": "未选择",
|
||||
@@ -44,7 +45,16 @@
|
||||
"DocumentUrlCopied": "已复制",
|
||||
"CopyLink": "复制链接",
|
||||
"AccessDenied": "对象不存在或您无权访问。",
|
||||
"UnableToFollowMention": "无法打开提及的对象"
|
||||
"UnableToFollowMention": "无法打开提及的对象",
|
||||
"Undo": "撤销",
|
||||
"Redo": "重做",
|
||||
"ClearCanvas": "清除画布",
|
||||
"PenTool": "画笔工具",
|
||||
"EraserTool": "橡皮擦工具",
|
||||
"PanTool": "移动工具",
|
||||
"TextTool": "文字工具",
|
||||
"ColorTooltip": "{color}",
|
||||
"PaletteManagementMenu": "管理颜色预设"
|
||||
},
|
||||
"status": {
|
||||
"FileTooLarge": "文件太大"
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/uuid": "^8.3.1"
|
||||
@@ -61,7 +62,8 @@
|
||||
"fast-equals": "^5.2.2",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"svelte": "^4.2.19",
|
||||
"uuid": "^8.3.2"
|
||||
"uuid": "^8.3.2",
|
||||
"yjs": "^13.6.23"
|
||||
},
|
||||
"repository": "https://github.com/hcengineering/platform",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
//
|
||||
// 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 { makeCommandUid, drawing } from '../drawing'
|
||||
import type { DrawTextCmd, CommandUid, DrawingTool, DrawingProps, DrawingCmd } from '../drawing'
|
||||
import { makeCanvasPoint } from '../drawingUtils'
|
||||
|
||||
const fakeCanvasContext = {
|
||||
clearRect: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
strokeText: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 50 })),
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
reset: jest.fn(),
|
||||
scale: jest.fn(),
|
||||
translate: jest.fn(),
|
||||
rotate: jest.fn(),
|
||||
setTransform: jest.fn(),
|
||||
transform: jest.fn(),
|
||||
createLinearGradient: jest.fn(),
|
||||
createRadialGradient: jest.fn(),
|
||||
createPattern: jest.fn(),
|
||||
arc: jest.fn(),
|
||||
arcTo: jest.fn(),
|
||||
bezierCurveTo: jest.fn(),
|
||||
quadraticCurveTo: jest.fn(),
|
||||
closePath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
isPointInPath: jest.fn(),
|
||||
drawImage: jest.fn(),
|
||||
putImageData: jest.fn(),
|
||||
getImageData: jest.fn(),
|
||||
createImageData: jest.fn(),
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
strokeStyle: '#000000',
|
||||
fillStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
lineCap: 'butt',
|
||||
lineJoin: 'miter',
|
||||
miterLimit: 10,
|
||||
lineDashOffset: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
shadowBlur: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0)',
|
||||
font: '10px sans-serif',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
direction: 'inherit'
|
||||
}
|
||||
|
||||
describe('drawing module tests', () => {
|
||||
describe('utilities tests', () => {
|
||||
describe('makeCommandUid', () => {
|
||||
it('id length', () => {
|
||||
const uid = makeCommandUid()
|
||||
expect(uid.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('values uniqueness', () => {
|
||||
const first = makeCommandUid()
|
||||
const second = makeCommandUid()
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawing factory function tests', () => {
|
||||
function prepareFakeCanvas (): void {
|
||||
HTMLCanvasElement.prototype.getContext = jest.fn(() => fakeCanvasContext) as any
|
||||
;(globalThis as any).ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn()
|
||||
}))
|
||||
}
|
||||
|
||||
let drawingPlugInPoint: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
prepareFakeCanvas()
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
drawingPlugInPoint = document.createElement('div')
|
||||
document.body.appendChild(drawingPlugInPoint)
|
||||
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
|
||||
if (drawingPlugInPoint.parentNode != null) {
|
||||
drawingPlugInPoint.parentNode.removeChild(drawingPlugInPoint)
|
||||
}
|
||||
})
|
||||
|
||||
it('create a drawing board', () => {
|
||||
const drawingBoard = drawing(drawingPlugInPoint, {
|
||||
readonly: false,
|
||||
imageWidth: 40,
|
||||
imageHeight: 40,
|
||||
commands: []
|
||||
})
|
||||
|
||||
expect(drawingBoard).toBeDefined()
|
||||
})
|
||||
|
||||
describe('text editing', () => {
|
||||
const DefaultPenColor: string = 'red'
|
||||
const DefaultTool: DrawingTool = 'pen'
|
||||
const EmptyCommandUid = '' as CommandUid
|
||||
const DefaultDrawingBoardWidth = 200
|
||||
const DefaultDrawingBoardHeight = 200
|
||||
|
||||
const createTextCommandStub = (
|
||||
overrides: Partial<DrawTextCmd> = {}
|
||||
): { textCommandUid: CommandUid, textCommand: DrawTextCmd } => {
|
||||
const textCommandUid = makeCommandUid()
|
||||
const textCommand: DrawTextCmd = {
|
||||
id: textCommandUid,
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
pos: makeCanvasPoint(10, 10),
|
||||
fontSize: 12,
|
||||
fontFace: '"IBM Plex Sans"',
|
||||
color: 'green',
|
||||
...overrides
|
||||
}
|
||||
return { textCommandUid, textCommand }
|
||||
}
|
||||
|
||||
const createDrawingBoard = (
|
||||
existingTextCommand: DrawTextCmd | undefined,
|
||||
overrides: Partial<Parameters<typeof drawing>[1]> = {}
|
||||
): { drawingBoard: ReturnType<typeof drawing>, initialState: DrawingProps } => {
|
||||
const commands = existingTextCommand === undefined ? [] : [existingTextCommand]
|
||||
|
||||
const initialState = {
|
||||
readonly: false,
|
||||
imageWidth: DefaultDrawingBoardWidth,
|
||||
imageHeight: DefaultDrawingBoardHeight,
|
||||
drawing: commands,
|
||||
penColor: DefaultPenColor,
|
||||
tool: DefaultTool,
|
||||
...overrides
|
||||
}
|
||||
return { drawingBoard: drawing(drawingPlugInPoint, initialState), initialState }
|
||||
}
|
||||
|
||||
const findContentEditableElement = (container: Element): Element | null => {
|
||||
const allDivs = container.querySelectorAll('div')
|
||||
for (let i = 0; i < allDivs.length; i++) {
|
||||
const div = allDivs[i] as HTMLElement
|
||||
if (div.contentEditable === 'true') {
|
||||
return div
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const isLiveTextEditorPresent = (container: Element): boolean => {
|
||||
return findContentEditableElement(container) !== null
|
||||
}
|
||||
|
||||
const setTextEditorText = (container: Element, text: string): void => {
|
||||
const editor = findContentEditableElement(container)
|
||||
if (editor != null) {
|
||||
const editorElement = editor as HTMLElement
|
||||
editorElement.innerText = text
|
||||
}
|
||||
}
|
||||
|
||||
const getTextEditorColor = (container: Element): string | null => {
|
||||
const editor = findContentEditableElement(container)
|
||||
if (editor != null) {
|
||||
const editorElement = editor as HTMLElement
|
||||
const color = editorElement.style.color
|
||||
return color !== '' ? color : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sendCtrlEnterToTextEditor = (container: Element): void => {
|
||||
const editor = findContentEditableElement(container)
|
||||
if (editor != null) {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
editor.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
it('changing color for text editor', () => {
|
||||
const commandAddedSpy = jest.fn()
|
||||
const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, { cmdAdded: commandAddedSpy })
|
||||
|
||||
{
|
||||
const colorToSet = 'blue'
|
||||
// simulating: user selected text tool
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text' })
|
||||
// simulating: user selected a color
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet })
|
||||
// simulating: user clicked somewhere on the board
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid })
|
||||
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet)
|
||||
}
|
||||
|
||||
// change color to something else
|
||||
{
|
||||
const colorToSet = 'blue'
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid })
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet)
|
||||
}
|
||||
|
||||
const lastSetColor = 'red'
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBe(lastSetColor)
|
||||
|
||||
const setTextContent = 'New Text In The Box'
|
||||
setTextEditorText(drawingPlugInPoint, setTextContent)
|
||||
|
||||
// new tool selection - editor should have been closed
|
||||
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
|
||||
// changing color for the pen
|
||||
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: 'magenta', changingCmdId: undefined })
|
||||
|
||||
setTextEditorText(drawingPlugInPoint, setTextContent + setTextContent)
|
||||
|
||||
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
|
||||
jest.runOnlyPendingTimers()
|
||||
|
||||
// created text command should have correct color and text
|
||||
const storedTextDrawCommand = commandAddedSpy.mock.calls[0][0] as DrawTextCmd | undefined
|
||||
expect(storedTextDrawCommand).toBeDefined()
|
||||
expect(storedTextDrawCommand?.color).toBe(lastSetColor)
|
||||
expect(storedTextDrawCommand?.text).toBe(setTextContent)
|
||||
})
|
||||
|
||||
it('commit text editing by changing tool', () => {
|
||||
const commandChangedSpy = jest.fn()
|
||||
const commandAddedSpy = jest.fn()
|
||||
|
||||
const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, {
|
||||
cmdChanged: commandChangedSpy,
|
||||
cmdAdded: commandAddedSpy
|
||||
})
|
||||
|
||||
// simulating: user selected text tool
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text' })
|
||||
// simulating: user clicked somewhere on the board, starting new text editing
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: EmptyCommandUid })
|
||||
|
||||
// editor should be present in the DOM
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBe(boardState.penColor)
|
||||
|
||||
const setTextContent = 'New Text In The Box'
|
||||
setTextEditorText(drawingPlugInPoint, setTextContent)
|
||||
|
||||
// changing tool
|
||||
drawingBoard.update?.({ ...boardState, tool: 'pen', changingCmdId: EmptyCommandUid })
|
||||
|
||||
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
|
||||
jest.runOnlyPendingTimers()
|
||||
|
||||
// editor should be removed
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBeNull()
|
||||
|
||||
// commandAdded should have been called once
|
||||
expect(commandChangedSpy).toHaveBeenCalledTimes(0)
|
||||
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// new command should have passed to the delegate
|
||||
const newlyCreatedTextDrawCommand = commandAddedSpy.mock.calls[0][0] as DrawingCmd | undefined
|
||||
expect(newlyCreatedTextDrawCommand).toBeDefined()
|
||||
expect(newlyCreatedTextDrawCommand?.type).toBe('text')
|
||||
|
||||
// further actions should not create additional commandChanged / commandAdded calls
|
||||
drawingBoard.update?.({ ...boardState, tool: 'erase', changingCmdId: undefined })
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: undefined })
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: EmptyCommandUid })
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
expect(commandChangedSpy).toHaveBeenCalledTimes(0)
|
||||
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('editing existing text', () => {
|
||||
const commandChangedSpy = jest.fn()
|
||||
const commandAddedSpy = jest.fn()
|
||||
|
||||
const { textCommandUid, textCommand } = createTextCommandStub()
|
||||
|
||||
const { drawingBoard, initialState: boardState } = createDrawingBoard(textCommand, {
|
||||
cmdChanged: commandChangedSpy,
|
||||
cmdAdded: commandAddedSpy
|
||||
})
|
||||
|
||||
// simulating: user selected text tool
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text' })
|
||||
// simulating: user clicked on the existing text
|
||||
drawingBoard.update?.({
|
||||
...boardState,
|
||||
tool: 'text',
|
||||
changingCmdId: textCommandUid,
|
||||
penColor: textCommand.color
|
||||
})
|
||||
|
||||
// editor should be present in the DOM
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBe(textCommand.color)
|
||||
|
||||
const newText = 'New Text'
|
||||
setTextEditorText(drawingPlugInPoint, newText)
|
||||
|
||||
const newColor = 'yellow'
|
||||
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: textCommandUid, penColor: newColor })
|
||||
|
||||
// commit text editing
|
||||
sendCtrlEnterToTextEditor(drawingPlugInPoint)
|
||||
|
||||
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
|
||||
jest.runOnlyPendingTimers()
|
||||
|
||||
// editor should be removed
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
|
||||
expect(getTextEditorColor(drawingPlugInPoint)).toBeNull()
|
||||
|
||||
// commandChangedSpy should have been called once
|
||||
expect(commandChangedSpy).toHaveBeenCalledTimes(1)
|
||||
expect(commandAddedSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
// new command should have passed to the delegate
|
||||
const changedCommand = commandChangedSpy.mock.calls[0][0] as DrawTextCmd | undefined
|
||||
expect(changedCommand).toBeDefined()
|
||||
expect(changedCommand?.type).toBe('text')
|
||||
expect(changedCommand?.text).toBe(newText)
|
||||
expect(changedCommand?.color).toBe(newColor)
|
||||
})
|
||||
|
||||
it('text editor closing with tool change', () => {
|
||||
const { drawingBoard, initialState } = createDrawingBoard(undefined, {})
|
||||
|
||||
const textCmdId = makeCommandUid()
|
||||
drawingBoard.update?.({ ...initialState, tool: 'text' })
|
||||
drawingBoard.update?.({ ...initialState, tool: 'text', changingCmdId: textCmdId })
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
|
||||
|
||||
drawingBoard.update?.({ ...initialState, tool: 'pen', changingCmdId: textCmdId })
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
|
||||
|
||||
drawingBoard.update?.({ ...initialState, tool: 'erase', changingCmdId: undefined })
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvas rescaling via ResizeObserver', () => {
|
||||
const resizeObserverSpyType = jest.fn()
|
||||
let resizeObserverSpy: any
|
||||
|
||||
const DefaultPointerEventOffsetX = 13
|
||||
const DefaultPointerEventOffsetY = 17
|
||||
|
||||
function flushRequestAnimationFrameCallbacks (): void {
|
||||
jest.runOnlyPendingTimers()
|
||||
}
|
||||
|
||||
const makePointerEvent = (type: string, offsetX: number, offsetY: number): PointerEvent => {
|
||||
const event = new PointerEvent(type, { pointerId: 1, button: 0, bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'offsetX', { value: offsetX, writable: false })
|
||||
Object.defineProperty(event, 'offsetY', { value: offsetY, writable: false })
|
||||
return event
|
||||
}
|
||||
|
||||
function expectSingleLineCommand (
|
||||
commandAddedSpy: jest.Mock,
|
||||
expectedPoints: Array<{ x: number, y: number }>
|
||||
): void {
|
||||
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
|
||||
const actualAddedCommand = commandAddedSpy.mock.calls[0][0]
|
||||
expect(actualAddedCommand).toBeDefined()
|
||||
expect(actualAddedCommand.type).toBe('line')
|
||||
expect(actualAddedCommand.points).toBeDefined()
|
||||
expect(actualAddedCommand.points.length).toBe(expectedPoints.length)
|
||||
expectedPoints.forEach((expectedPoint, index) => {
|
||||
expect(actualAddedCommand.points[index]).toStrictEqual(expectedPoint)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resizeObserverSpy = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn()
|
||||
}
|
||||
resizeObserverSpyType.mockImplementation(() => resizeObserverSpy)
|
||||
;(globalThis as any).ResizeObserver = resizeObserverSpyType
|
||||
|
||||
if (typeof PointerEvent === 'undefined') {
|
||||
;(globalThis as any).PointerEvent = class MockPointerEvent extends Event {
|
||||
pointerId: number
|
||||
button: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
|
||||
constructor (type: string, options: any = {}) {
|
||||
super(type, options)
|
||||
this.pointerId = options.pointerId ?? 1
|
||||
this.button = options.button ?? 0
|
||||
this.offsetX = DefaultPointerEventOffsetX
|
||||
this.offsetY = DefaultPointerEventOffsetY
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('basic pointer move', () => {
|
||||
const pointerMovedSpy = jest.fn()
|
||||
|
||||
drawing(drawingPlugInPoint, {
|
||||
readonly: false,
|
||||
imageWidth: 400,
|
||||
imageHeight: 300,
|
||||
commands: [],
|
||||
tool: 'pen',
|
||||
pointerMoved: pointerMovedSpy
|
||||
})
|
||||
|
||||
const canvas = drawingPlugInPoint.querySelector('canvas') as HTMLCanvasElement
|
||||
canvas.setPointerCapture = jest.fn()
|
||||
canvas.releasePointerCapture = jest.fn()
|
||||
expect(canvas.onpointermove).toBeDefined()
|
||||
expect(typeof canvas.onpointermove).toBe('function')
|
||||
|
||||
const expectedPointerX = 50
|
||||
const expectedPointerY = 40
|
||||
const dummyPointerMoveEvent = makePointerEvent('pointermove', expectedPointerX, expectedPointerY)
|
||||
|
||||
canvas.onpointermove?.(dummyPointerMoveEvent)
|
||||
|
||||
expect(pointerMovedSpy).toHaveBeenCalledTimes(1)
|
||||
const actualPosition = pointerMovedSpy.mock.calls[0][0]
|
||||
expect(actualPosition).toStrictEqual({ x: expectedPointerX, y: expectedPointerY })
|
||||
})
|
||||
|
||||
it('canvas resize followed by pen drawing', () => {
|
||||
const commandAddedSpy = jest.fn()
|
||||
const pointerMovedSpy = jest.fn()
|
||||
|
||||
const backgroundImageWidth = 400
|
||||
const backgroundImageHeight = 300
|
||||
drawing(drawingPlugInPoint, {
|
||||
readonly: false,
|
||||
autoSize: false,
|
||||
imageWidth: backgroundImageWidth,
|
||||
imageHeight: backgroundImageHeight,
|
||||
commands: [],
|
||||
tool: 'pen',
|
||||
penColor: 'red',
|
||||
cmdAdded: commandAddedSpy,
|
||||
pointerMoved: pointerMovedSpy
|
||||
})
|
||||
|
||||
// ResizeObserver was created and canvas is being observed
|
||||
expect(resizeObserverSpyType).toHaveBeenCalledTimes(1)
|
||||
const actualResizeObserverCallback = resizeObserverSpyType.mock.calls[0][0]
|
||||
const canvas = drawingPlugInPoint.querySelector('canvas') as HTMLCanvasElement
|
||||
canvas.setPointerCapture = jest.fn()
|
||||
canvas.releasePointerCapture = jest.fn()
|
||||
expect(resizeObserverSpy.observe).toHaveBeenCalledTimes(1)
|
||||
expect(resizeObserverSpy.observe.mock.calls[0][0]).toBe(canvas)
|
||||
|
||||
// trigger the resize observer callback
|
||||
actualResizeObserverCallback([
|
||||
{
|
||||
target: canvas,
|
||||
contentRect: { width: 100, height: 150 }
|
||||
}
|
||||
])
|
||||
|
||||
// verify that CSS transform has been applied (should scale to fit aspect ratio)
|
||||
expect(canvas.style.transform).toContain('scale(1, 0.5)')
|
||||
|
||||
jest.clearAllMocks()
|
||||
|
||||
const drawStartPoint = { x: 50, y: 40 }
|
||||
const drawContinuePoint = { x: drawStartPoint.x + 25, y: drawStartPoint.y + 25 }
|
||||
const drawEndPoint = { x: drawContinuePoint.x + 25, y: drawContinuePoint.y + 25 }
|
||||
|
||||
// simulate pointerdown (drawStart) by calling the handler directly
|
||||
canvas.onpointerdown?.(makePointerEvent('pointerdown', drawStartPoint.x, drawStartPoint.y))
|
||||
|
||||
// simulate pointermove (drawContinue) - this should trigger pointerMoved callback
|
||||
canvas.onpointermove?.(makePointerEvent('pointermove', drawContinuePoint.x, drawContinuePoint.y))
|
||||
|
||||
flushRequestAnimationFrameCallbacks()
|
||||
|
||||
expect(pointerMovedSpy).toHaveBeenCalled()
|
||||
const actualFirstPointerPosition = pointerMovedSpy.mock.calls[0][0]
|
||||
expect(actualFirstPointerPosition).toStrictEqual({ x: 300, y: 130 })
|
||||
|
||||
// simulate another pointermove to create a line with sufficient distance for drawing
|
||||
canvas.onpointermove?.(makePointerEvent('pointermove', drawEndPoint.x, drawEndPoint.y))
|
||||
|
||||
flushRequestAnimationFrameCallbacks()
|
||||
|
||||
// simulate pointerup (drawEnd)
|
||||
canvas.onpointerup?.(makePointerEvent('pointerup', drawEndPoint.x, drawEndPoint.y))
|
||||
|
||||
flushRequestAnimationFrameCallbacks()
|
||||
|
||||
expectSingleLineCommand(commandAddedSpy, [
|
||||
{ x: 200, y: 80 },
|
||||
{ x: 300, y: 130 },
|
||||
{ x: 400, y: 180 }
|
||||
])
|
||||
|
||||
// test that subsequent resize events properly update the transform
|
||||
|
||||
jest.clearAllMocks()
|
||||
|
||||
// trigger another resize
|
||||
actualResizeObserverCallback([{ target: canvas, contentRect: { width: 300, height: 600 } }])
|
||||
|
||||
expect(canvas.style.transform).toContain('scale(1, 0.375)')
|
||||
|
||||
// another drawing operation
|
||||
canvas.onpointerdown?.(makePointerEvent('pointerdown', 80, 80))
|
||||
canvas.onpointermove?.(makePointerEvent('pointermove', 110, 110))
|
||||
flushRequestAnimationFrameCallbacks()
|
||||
canvas.onpointerup?.(makePointerEvent('pointerup', 110, 110))
|
||||
flushRequestAnimationFrameCallbacks()
|
||||
|
||||
expect(pointerMovedSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
expectSingleLineCommand(commandAddedSpy, [
|
||||
{ x: 107, y: 40 },
|
||||
{ x: 147, y: 55 }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,478 @@
|
||||
//
|
||||
// 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 { DrawingCommandsProcessor, UndoRedoAvailability } from '../drawingCommandsProcessor'
|
||||
import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawing'
|
||||
import { type Array as YArray, Doc as YDoc } from 'yjs'
|
||||
import { makeCanvasPoint } from '../drawingUtils'
|
||||
|
||||
const makeTextCommand = (overrides: Partial<DrawTextCmd> = {}): DrawTextCmd => ({
|
||||
id: makeCommandUid(),
|
||||
type: 'text',
|
||||
text: 'Default Text',
|
||||
pos: makeCanvasPoint(13, 17),
|
||||
fontSize: 11,
|
||||
fontFace: 'Arial',
|
||||
color: 'red',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const makeLineCommand = (overrides: Partial<DrawLineCmd> = {}): DrawLineCmd => ({
|
||||
id: makeCommandUid(),
|
||||
type: 'line',
|
||||
lineWidth: 3,
|
||||
erasing: false,
|
||||
penColor: 'blue',
|
||||
points: [makeCanvasPoint(1, 3), makeCanvasPoint(11, 13)],
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('DrawingCommandsProcessor Tests', () => {
|
||||
let document: YDoc
|
||||
let commands: YArray<DrawingCmd>
|
||||
let systemUnderTest: DrawingCommandsProcessor
|
||||
|
||||
beforeEach(() => {
|
||||
document = new YDoc()
|
||||
commands = document.getArray<DrawingCmd>('test-commands')
|
||||
systemUnderTest = new DrawingCommandsProcessor(document, commands)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.destroy()
|
||||
})
|
||||
|
||||
describe('UndoRedoAvailability', () => {
|
||||
it.each([
|
||||
{ undoDisabled: true, redoDisabled: false },
|
||||
{ undoDisabled: true, redoDisabled: true },
|
||||
{ undoDisabled: false, redoDisabled: false },
|
||||
{ undoDisabled: false, redoDisabled: true }
|
||||
])(
|
||||
'construction test with undoDisabled = $undoDisabled, redoDisabled = $redoDisabled',
|
||||
({ undoDisabled, redoDisabled }) => {
|
||||
const availability = new UndoRedoAvailability(undoDisabled, redoDisabled)
|
||||
expect(availability.undoDisabled).toBe(undoDisabled)
|
||||
expect(availability.redoDisabled).toBe(redoDisabled)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('after construction', () => {
|
||||
it('snapshot', () => {
|
||||
expect(systemUnderTest.snapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('undo/redo availability', () => {
|
||||
const availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(true)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('after set', () => {
|
||||
it('undo/redo availability', () => {
|
||||
const testCommands: DrawingCmd[] = [makeTextCommand(), makeLineCommand()]
|
||||
|
||||
systemUnderTest.set(testCommands)
|
||||
|
||||
const availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(true)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
})
|
||||
|
||||
it('snapshot', () => {
|
||||
const initialCommands: DrawingCmd[] = [makeTextCommand()]
|
||||
const newCommands: DrawingCmd[] = [makeLineCommand()]
|
||||
|
||||
systemUnderTest.set(initialCommands)
|
||||
systemUnderTest.set(newCommands)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual(newCommands)
|
||||
expect(systemUnderTest.snapshot()).not.toEqual(initialCommands)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addCommand', () => {
|
||||
it('single invocation', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toContain(command)
|
||||
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('multiple invocations', () => {
|
||||
const first = makeTextCommand()
|
||||
const second = makeLineCommand()
|
||||
|
||||
systemUnderTest.addCommand(first)
|
||||
systemUnderTest.addCommand(second)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([first, second])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteCommand', () => {
|
||||
it('existing command deletion', () => {
|
||||
const toBeDeleted = makeTextCommand()
|
||||
const toBeKept = makeLineCommand()
|
||||
|
||||
systemUnderTest.addCommand(toBeDeleted)
|
||||
systemUnderTest.addCommand(toBeKept)
|
||||
systemUnderTest.deleteCommand(toBeDeleted.id)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([toBeKept])
|
||||
})
|
||||
|
||||
it('non-existent command deletion', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
|
||||
const nonExistentId = makeCommandUid()
|
||||
systemUnderTest.deleteCommand(nonExistentId)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([command])
|
||||
})
|
||||
|
||||
it('undo/redo availability', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
systemUnderTest.deleteCommand(command.id)
|
||||
|
||||
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('changeCommand', () => {
|
||||
it('existing command change', () => {
|
||||
const originalCommand = makeTextCommand({
|
||||
text: 'Original',
|
||||
color: 'red'
|
||||
})
|
||||
|
||||
const changedCommand: DrawTextCmd = {
|
||||
...originalCommand,
|
||||
text: 'Changed',
|
||||
color: 'blue'
|
||||
}
|
||||
|
||||
systemUnderTest.addCommand(originalCommand)
|
||||
systemUnderTest.changeCommand(changedCommand)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([changedCommand])
|
||||
})
|
||||
|
||||
it('absent command change', () => {
|
||||
const newCommand = makeTextCommand()
|
||||
|
||||
systemUnderTest.changeCommand(newCommand)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([newCommand])
|
||||
})
|
||||
|
||||
it('order preservation', () => {
|
||||
const first = makeTextCommand({
|
||||
text: 'First',
|
||||
color: 'red'
|
||||
})
|
||||
|
||||
const second = makeTextCommand({
|
||||
text: 'Second',
|
||||
pos: makeCanvasPoint(20, 20),
|
||||
color: 'blue'
|
||||
})
|
||||
|
||||
const third = makeTextCommand({
|
||||
text: 'Third',
|
||||
pos: makeCanvasPoint(30, 30),
|
||||
color: 'green'
|
||||
})
|
||||
|
||||
systemUnderTest.addCommand(first)
|
||||
systemUnderTest.addCommand(second)
|
||||
systemUnderTest.addCommand(third)
|
||||
|
||||
const changedSecond: DrawTextCmd = {
|
||||
...second,
|
||||
text: 'Second Changed'
|
||||
}
|
||||
|
||||
systemUnderTest.changeCommand(changedSecond)
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([first, changedSecond, third])
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('addCommand undo', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
systemUnderTest.undo()
|
||||
|
||||
expect(systemUnderTest.snapshot().length).toBe(0)
|
||||
})
|
||||
|
||||
it('addCommand undo, than redo', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
systemUnderTest.undo()
|
||||
systemUnderTest.redo()
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([command])
|
||||
})
|
||||
|
||||
it('multiple undo/redo', () => {
|
||||
const first = makeTextCommand()
|
||||
const second = makeLineCommand()
|
||||
|
||||
systemUnderTest.addCommand(first)
|
||||
systemUnderTest.addCommand(second)
|
||||
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([first])
|
||||
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([])
|
||||
|
||||
systemUnderTest.redo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([first])
|
||||
|
||||
systemUnderTest.redo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([first, second])
|
||||
})
|
||||
|
||||
it('undo/redo availability reporting', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
let availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(true)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(false)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
|
||||
systemUnderTest.undo()
|
||||
availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(true)
|
||||
expect(availability.redoDisabled).toBe(false)
|
||||
|
||||
systemUnderTest.redo()
|
||||
availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(false)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('execution', () => {
|
||||
systemUnderTest.addCommand(makeTextCommand())
|
||||
systemUnderTest.addCommand(makeLineCommand())
|
||||
systemUnderTest.clear()
|
||||
|
||||
expect(systemUnderTest.snapshot().length).toBe(0)
|
||||
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
|
||||
})
|
||||
|
||||
it('undo', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
systemUnderTest.clear()
|
||||
systemUnderTest.undo()
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([command])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureAllCommandsWithUids', () => {
|
||||
it('absent uids addition', () => {
|
||||
const commandWithoutUid = {
|
||||
type: 'text',
|
||||
text: 'No UID',
|
||||
pos: { x: 10, y: 10 },
|
||||
fontSize: 12,
|
||||
fontFace: 'Arial',
|
||||
color: 'red'
|
||||
} as any
|
||||
commands.push([commandWithoutUid])
|
||||
|
||||
systemUnderTest.ensureAllCommandsWithUids()
|
||||
|
||||
const snapshot = systemUnderTest.snapshot()
|
||||
expect(snapshot.length).toBe(1)
|
||||
expect(snapshot[0].id).toBeDefined()
|
||||
expect(typeof snapshot[0].id).toBe('string')
|
||||
})
|
||||
|
||||
it('should preserve existing UIDs', () => {
|
||||
const existingUid = makeCommandUid()
|
||||
const commandWithUid = makeTextCommand({
|
||||
id: existingUid
|
||||
})
|
||||
commands.push([commandWithUid])
|
||||
|
||||
systemUnderTest.ensureAllCommandsWithUids()
|
||||
|
||||
expect(systemUnderTest.snapshot()).toEqual([commandWithUid])
|
||||
})
|
||||
|
||||
it('should handle mixed commands with and without UIDs', () => {
|
||||
const existingUid = makeCommandUid()
|
||||
const commandWithUid = makeTextCommand({
|
||||
id: existingUid
|
||||
})
|
||||
|
||||
const commandWithoutUid = {
|
||||
type: 'line',
|
||||
lineWidth: 2,
|
||||
erasing: false,
|
||||
penColor: 'blue',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 20, y: 20 }
|
||||
]
|
||||
} as any
|
||||
|
||||
commands.push([commandWithUid, commandWithoutUid])
|
||||
|
||||
systemUnderTest.ensureAllCommandsWithUids()
|
||||
|
||||
const snapshot = systemUnderTest.snapshot()
|
||||
expect(snapshot.length).toBe(2)
|
||||
expect(snapshot[0].id).toBe(existingUid)
|
||||
expect(snapshot[1].id).toBeDefined()
|
||||
expect(typeof snapshot[1].id).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapshot', () => {
|
||||
it('consecutive executions', () => {
|
||||
const command = makeTextCommand()
|
||||
|
||||
systemUnderTest.addCommand(command)
|
||||
|
||||
const firstSnapshot = systemUnderTest.snapshot()
|
||||
const secondSnapshot = systemUnderTest.snapshot()
|
||||
|
||||
expect(firstSnapshot).toEqual(secondSnapshot)
|
||||
expect(firstSnapshot).not.toBe(secondSnapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration tests', () => {
|
||||
it('complex workflow with all operations', () => {
|
||||
const textCommand = makeTextCommand()
|
||||
const lineCommand = makeLineCommand()
|
||||
|
||||
systemUnderTest.addCommand(textCommand)
|
||||
systemUnderTest.addCommand(lineCommand)
|
||||
expect(systemUnderTest.snapshot().length).toBe(2)
|
||||
|
||||
const changedTextCommand: DrawTextCmd = {
|
||||
...textCommand,
|
||||
text: 'Changed Text',
|
||||
color: 'green'
|
||||
}
|
||||
systemUnderTest.changeCommand(changedTextCommand)
|
||||
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand])
|
||||
|
||||
systemUnderTest.deleteCommand(lineCommand.id)
|
||||
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand])
|
||||
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand])
|
||||
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([textCommand, lineCommand])
|
||||
|
||||
systemUnderTest.clear()
|
||||
expect(systemUnderTest.snapshot().length).toBe(0)
|
||||
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot()).toEqual([textCommand, lineCommand])
|
||||
})
|
||||
|
||||
it('rapid command additions and deletions', () => {
|
||||
const commands: DrawTextCmd[] = []
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const cmd = makeTextCommand({
|
||||
text: `Command ${i}`,
|
||||
pos: makeCanvasPoint(i * 10, i * 10),
|
||||
color: 'black'
|
||||
})
|
||||
commands.push(cmd)
|
||||
systemUnderTest.addCommand(cmd)
|
||||
}
|
||||
|
||||
expect(systemUnderTest.snapshot().length).toBe(10)
|
||||
|
||||
for (let i = 0; i < commands.length; i += 2) {
|
||||
systemUnderTest.deleteCommand(commands[i].id)
|
||||
}
|
||||
|
||||
expect(systemUnderTest.snapshot().length).toBe(5)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
systemUnderTest.undo()
|
||||
}
|
||||
|
||||
expect(systemUnderTest.snapshot().length).toBe(10)
|
||||
})
|
||||
|
||||
it('multiple undo/redo cycles', () => {
|
||||
systemUnderTest.addCommand(makeTextCommand())
|
||||
systemUnderTest.addCommand(makeLineCommand())
|
||||
|
||||
const initialSnapshot = systemUnderTest.snapshot()
|
||||
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
systemUnderTest.undo()
|
||||
systemUnderTest.undo()
|
||||
systemUnderTest.undo()
|
||||
expect(systemUnderTest.snapshot().length).toBe(0)
|
||||
|
||||
systemUnderTest.redo()
|
||||
systemUnderTest.redo()
|
||||
systemUnderTest.redo()
|
||||
expect(systemUnderTest.snapshot()).toEqual(initialSnapshot)
|
||||
}
|
||||
})
|
||||
|
||||
it('set after undo/redo', () => {
|
||||
systemUnderTest.addCommand(makeTextCommand())
|
||||
systemUnderTest.undo()
|
||||
|
||||
expect(systemUnderTest.getUndoRedoAvailability().redoDisabled).toBe(false)
|
||||
|
||||
const newCommand = makeLineCommand()
|
||||
|
||||
systemUnderTest.set([newCommand])
|
||||
|
||||
const availability = systemUnderTest.getUndoRedoAvailability()
|
||||
expect(availability.undoDisabled).toBe(true)
|
||||
expect(availability.redoDisabled).toBe(true)
|
||||
expect(systemUnderTest.snapshot()).toEqual([newCommand])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
//
|
||||
// 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 { rescaleToFitAspectRatio, scalePoint, offsetPoint, offsetInParent, type Point } from '../drawingUtils'
|
||||
|
||||
describe('drawingUtils module tests', () => {
|
||||
describe('scalePoint', () => {
|
||||
it('execution', () => {
|
||||
const point = { x: 10, y: 30 }
|
||||
const scale = { x: 5, y: 7 }
|
||||
const scaled = scalePoint(point, scale)
|
||||
expect(scaled).toEqual({ x: 50, y: 210 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rescaleToFitAspectRatio', () => {
|
||||
interface ReferenceAndActual {
|
||||
referenceWidth: number
|
||||
referenceHeight: number
|
||||
actualWidth: number
|
||||
actualHeight: number
|
||||
}
|
||||
|
||||
interface TestCase {
|
||||
description: string
|
||||
data: ReferenceAndActual
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
description: 'horizontal reference, horizontal target, less width, greater height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 700, actualHeight: 650 }
|
||||
},
|
||||
{
|
||||
description: 'horizontal reference, horizontal target, greater width, greater height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 650 }
|
||||
},
|
||||
{
|
||||
description: 'horizontal reference, horizontal target, less width, less height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 700, actualHeight: 500 }
|
||||
},
|
||||
{
|
||||
description: 'horizontal reference, horizontal target, greater width, less height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 500 }
|
||||
},
|
||||
|
||||
{
|
||||
description: 'horizontal reference, vertical target, less height, greater width',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 90, actualHeight: 7000 }
|
||||
},
|
||||
{
|
||||
description: 'horizontal reference, vertical target, greater width, greater height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 7000 }
|
||||
},
|
||||
{
|
||||
description: 'horizontal reference, vertical target, less width, less height',
|
||||
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 90, actualHeight: 170 }
|
||||
},
|
||||
|
||||
{
|
||||
description: 'vertical reference, horizontal target, greater width, greater height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 903, actualHeight: 803 }
|
||||
},
|
||||
{
|
||||
description: 'vertical reference, horizontal target, less width, less height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 100 }
|
||||
},
|
||||
{
|
||||
description: 'vertical reference, horizontal target, greater width, less height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 900, actualHeight: 500 }
|
||||
},
|
||||
|
||||
{
|
||||
description: 'vertical reference, vertical target, less width, greater height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 900 }
|
||||
},
|
||||
{
|
||||
description: 'vertical reference, vertical target, greater width, greater height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 700, actualHeight: 900 }
|
||||
},
|
||||
{
|
||||
description: 'vertical reference, vertical target, less width, less height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 700 }
|
||||
},
|
||||
{
|
||||
description: 'vertical reference, vertical target, greater width, less height',
|
||||
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 650, actualHeight: 700 }
|
||||
}
|
||||
]
|
||||
|
||||
it.each(testCases)('$description', ({ data: { referenceWidth, referenceHeight, actualWidth, actualHeight } }) => {
|
||||
const scale = rescaleToFitAspectRatio(referenceWidth, referenceHeight, actualWidth, actualHeight)
|
||||
|
||||
const scaledWidth = actualWidth * scale.x
|
||||
const scaledHeight = actualHeight * scale.y
|
||||
|
||||
const resultAspectRatio = scaledWidth / scaledHeight
|
||||
const desiredAspectRatio = referenceWidth / referenceHeight
|
||||
|
||||
expect(resultAspectRatio).toBeCloseTo(desiredAspectRatio, 6)
|
||||
expect(scale.x === 1 || scale.y === 1).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetPoint', () => {
|
||||
interface TestCase {
|
||||
description: string
|
||||
victim: Point
|
||||
offset: Point
|
||||
expected: Point
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
description: 'positive offset',
|
||||
victim: { x: 10, y: 20 },
|
||||
offset: { x: 5, y: 15 },
|
||||
expected: { x: 15, y: 35 }
|
||||
},
|
||||
{
|
||||
description: 'negative offset',
|
||||
victim: { x: 10, y: 20 },
|
||||
offset: { x: -3, y: -8 },
|
||||
expected: { x: 7, y: 12 }
|
||||
},
|
||||
{
|
||||
description: 'zero offset',
|
||||
victim: { x: 10, y: 20 },
|
||||
offset: { x: 0, y: 0 },
|
||||
expected: { x: 10, y: 20 }
|
||||
},
|
||||
{
|
||||
description: 'mixed positive and negative offset',
|
||||
victim: { x: 10, y: 20 },
|
||||
offset: { x: -5, y: 10 },
|
||||
expected: { x: 5, y: 30 }
|
||||
}
|
||||
]
|
||||
|
||||
it.each(testCases)('$description', ({ victim, offset, expected }) => {
|
||||
const result = offsetPoint(victim, offset)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetInParent', () => {
|
||||
interface TestCase {
|
||||
description: string
|
||||
parentSize: { width: number, height: number }
|
||||
childSize: { width: number, height: number }
|
||||
expected: Point
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
description: 'child smaller than parent',
|
||||
parentSize: { width: 200, height: 100 },
|
||||
childSize: { width: 100, height: 50 },
|
||||
expected: { x: 50, y: 25 }
|
||||
},
|
||||
{
|
||||
description: 'child same size as parent',
|
||||
parentSize: { width: 100, height: 100 },
|
||||
childSize: { width: 100, height: 100 },
|
||||
expected: { x: 0, y: 0 }
|
||||
},
|
||||
{
|
||||
description: 'child larger than parent',
|
||||
parentSize: { width: 100, height: 50 },
|
||||
childSize: { width: 200, height: 100 },
|
||||
expected: { x: -50, y: -25 }
|
||||
},
|
||||
{
|
||||
description: 'asymmetric sizes',
|
||||
parentSize: { width: 300, height: 80 },
|
||||
childSize: { width: 100, height: 40 },
|
||||
expected: { x: 100, y: 20 }
|
||||
}
|
||||
]
|
||||
|
||||
it.each(testCases)('$description', ({ parentSize, childSize, expected }) => {
|
||||
// Create mock HTML elements
|
||||
const mockParent = {
|
||||
getBoundingClientRect: jest.fn().mockReturnValue({
|
||||
width: parentSize.width,
|
||||
height: parentSize.height
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
|
||||
const mockChild = {
|
||||
getBoundingClientRect: jest.fn().mockReturnValue({
|
||||
width: childSize.width,
|
||||
height: childSize.height
|
||||
})
|
||||
} as unknown as HTMLElement
|
||||
|
||||
const result = offsetInParent(mockParent, mockChild)
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,9 +19,11 @@
|
||||
import { getFileUrl } from '../file'
|
||||
import Download from './icons/Download.svelte'
|
||||
import presentation from '../plugin'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
|
||||
export let file: Ref<Blob> | undefined
|
||||
export let name: string
|
||||
export let tooltip: IntlString | undefined = presentation.string.Download
|
||||
|
||||
let download: HTMLAnchorElement
|
||||
$: srcRef = file !== undefined ? getFileUrl(file, name) : undefined
|
||||
@@ -36,7 +38,7 @@
|
||||
on:click={() => {
|
||||
download.click()
|
||||
}}
|
||||
showTooltip={{ label: presentation.string.Download }}
|
||||
showTooltip={{ label: tooltip ?? presentation.string.Download }}
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
<script lang="ts">
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { resizeObserver } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import {
|
||||
CommandUid,
|
||||
drawing,
|
||||
makeCommandId,
|
||||
type DrawingCmd,
|
||||
type DrawingData,
|
||||
type DrawingTool,
|
||||
type DrawTextCmd
|
||||
} from '../drawing'
|
||||
import DrawingBoardToolbar from './DrawingBoardToolbar.svelte'
|
||||
import { DrawingCommandsProcessor } from '../drawingCommandsProcessor'
|
||||
import { Doc as YDoc } from 'yjs'
|
||||
|
||||
export let active = false
|
||||
export let readonly = true
|
||||
@@ -38,15 +40,30 @@
|
||||
let penWidth: number
|
||||
let eraserWidth: number
|
||||
let fontSize: number
|
||||
let commands: DrawingCmd[] | undefined
|
||||
let model: DrawingCmd[] | undefined
|
||||
let modified = false
|
||||
let board: HTMLDivElement
|
||||
let toolbar: HTMLDivElement
|
||||
let toolbarInside = false
|
||||
let oldReadonly: boolean
|
||||
let oldDrawings: DrawingData[]
|
||||
let modified = false
|
||||
let changingCmdId: string | undefined
|
||||
let currentReadonly: boolean
|
||||
let currentDrawings: DrawingData[]
|
||||
let changingCmdId: CommandUid | undefined
|
||||
let cmdEditor: HTMLDivElement | undefined
|
||||
let disableUndo: boolean = false
|
||||
let disableRedo: boolean = false
|
||||
|
||||
const document: YDoc = new YDoc()
|
||||
const undoableCommands = document.getArray<DrawingCmd>('drawing-commands')
|
||||
const commandProcessor = new DrawingCommandsProcessor(document, undoableCommands)
|
||||
|
||||
function onSavedCommandsChanged (): void {
|
||||
model = commandProcessor.snapshot()
|
||||
setTimeout(() => {
|
||||
const status = commandProcessor.getUndoRedoAvailability()
|
||||
disableUndo = currentReadonly || status.undoDisabled
|
||||
disableRedo = currentReadonly || status.redoDisabled
|
||||
})
|
||||
}
|
||||
|
||||
$: updateToolbarPosition(readonly, board, toolbar)
|
||||
$: updateEditableState(drawings, readonly)
|
||||
@@ -59,50 +76,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
function dropTextEditor (): void {
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
}
|
||||
|
||||
function handleModification (): void {
|
||||
modified = true
|
||||
dropTextEditor()
|
||||
}
|
||||
|
||||
function updateEditableState (drawings: DrawingData[], readonly: boolean): void {
|
||||
if (readonly !== oldReadonly || drawings !== oldDrawings) {
|
||||
if (drawings !== undefined) {
|
||||
if (readonly) {
|
||||
saveDrawing()
|
||||
parseDrawing(drawings[0])
|
||||
modified = false
|
||||
} else {
|
||||
if (commands === undefined) {
|
||||
commands = []
|
||||
} else {
|
||||
// Edit current content as a new drawing
|
||||
commands = commands.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() }))
|
||||
}
|
||||
modified = false
|
||||
}
|
||||
} else {
|
||||
commands = undefined
|
||||
}
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
oldDrawings = drawings
|
||||
oldReadonly = readonly
|
||||
const readOnlyStatusChanged = readonly !== currentReadonly
|
||||
const drawingsChanged = drawings !== currentDrawings
|
||||
|
||||
if (!readOnlyStatusChanged && !drawingsChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
if (drawings !== undefined) {
|
||||
if (readonly) {
|
||||
saveDrawing()
|
||||
parseDrawing(drawings[0])
|
||||
modified = false
|
||||
} else {
|
||||
if (model === undefined) {
|
||||
commandProcessor.set([])
|
||||
onSavedCommandsChanged()
|
||||
} else {
|
||||
commandProcessor.ensureAllCommandsWithUids()
|
||||
}
|
||||
modified = false
|
||||
}
|
||||
} else {
|
||||
model = undefined
|
||||
}
|
||||
dropTextEditor()
|
||||
currentDrawings = drawings
|
||||
currentReadonly = readonly
|
||||
}
|
||||
|
||||
function parseDrawing (data: DrawingData | undefined): void {
|
||||
if (data?.content !== undefined && data?.content !== null) {
|
||||
try {
|
||||
commands = JSON.parse(data.content)
|
||||
const commands: DrawingCmd[] = JSON.parse(data.content)
|
||||
commandProcessor.set(commands)
|
||||
} catch (error: any) {
|
||||
commands = []
|
||||
commandProcessor.set([])
|
||||
Analytics.handleError(error)
|
||||
console.error('Failed to parse drawing content', error)
|
||||
}
|
||||
} else {
|
||||
commands = []
|
||||
commandProcessor.set([])
|
||||
}
|
||||
onSavedCommandsChanged()
|
||||
}
|
||||
|
||||
function saveDrawing (): void {
|
||||
if (modified && commands !== undefined) {
|
||||
if (modified && model !== undefined) {
|
||||
const data: DrawingData = {
|
||||
content: JSON.stringify(commands)
|
||||
content: JSON.stringify(model)
|
||||
}
|
||||
createDrawing(data).catch((error) => {
|
||||
Analytics.handleError(error)
|
||||
@@ -111,23 +144,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function addCommand (cmd: DrawingCmd): void {
|
||||
if (commands !== undefined) {
|
||||
commands = [...commands, cmd]
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
modified = true
|
||||
function addCommand (command: DrawingCmd): void {
|
||||
if (model !== undefined) {
|
||||
commandProcessor.addCommand(command)
|
||||
handleModification()
|
||||
}
|
||||
}
|
||||
|
||||
function showCommandProps (id: string): void {
|
||||
function showCommandProps (id: CommandUid): void {
|
||||
changingCmdId = id
|
||||
for (const cmd of commands ?? []) {
|
||||
if (cmd.id === id) {
|
||||
if (cmd.type === 'text') {
|
||||
const textCmd = cmd as DrawTextCmd
|
||||
penColor = textCmd.color
|
||||
fontSize = textCmd.fontSize
|
||||
for (const command of model ?? []) {
|
||||
if (command.id === id) {
|
||||
if (command.type === 'text') {
|
||||
const textCommand = command as DrawTextCmd
|
||||
penColor = textCommand.color
|
||||
fontSize = textCommand.fontSize
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -135,29 +166,31 @@
|
||||
}
|
||||
|
||||
function changeCommand (cmd: DrawingCmd): void {
|
||||
if (commands !== undefined) {
|
||||
commands = commands.map((c) => (c.id === cmd.id ? cmd : c))
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
modified = true
|
||||
if (model !== undefined) {
|
||||
commandProcessor.changeCommand(cmd)
|
||||
handleModification()
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCommand (id: string): void {
|
||||
if (commands !== undefined) {
|
||||
commands = commands.filter((c) => c.id !== id)
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
modified = true
|
||||
function deleteCommand (id: CommandUid): void {
|
||||
if (model !== undefined) {
|
||||
commandProcessor.deleteCommand(id)
|
||||
handleModification()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onSavedCommandsChanged()
|
||||
undoableCommands.observe(onSavedCommandsChanged)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
undoableCommands.unobserve(onSavedCommandsChanged)
|
||||
saveDrawing()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if active && commands !== undefined}
|
||||
{#if active && model !== undefined}
|
||||
<div
|
||||
{...$$restProps}
|
||||
style:position="relative"
|
||||
@@ -170,7 +203,7 @@
|
||||
readonly,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
commands,
|
||||
commands: model,
|
||||
tool,
|
||||
penColor,
|
||||
penWidth,
|
||||
@@ -199,8 +232,18 @@
|
||||
bind:penWidth
|
||||
bind:eraserWidth
|
||||
bind:fontSize
|
||||
bind:disableUndo
|
||||
bind:disableRedo
|
||||
on:clear={() => {
|
||||
commands = []
|
||||
commandProcessor.clear()
|
||||
modified = true
|
||||
}}
|
||||
on:undo={() => {
|
||||
commandProcessor.undo()
|
||||
modified = true
|
||||
}}
|
||||
on:redo={() => {
|
||||
commandProcessor.redo()
|
||||
modified = true
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,19 +20,27 @@
|
||||
IconEdit,
|
||||
IconMoreH,
|
||||
IconRedo,
|
||||
IconUndo,
|
||||
SelectPopup,
|
||||
SelectPopupValueType,
|
||||
eventToHTMLElement,
|
||||
showPopup
|
||||
} from '@hcengineering/ui'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
interface DrawingBoardToolbarEvents {
|
||||
undo: undefined
|
||||
redo: undefined
|
||||
clear: undefined
|
||||
}
|
||||
|
||||
import IconEraser from './icons/Eraser.svelte'
|
||||
import IconMove from './icons/Move.svelte'
|
||||
import IconText from './icons/Text.svelte'
|
||||
import { DrawingTool } from '../drawing'
|
||||
import presentation from '../plugin'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatch = createEventDispatcher<DrawingBoardToolbarEvents>()
|
||||
const maxColors = 8
|
||||
const minColors = 0
|
||||
const defaultColor = '#0000ff'
|
||||
@@ -54,20 +62,24 @@
|
||||
export let showPanTool = false
|
||||
export let toolbar: HTMLDivElement | undefined
|
||||
export let cmdEditor: HTMLDivElement | undefined
|
||||
export let disableUndo: boolean = false
|
||||
export let disableRedo: boolean = false
|
||||
|
||||
let colorSelector: HTMLInputElement
|
||||
let penColors: string[] = defaultColors
|
||||
let colorsPalette: string[] = defaultColors
|
||||
|
||||
function showMenu (ev: MouseEvent): void {
|
||||
const items: SelectPopupValueType[] = []
|
||||
if (penColors.length < maxColors) {
|
||||
type PaletteCommandId = 'add-color' | 'remove-color' | 'reset-colors'
|
||||
|
||||
function showPaletteManagementMenu (ev: MouseEvent): void {
|
||||
const items: Array<Omit<SelectPopupValueType, 'id'> & { id: PaletteCommandId }> = []
|
||||
if (colorsPalette.length < maxColors) {
|
||||
items.push({
|
||||
id: 'add-color',
|
||||
label: presentation.string.ColorAdd,
|
||||
icon: IconAdd
|
||||
})
|
||||
}
|
||||
if (penColors.length > minColors) {
|
||||
if (colorsPalette.length > minColors) {
|
||||
items.push({
|
||||
id: 'remove-color',
|
||||
label: presentation.string.ColorRemove,
|
||||
@@ -79,7 +91,7 @@
|
||||
label: presentation.string.ColorReset,
|
||||
icon: IconRedo
|
||||
})
|
||||
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id) => {
|
||||
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id: PaletteCommandId | undefined) => {
|
||||
switch (id) {
|
||||
case 'add-color': {
|
||||
if (colorSelector !== undefined) {
|
||||
@@ -89,16 +101,16 @@
|
||||
break
|
||||
}
|
||||
case 'remove-color': {
|
||||
penColors = penColors.filter((c: string) => c !== penColor)
|
||||
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
|
||||
selectColor(penColors[0])
|
||||
colorsPalette = colorsPalette.filter((c: string) => c !== penColor)
|
||||
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
|
||||
selectColor(colorsPalette[0])
|
||||
focusEditor()
|
||||
break
|
||||
}
|
||||
case 'reset-colors': {
|
||||
penColors = defaultColors
|
||||
colorsPalette = defaultColors
|
||||
localStorage.removeItem(storageKey.colors)
|
||||
selectColor(penColors[0])
|
||||
selectColor(colorsPalette[0])
|
||||
focusEditor()
|
||||
break
|
||||
}
|
||||
@@ -106,6 +118,8 @@
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _exhaustive: never = id
|
||||
console.error('Unknown command id', id)
|
||||
}
|
||||
}
|
||||
@@ -114,9 +128,9 @@
|
||||
|
||||
function addColorPreset (): void {
|
||||
penColor = penColor.toLowerCase()
|
||||
if (!penColors.includes(penColor)) {
|
||||
penColors = [...penColors, penColor]
|
||||
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
|
||||
if (!colorsPalette.includes(penColor)) {
|
||||
colorsPalette = [...colorsPalette, penColor]
|
||||
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
|
||||
}
|
||||
focusEditor()
|
||||
}
|
||||
@@ -129,13 +143,13 @@
|
||||
onMount(() => {
|
||||
try {
|
||||
const savedColors = localStorage.getItem(storageKey.colors)
|
||||
penColors = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
|
||||
colorsPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
|
||||
} catch {
|
||||
penColors = defaultColors
|
||||
colorsPalette = defaultColors
|
||||
}
|
||||
penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor).toLowerCase()
|
||||
if (!penColors.includes(penColor)) {
|
||||
penColor = penColors[0] ?? defaultColor
|
||||
if (!colorsPalette.includes(penColor)) {
|
||||
penColor = colorsPalette[0] ?? defaultColor
|
||||
}
|
||||
penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4')
|
||||
eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '50')
|
||||
@@ -165,9 +179,31 @@
|
||||
</script>
|
||||
|
||||
<div class="toolbar" class:inside={placeInside} bind:this={toolbar}>
|
||||
<Button
|
||||
icon={IconUndo}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.Undo }}
|
||||
noFocus
|
||||
disabled={disableUndo}
|
||||
on:click={() => {
|
||||
dispatch('undo')
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={IconRedo}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.Redo }}
|
||||
noFocus
|
||||
disabled={disableRedo}
|
||||
on:click={() => {
|
||||
dispatch('redo')
|
||||
}}
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
<Button
|
||||
icon={IconDelete}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.ClearCanvas }}
|
||||
noFocus
|
||||
on:click={() => {
|
||||
tool = 'pen'
|
||||
@@ -178,6 +214,7 @@
|
||||
<Button
|
||||
icon={IconEdit}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.PenTool }}
|
||||
noFocus
|
||||
selected={tool === 'pen'}
|
||||
on:click={() => {
|
||||
@@ -187,6 +224,7 @@
|
||||
<Button
|
||||
icon={IconEraser}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.EraserTool }}
|
||||
noFocus
|
||||
selected={tool === 'erase'}
|
||||
on:click={() => {
|
||||
@@ -197,6 +235,7 @@
|
||||
<Button
|
||||
icon={IconMove}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.PanTool }}
|
||||
noFocus
|
||||
selected={tool === 'pan'}
|
||||
on:click={() => {
|
||||
@@ -207,6 +246,7 @@
|
||||
<Button
|
||||
icon={IconText}
|
||||
kind="icon"
|
||||
showTooltip={{ label: presentation.string.TextTool }}
|
||||
noFocus
|
||||
selected={tool === 'text'}
|
||||
on:click={() => {
|
||||
@@ -248,11 +288,12 @@
|
||||
/>
|
||||
<div class="divider buttons-divider" />
|
||||
{/if}
|
||||
{#each penColors as color}
|
||||
{#each colorsPalette as color}
|
||||
<Button
|
||||
kind="icon"
|
||||
noFocus
|
||||
selected={penColor === color}
|
||||
showTooltip={{ label: presentation.string.ColorTooltip, props: { color } }}
|
||||
on:click={() => {
|
||||
if (tool === 'erase') {
|
||||
tool = 'pen'
|
||||
@@ -272,7 +313,13 @@
|
||||
bind:value={penColor}
|
||||
on:change={addColorPreset}
|
||||
/>
|
||||
<Button kind="icon" icon={IconMoreH} noFocus on:click={showMenu} />
|
||||
<Button
|
||||
kind="icon"
|
||||
icon={IconMoreH}
|
||||
noFocus
|
||||
showTooltip={{ label: presentation.string.PaletteManagementMenu }}
|
||||
on:click={showPaletteManagementMenu}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -198,7 +198,11 @@
|
||||
/>
|
||||
<div class="buttons-divider" />
|
||||
{/if}
|
||||
<DownloadFileButton {name} {file} />
|
||||
<DownloadFileButton
|
||||
{name}
|
||||
{file}
|
||||
tooltip={props.drawingAvailable === true ? presentation.string.DownloadOriginal : undefined}
|
||||
/>
|
||||
<ComponentExtensions
|
||||
extension={presentation.extension.FilePreviewPopupActions}
|
||||
props={{
|
||||
|
||||
@@ -13,6 +13,24 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { generateId } from '@hcengineering/core'
|
||||
import {
|
||||
type CanvasPoint,
|
||||
easeInOutCubic,
|
||||
middlePoint,
|
||||
offsetInParent,
|
||||
offsetPoint,
|
||||
rescaleToFitAspectRatio,
|
||||
scalePoint,
|
||||
type Point,
|
||||
makeCanvasPoint,
|
||||
type NodePoint,
|
||||
type MouseScaledPoint,
|
||||
makeMouseScaledPoint,
|
||||
makeNodePoint,
|
||||
offsetCanvasPoint
|
||||
} from './drawingUtils'
|
||||
|
||||
export interface DrawingData {
|
||||
content?: string
|
||||
}
|
||||
@@ -22,7 +40,7 @@ export interface DrawingProps {
|
||||
autoSize?: boolean
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
commands: DrawingCmd[]
|
||||
commands?: DrawingCmd[]
|
||||
offset?: Point
|
||||
tool?: DrawingTool
|
||||
penColor?: string
|
||||
@@ -30,14 +48,14 @@ export interface DrawingProps {
|
||||
eraserWidth?: number
|
||||
fontSize?: number
|
||||
defaultCursor?: string
|
||||
changingCmdId?: string
|
||||
changingCmdId?: CommandUid
|
||||
personCursorPos?: Point
|
||||
personCursorVisible?: boolean
|
||||
cmdAdded?: (cmd: DrawingCmd) => void
|
||||
cmdChanging?: (id: string) => void
|
||||
cmdUnchanged?: (id: string) => void
|
||||
cmdChanging?: (id: CommandUid) => void
|
||||
cmdUnchanged?: (id: CommandUid) => void
|
||||
cmdChanged?: (cmd: DrawingCmd) => void
|
||||
cmdDeleted?: (id: string) => void
|
||||
cmdDeleted?: (id: CommandUid) => void
|
||||
editorCreated?: (editor: HTMLDivElement) => void
|
||||
pointerMoved?: (canvasPos: Point) => void
|
||||
personCursorMoved?: (nodePos: Point) => void
|
||||
@@ -45,14 +63,16 @@ export interface DrawingProps {
|
||||
panned?: (offset: Point) => void
|
||||
}
|
||||
|
||||
export type CommandUid = string & { readonly __brand: 'CommandUid' }
|
||||
|
||||
export interface DrawingCmd {
|
||||
id: string
|
||||
id: CommandUid
|
||||
type: 'line' | 'text'
|
||||
}
|
||||
|
||||
export interface DrawTextCmd extends DrawingCmd {
|
||||
text: string
|
||||
pos: Point
|
||||
pos: CanvasPoint
|
||||
fontSize: number
|
||||
fontFace: string
|
||||
color: string
|
||||
@@ -62,34 +82,23 @@ export interface DrawLineCmd extends DrawingCmd {
|
||||
lineWidth: number
|
||||
erasing: boolean
|
||||
penColor: string
|
||||
points: Point[]
|
||||
points: CanvasPoint[]
|
||||
}
|
||||
|
||||
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function avgPoint (p1: Point, p2: Point): Point {
|
||||
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
|
||||
}
|
||||
|
||||
const maxTextLength = 500
|
||||
|
||||
export const makeCommandId = (): string => {
|
||||
return crypto.randomUUID().toString()
|
||||
}
|
||||
|
||||
function easeInOutCubic (x: number): number {
|
||||
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
|
||||
export const makeCommandUid = (): CommandUid => {
|
||||
return (crypto?.randomUUID?.() ?? generateId()) as CommandUid
|
||||
}
|
||||
|
||||
const crossSvg = `<svg height="8" width="8" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m1.29 2.71 5.3 5.29-5.3 5.29c-.92.92.49 2.34 1.41 1.41l5.3-5.29 5.29 5.3c.92.92 2.34-.49 1.41-1.41l-5.29-5.3 5.3-5.29c.92-.93-.49-2.34-1.42-1.42l-5.29 5.3-5.29-5.3c-.93-.92-2.34.49-1.42 1.42z"/>
|
||||
</svg>`
|
||||
|
||||
type PointStatus = 'last-point' | 'intermediate-point'
|
||||
|
||||
class DrawState {
|
||||
on = false
|
||||
tool: DrawingTool = 'pen'
|
||||
@@ -101,8 +110,9 @@ class DrawState {
|
||||
fontFace = '"IBM Plex Sans"'
|
||||
center: Point = { x: 0, y: 0 }
|
||||
offset: Point = { x: 0, y: 0 }
|
||||
points: Point[] = []
|
||||
points: CanvasPoint[] = []
|
||||
scale: Point = { x: 1, y: 1 }
|
||||
cssTransformScale: Point = { x: 1, y: 1 }
|
||||
ctx: CanvasRenderingContext2D
|
||||
|
||||
constructor (ctx: CanvasRenderingContext2D) {
|
||||
@@ -117,22 +127,22 @@ class DrawState {
|
||||
return (this.scale.x + this.scale.y) / 2
|
||||
}
|
||||
|
||||
addPoint = (mouseX: number, mouseY: number): void => {
|
||||
this.points.push(this.mouseToCanvasPoint({ x: mouseX, y: mouseY }))
|
||||
addPoint = (target: MouseScaledPoint): void => {
|
||||
this.points.push(this.mouseToCanvasPoint(target))
|
||||
}
|
||||
|
||||
mouseToCanvasPoint = (mouse: Point): Point => {
|
||||
return {
|
||||
x: Math.round(mouse.x * this.scale.x - this.offset.x - this.center.x),
|
||||
y: Math.round(mouse.y * this.scale.y - this.offset.y - this.center.y)
|
||||
}
|
||||
mouseToCanvasPoint = (mouse: MouseScaledPoint): CanvasPoint => {
|
||||
return makeCanvasPoint(
|
||||
Math.round(mouse.x * this.scale.x - this.offset.x - this.center.x),
|
||||
Math.round(mouse.y * this.scale.y - this.offset.y - this.center.y)
|
||||
)
|
||||
}
|
||||
|
||||
canvasToMousePoint = (canvas: Point): Point => {
|
||||
return {
|
||||
x: Math.round(canvas.x / this.scale.x + this.offset.x + this.center.x),
|
||||
y: Math.round(canvas.y / this.scale.y + this.offset.y + this.center.y)
|
||||
}
|
||||
canvasToMousePoint = (canvas: CanvasPoint): MouseScaledPoint => {
|
||||
return makeMouseScaledPoint(
|
||||
Math.round(canvas.x / this.scale.x + this.offset.x + this.center.x),
|
||||
Math.round(canvas.y / this.scale.y + this.offset.y + this.center.y)
|
||||
)
|
||||
}
|
||||
|
||||
isDrawingTool = (): boolean => {
|
||||
@@ -143,26 +153,29 @@ class DrawState {
|
||||
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
|
||||
}
|
||||
|
||||
drawLive = (x: number, y: number, lastPoint = false): void => {
|
||||
drawLine = (point: MouseScaledPoint, status: PointStatus): void => {
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!lastPoint || this.points.length > 1) {
|
||||
this.addPoint(x, y)
|
||||
if (status === 'intermediate-point' || this.points.length <= 1) {
|
||||
this.addPoint(point)
|
||||
}
|
||||
const erasing = this.tool === 'erase'
|
||||
this.ctx.save()
|
||||
this.translateCtx()
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = this.penColor
|
||||
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
|
||||
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
|
||||
if (this.points.length === 1) {
|
||||
this.drawPoint(this.points[0], erasing)
|
||||
} else {
|
||||
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
|
||||
this.ctx.stroke()
|
||||
try {
|
||||
this.translateCtx()
|
||||
this.ctx.beginPath()
|
||||
this.ctx.lineCap = 'round'
|
||||
this.ctx.strokeStyle = this.penColor
|
||||
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
|
||||
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
|
||||
if (this.points.length === 1) {
|
||||
this.drawPoint(this.points[0], erasing)
|
||||
} else {
|
||||
this.drawSmoothSegment(this.points, this.points.length - 1, status)
|
||||
this.ctx.stroke()
|
||||
}
|
||||
} finally {
|
||||
this.ctx.restore()
|
||||
}
|
||||
this.ctx.restore()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,7 +199,8 @@ class DrawState {
|
||||
this.drawPoint(cmd.points[0], cmd.erasing)
|
||||
} else {
|
||||
for (let i = 1; i < cmd.points.length; i++) {
|
||||
this.drawSmoothSegment(cmd.points, i, i === cmd.points.length - 1)
|
||||
const pointStatus: PointStatus = i === cmd.points.length - 1 ? 'last-point' : 'intermediate-point'
|
||||
this.drawSmoothSegment(cmd.points, i, pointStatus)
|
||||
}
|
||||
this.ctx.stroke()
|
||||
}
|
||||
@@ -209,7 +223,7 @@ class DrawState {
|
||||
this.ctx.restore()
|
||||
}
|
||||
|
||||
isPointInText = (p: Point, cmd: DrawTextCmd): boolean => {
|
||||
isPointInText = (p: CanvasPoint, cmd: DrawTextCmd): boolean => {
|
||||
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
|
||||
const lines = cmd.text.split('\n').map((l) => l.trim())
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
@@ -239,21 +253,21 @@ class DrawState {
|
||||
this.ctx.fill()
|
||||
}
|
||||
|
||||
drawSmoothSegment = (points: Point[], index: number, lastPoint: boolean): void => {
|
||||
drawSmoothSegment = (points: Point[], index: number, status: PointStatus): void => {
|
||||
const curPos = points[index]
|
||||
const prevPos = points[index - 1]
|
||||
const avg = avgPoint(prevPos, curPos)
|
||||
const avg = middlePoint(prevPos, curPos)
|
||||
if (index === 1) {
|
||||
this.ctx.moveTo(prevPos.x, prevPos.y)
|
||||
if (lastPoint) {
|
||||
if (status === 'last-point') {
|
||||
this.ctx.lineTo(curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(curPos.x, curPos.y, avg.x, avg.y)
|
||||
}
|
||||
} else {
|
||||
const prevAvg = avgPoint(points[index - 2], prevPos)
|
||||
const prevAvg = middlePoint(points[index - 2], prevPos)
|
||||
this.ctx.moveTo(prevAvg.x, prevAvg.y)
|
||||
if (lastPoint) {
|
||||
if (status === 'last-point') {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, curPos.x, curPos.y)
|
||||
} else {
|
||||
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, avg.x, avg.y)
|
||||
@@ -300,8 +314,8 @@ export function drawing (
|
||||
node.appendChild(toolCursor)
|
||||
|
||||
let readonly = props.readonly ?? false
|
||||
let prevPos: Point = { x: 0, y: 0 }
|
||||
let personCursorPos: Point = { x: 0, y: 0 }
|
||||
let prevPos: MouseScaledPoint = makeMouseScaledPoint(0, 0)
|
||||
let personCursorPos: CanvasPoint = makeCanvasPoint(0, 0)
|
||||
let isPersonCursorAnimating = false
|
||||
|
||||
const draw = new DrawState(ctx)
|
||||
@@ -317,15 +331,27 @@ export function drawing (
|
||||
updateCanvasTouchAction()
|
||||
|
||||
interface LiveTextBox {
|
||||
pos: Point
|
||||
pos: MouseScaledPoint
|
||||
box: HTMLDivElement
|
||||
editor: HTMLDivElement
|
||||
cmdId: string
|
||||
cmdId: CommandUid
|
||||
}
|
||||
let liveTextBox: LiveTextBox | undefined
|
||||
|
||||
let commands = props.commands
|
||||
replayCommands()
|
||||
let currentCommands = props.commands
|
||||
|
||||
function traverseCommands (target: DrawingCmd[] | undefined, delegate: (command: DrawingCmd) => boolean): void {
|
||||
if (undefined === target) {
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
if (delegate(target[i])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
replayCommands(currentCommands)
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -334,14 +360,26 @@ export function drawing (
|
||||
draw.scale = { x: 1, y: 1 }
|
||||
canvas.width = Math.floor(entry.contentRect.width)
|
||||
canvas.height = Math.floor(entry.contentRect.height)
|
||||
draw.center.x = canvas.width / 2
|
||||
draw.center.y = canvas.height / 2
|
||||
replayCommands()
|
||||
} else {
|
||||
draw.scale = {
|
||||
x: canvas.width / entry.contentRect.width,
|
||||
y: canvas.height / entry.contentRect.height
|
||||
draw.center = {
|
||||
x: canvas.width / 2,
|
||||
y: canvas.height / 2
|
||||
}
|
||||
replayCommands(currentCommands)
|
||||
} else {
|
||||
const imageWidth = props.imageWidth ?? 1
|
||||
const imageHeight = props.imageHeight ?? 1
|
||||
const scale = rescaleToFitAspectRatio(
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
entry.contentRect.width,
|
||||
entry.contentRect.height
|
||||
)
|
||||
canvas.style.transform = `scale(${scale.x}, ${scale.y})`
|
||||
draw.scale = {
|
||||
x: canvas.width / entry.contentRect.width / scale.x,
|
||||
y: canvas.height / entry.contentRect.height / scale.y
|
||||
}
|
||||
draw.cssTransformScale = scale
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,19 +397,13 @@ export function drawing (
|
||||
}
|
||||
}
|
||||
|
||||
function touchToNodePoint (touch: Touch, node: HTMLElement): Point {
|
||||
function touchToNodePoint (touch: Touch, node: HTMLElement): NodePoint {
|
||||
const rect = node.getBoundingClientRect()
|
||||
return {
|
||||
x: Math.round(touch.clientX - rect.left),
|
||||
y: Math.round(touch.clientY - rect.top)
|
||||
}
|
||||
return makeNodePoint(Math.round(touch.clientX - rect.left), Math.round(touch.clientY - rect.top))
|
||||
}
|
||||
|
||||
function pointerToNodePoint (e: PointerEvent): Point {
|
||||
return {
|
||||
x: Math.round(e.offsetX),
|
||||
y: Math.round(e.offsetY)
|
||||
}
|
||||
function pointerToNodePoint (e: PointerEvent): NodePoint {
|
||||
return makeNodePoint(Math.round(e.offsetX), Math.round(e.offsetY))
|
||||
}
|
||||
|
||||
canvas.ontouchstart = (e) => {
|
||||
@@ -449,34 +481,46 @@ export function drawing (
|
||||
}
|
||||
}
|
||||
|
||||
function drawStart (p: Point): void {
|
||||
function rescaleWithCss (target: NodePoint): MouseScaledPoint {
|
||||
const scaled = scalePoint(target, draw.cssTransformScale)
|
||||
return makeMouseScaledPoint(scaled.x, scaled.y)
|
||||
}
|
||||
|
||||
function drawStart (p: NodePoint): void {
|
||||
const scaledPoint = rescaleWithCss(p)
|
||||
draw.on = true
|
||||
draw.points = []
|
||||
prevPos = p
|
||||
prevPos = scaledPoint
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.addPoint(p.x, p.y)
|
||||
draw.addPoint(scaledPoint)
|
||||
}
|
||||
}
|
||||
|
||||
function drawContinue (p: Point): void {
|
||||
function drawContinue (p: NodePoint): void {
|
||||
const scaledPoint = rescaleWithCss(p)
|
||||
|
||||
if (draw.isDrawingTool()) {
|
||||
const w = draw.cursorWidth()
|
||||
toolCursor.style.left = `${p.x - w / 2}px`
|
||||
toolCursor.style.top = `${p.y - w / 2}px`
|
||||
const cursorSize = draw.cursorWidth()
|
||||
|
||||
const canvasOffsetInParent = offsetInParent(node, canvas)
|
||||
const parentRelativeLocation = offsetPoint(scaledPoint, canvasOffsetInParent)
|
||||
toolCursor.style.left = `${parentRelativeLocation.x - cursorSize / 2}px`
|
||||
toolCursor.style.top = `${parentRelativeLocation.y - cursorSize / 2}px`
|
||||
|
||||
if (draw.on) {
|
||||
if (Math.hypot(prevPos.x - p.x, prevPos.y - p.y) >= draw.minLineLength) {
|
||||
draw.drawLive(p.x, p.y)
|
||||
prevPos = p
|
||||
if (Math.hypot(prevPos.x - scaledPoint.x, prevPos.y - scaledPoint.y) >= draw.minLineLength) {
|
||||
draw.drawLine(scaledPoint, 'intermediate-point')
|
||||
prevPos = scaledPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draw.on && draw.tool === 'pan') {
|
||||
requestAnimationFrame(() => {
|
||||
draw.offset.x += p.x - prevPos.x
|
||||
draw.offset.y += p.y - prevPos.y
|
||||
replayCommands()
|
||||
prevPos = p
|
||||
draw.offset.x += scaledPoint.x - prevPos.x
|
||||
draw.offset.y += scaledPoint.y - prevPos.y
|
||||
replayCommands(currentCommands)
|
||||
prevPos = scaledPoint
|
||||
if (props.panning !== undefined) {
|
||||
props.panning(draw.offset)
|
||||
}
|
||||
@@ -484,40 +528,43 @@ export function drawing (
|
||||
}
|
||||
|
||||
if (draw.on && draw.tool === 'text') {
|
||||
prevPos = p
|
||||
prevPos = scaledPoint
|
||||
}
|
||||
|
||||
if (props.pointerMoved !== undefined) {
|
||||
props.pointerMoved(draw.mouseToCanvasPoint(p))
|
||||
props.pointerMoved(draw.mouseToCanvasPoint(scaledPoint))
|
||||
}
|
||||
}
|
||||
|
||||
function drawEnd (p: Point): void {
|
||||
function drawEnd (p: NodePoint): void {
|
||||
const scaledPoint = rescaleWithCss(p)
|
||||
if (draw.on) {
|
||||
if (draw.isDrawingTool()) {
|
||||
draw.drawLive(p.x, p.y, true)
|
||||
draw.drawLine(scaledPoint, 'last-point')
|
||||
storeLineCommand()
|
||||
} else if (draw.tool === 'pan') {
|
||||
props.panned?.(draw.offset)
|
||||
} else if (draw.tool === 'text') {
|
||||
if (liveTextBox !== undefined) {
|
||||
storeTextCommand()
|
||||
closeLiveTextBox()
|
||||
commitTextEdit({ deferCommandStore: false })
|
||||
} else {
|
||||
const cmd = findTextCommand(prevPos)
|
||||
props.cmdChanging?.(cmd?.id ?? '')
|
||||
props.cmdChanging?.(cmd?.id ?? ('' as CommandUid))
|
||||
}
|
||||
}
|
||||
draw.on = false
|
||||
}
|
||||
}
|
||||
|
||||
function findTextCommand (mousePos: Point): DrawTextCmd | undefined {
|
||||
function findTextCommand (mousePos: MouseScaledPoint): DrawTextCmd | undefined {
|
||||
if (currentCommands === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const pos = draw.mouseToCanvasPoint(mousePos)
|
||||
for (let i = commands.length - 1; i >= 0; i--) {
|
||||
const anyCmd = commands[i]
|
||||
if (anyCmd.type === 'text') {
|
||||
const cmd = anyCmd as DrawTextCmd
|
||||
for (let i = currentCommands.length - 1; i >= 0; i--) {
|
||||
const candidate = currentCommands[i]
|
||||
if (candidate.type === 'text') {
|
||||
const cmd = candidate as DrawTextCmd
|
||||
if (draw.isPointInText(pos, cmd)) {
|
||||
return cmd
|
||||
}
|
||||
@@ -526,25 +573,32 @@ export function drawing (
|
||||
return undefined
|
||||
}
|
||||
|
||||
function makeLiveTextBox (cmdId: string): void {
|
||||
function makeLiveTextBox (targetCommandId: CommandUid): void {
|
||||
let pos = prevPos
|
||||
let existingCmd: DrawTextCmd | undefined
|
||||
for (const cmd of commands) {
|
||||
if (cmd.id === cmdId && cmd.type === 'text') {
|
||||
existingCmd = cmd as DrawTextCmd
|
||||
pos = draw.canvasToMousePoint(existingCmd.pos)
|
||||
break
|
||||
let foundTextCommand: DrawTextCmd | undefined
|
||||
traverseCommands(currentCommands, (candidate) => {
|
||||
if (candidate.id === targetCommandId && candidate.type === 'text') {
|
||||
foundTextCommand = candidate as DrawTextCmd
|
||||
pos = draw.canvasToMousePoint(foundTextCommand.pos)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const padding = 6
|
||||
const handleSize = 14
|
||||
|
||||
const box = document.createElement('div')
|
||||
const editor = document.createElement('div')
|
||||
box.appendChild(editor)
|
||||
|
||||
const canvasOffsetInParent = offsetInParent(node, canvas)
|
||||
const parentRelativeLocation = offsetPoint(pos, canvasOffsetInParent)
|
||||
|
||||
box.style.zIndex = '1'
|
||||
box.style.position = 'absolute'
|
||||
box.style.left = `calc(${pos.x}px - ${padding}px)`
|
||||
box.style.top = `calc(${pos.y}px - ${padding}px)`
|
||||
box.style.left = `calc(${parentRelativeLocation.x}px - ${padding}px)`
|
||||
box.style.top = `calc(${parentRelativeLocation.y}px - ${padding}px)`
|
||||
box.style.border = '1px solid var(--theme-editbox-focus-border)'
|
||||
box.style.borderRadius = 'var(--small-BorderRadius)'
|
||||
box.style.padding = `${padding}px`
|
||||
@@ -558,15 +612,14 @@ export function drawing (
|
||||
editor.focus()
|
||||
})
|
||||
|
||||
const editor = document.createElement('div')
|
||||
editor.style.cursor = 'text'
|
||||
editor.style.padding = '0'
|
||||
editor.contentEditable = 'true'
|
||||
editor.style.outline = 'none'
|
||||
editor.style.minWidth = '2rem'
|
||||
editor.style.whiteSpace = 'nowrap'
|
||||
if (existingCmd !== undefined) {
|
||||
editor.innerText = existingCmd.text
|
||||
if (foundTextCommand !== undefined) {
|
||||
editor.innerText = foundTextCommand.text
|
||||
}
|
||||
editor.addEventListener('input', (e) => {
|
||||
if (editor.innerText.length > maxTextLength) {
|
||||
@@ -615,14 +668,12 @@ export function drawing (
|
||||
}, 0)
|
||||
}
|
||||
closeLiveTextBox()
|
||||
replayCommands()
|
||||
replayCommands(currentCommands)
|
||||
} else if (e.key === 'Enter' && e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
storeTextCommand()
|
||||
closeLiveTextBox()
|
||||
commitTextEdit({ deferCommandStore: false })
|
||||
}
|
||||
})
|
||||
box.appendChild(editor)
|
||||
|
||||
const moveCaretToEnd = (): void => {
|
||||
const selection = window.getSelection()
|
||||
@@ -657,25 +708,28 @@ export function drawing (
|
||||
}
|
||||
|
||||
const moveTextBox = (dx: number, dy: number): void => {
|
||||
let newX = box.offsetLeft + dx
|
||||
let newY = box.offsetTop + dy
|
||||
const canvasOffset = offsetInParent(node, canvas)
|
||||
let actualNewX = box.offsetLeft + dx
|
||||
let actualNewY = box.offsetTop + dy
|
||||
// For screenshots the canvas always has the same size as the underlying image
|
||||
// and we should not be able to drag the text box outside of the screenshot
|
||||
if (props.autoSize !== true) {
|
||||
newX = Math.max(0, newX)
|
||||
newY = Math.max(0, newY)
|
||||
if (newX + box.offsetWidth > node.clientWidth) {
|
||||
newX = node.clientWidth - box.offsetWidth
|
||||
const canvasSize: Point = { x: canvas.getBoundingClientRect().width, y: canvas.getBoundingClientRect().height }
|
||||
actualNewX = Math.max(canvasOffset.x, actualNewX)
|
||||
actualNewY = Math.max(canvasOffset.y, actualNewY)
|
||||
|
||||
if (actualNewX > canvasOffset.x + canvasSize.x) {
|
||||
actualNewX = canvasOffset.x + canvasSize.x
|
||||
}
|
||||
if (newY + box.offsetHeight > node.clientHeight) {
|
||||
newY = node.clientHeight - box.offsetHeight
|
||||
if (actualNewY > canvasOffset.y + canvasSize.y) {
|
||||
actualNewY = canvasOffset.y + canvasSize.y
|
||||
}
|
||||
}
|
||||
box.style.left = `${newX}px`
|
||||
box.style.top = `${newY}px`
|
||||
box.style.left = `${actualNewX}px`
|
||||
box.style.top = `${actualNewY}px`
|
||||
if (liveTextBox !== undefined) {
|
||||
liveTextBox.pos.x = newX + padding
|
||||
liveTextBox.pos.y = newY + padding
|
||||
liveTextBox.pos.x = actualNewX - canvasOffset.x + padding
|
||||
liveTextBox.pos.y = actualNewY - canvasOffset.y + padding
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,12 +741,12 @@ export function drawing (
|
||||
e.preventDefault()
|
||||
dragHandle.style.cursor = 'grabbing'
|
||||
dragHandle.setPointerCapture(e.pointerId)
|
||||
let prevPos = { x: e.clientX, y: e.clientY }
|
||||
let previousDragPosition = { x: e.clientX, y: e.clientY }
|
||||
const pointerMove = (e: PointerEvent): void => {
|
||||
e.preventDefault()
|
||||
const p = { x: e.clientX, y: e.clientY }
|
||||
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
|
||||
prevPos = p
|
||||
const currentDragPosition = { x: e.clientX, y: e.clientY }
|
||||
moveTextBox(currentDragPosition.x - previousDragPosition.x, currentDragPosition.y - previousDragPosition.y)
|
||||
previousDragPosition = currentDragPosition
|
||||
}
|
||||
const pointerUp = (e: PointerEvent): void => {
|
||||
setTimeout(() => {
|
||||
@@ -713,13 +767,13 @@ export function drawing (
|
||||
dragHandle.style.cursor = 'grabbing'
|
||||
const touch = e.changedTouches[0]
|
||||
const touchId = touch.identifier
|
||||
let prevPos = touchToNodePoint(touch, dragHandle)
|
||||
let prevPos: MouseScaledPoint = rescaleWithCss(touchToNodePoint(touch, dragHandle))
|
||||
const touchMove = (e: TouchEvent): void => {
|
||||
const touch = findTouch(e.changedTouches, touchId)
|
||||
if (touch !== undefined) {
|
||||
const p = touchToNodePoint(touch, dragHandle)
|
||||
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
|
||||
prevPos = p
|
||||
const scaledPoint: MouseScaledPoint = rescaleWithCss(touchToNodePoint(touch, dragHandle))
|
||||
moveTextBox(scaledPoint.x - prevPos.x, scaledPoint.y - prevPos.y)
|
||||
prevPos = scaledPoint
|
||||
}
|
||||
}
|
||||
const touchEnd = (e: TouchEvent): void => {
|
||||
@@ -751,7 +805,7 @@ export function drawing (
|
||||
box.appendChild(deleteButton)
|
||||
|
||||
node.appendChild(box)
|
||||
liveTextBox = { box, editor, pos, cmdId }
|
||||
liveTextBox = { box, editor, pos, cmdId: targetCommandId }
|
||||
updateLiveTextBox()
|
||||
setTimeout(() => {
|
||||
editor.focus()
|
||||
@@ -776,13 +830,13 @@ export function drawing (
|
||||
}
|
||||
}
|
||||
|
||||
function storeTextCommand (defer = false): void {
|
||||
function storeTextCommand (parameters: { defer: boolean }): void {
|
||||
if (liveTextBox !== undefined) {
|
||||
const text = (liveTextBox.editor.innerText ?? '').trim()
|
||||
if (text !== '') {
|
||||
const cmdId = liveTextBox.cmdId
|
||||
const cmd: DrawTextCmd = {
|
||||
id: cmdId === '' ? makeCommandId() : cmdId,
|
||||
id: cmdId === '' ? makeCommandUid() : cmdId,
|
||||
type: 'text',
|
||||
text,
|
||||
pos: draw.mouseToCanvasPoint(liveTextBox.pos),
|
||||
@@ -797,7 +851,7 @@ export function drawing (
|
||||
props.cmdAdded?.(cmd)
|
||||
}
|
||||
}
|
||||
if (defer) {
|
||||
if (parameters.defer) {
|
||||
setTimeout(notify, 0)
|
||||
} else {
|
||||
notify()
|
||||
@@ -808,11 +862,16 @@ export function drawing (
|
||||
}
|
||||
}
|
||||
|
||||
function commitTextEdit (parameters: { deferCommandStore: boolean }): void {
|
||||
storeTextCommand({ defer: parameters.deferCommandStore })
|
||||
closeLiveTextBox()
|
||||
}
|
||||
|
||||
function storeLineCommand (): void {
|
||||
if (draw.points.length > 0) {
|
||||
const erasing = draw.tool === 'erase'
|
||||
const cmd: DrawLineCmd = {
|
||||
id: makeCommandId(),
|
||||
id: makeCommandUid(),
|
||||
type: 'line',
|
||||
lineWidth: erasing ? draw.eraserWidth : draw.penWidth,
|
||||
erasing,
|
||||
@@ -896,10 +955,7 @@ export function drawing (
|
||||
if (deltaTime > frameInterval) {
|
||||
lastTime = currentTime - (deltaTime % frameInterval)
|
||||
const frac = Math.min((lastTime - startTime) / animDuration, 1)
|
||||
personCursorPos = {
|
||||
x: Math.round(oldPos.x + distanceX * frac),
|
||||
y: Math.round(oldPos.y + distanceY * frac)
|
||||
}
|
||||
personCursorPos = offsetCanvasPoint(oldPos, distanceX, distanceY, frac)
|
||||
if (props.personCursorMoved !== undefined) {
|
||||
const p = draw.canvasToMousePoint(personCursorPos)
|
||||
const x = Math.max(0, Math.min(p.x, canvas.width))
|
||||
@@ -929,14 +985,23 @@ export function drawing (
|
||||
canvas.style.touchAction = readonly ? 'unset' : 'none'
|
||||
}
|
||||
|
||||
function replayCommands (): void {
|
||||
function replayCommands (drawing: DrawingCmd[] | undefined): void {
|
||||
draw.ctx.reset()
|
||||
for (const cmd of commands) {
|
||||
if (cmd.id !== undefined && liveTextBox?.cmdId === cmd.id) {
|
||||
continue
|
||||
|
||||
/*
|
||||
On Safari (checked on 22.08.2025) reset() does not immediatly resets the canvas.
|
||||
The result looks like the "reset" command being cached and executed upon some
|
||||
action like mouse entering the canvas. Anyway with this line undo/redo (instantly
|
||||
adding commands) works good.
|
||||
*/
|
||||
draw.ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
traverseCommands(drawing, (command) => {
|
||||
if (command.id === undefined || liveTextBox?.cmdId !== command.id) {
|
||||
draw.drawCommand(command)
|
||||
}
|
||||
draw.drawCommand(cmd)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -946,6 +1011,7 @@ export function drawing (
|
||||
let offsetDelta: Point | undefined
|
||||
let replay = false
|
||||
let syncToolCursor = false
|
||||
let toolChanged = false
|
||||
let syncPersonCursor: Point | undefined
|
||||
let syncLiveTextBox = false
|
||||
if (props.offset !== undefined && !isOffsetAnimating) {
|
||||
@@ -961,8 +1027,8 @@ export function drawing (
|
||||
}
|
||||
}
|
||||
if (props.commands !== undefined) {
|
||||
if (commands !== props.commands) {
|
||||
commands = props.commands
|
||||
if (currentCommands !== props.commands) {
|
||||
currentCommands = props.commands
|
||||
replay = true
|
||||
}
|
||||
}
|
||||
@@ -970,6 +1036,7 @@ export function drawing (
|
||||
if (draw.tool !== props.tool) {
|
||||
draw.tool = props.tool
|
||||
syncToolCursor = true
|
||||
toolChanged = true
|
||||
}
|
||||
}
|
||||
if (props.penColor !== undefined) {
|
||||
@@ -1009,22 +1076,24 @@ export function drawing (
|
||||
syncPersonCursor = props.personCursorPos
|
||||
}
|
||||
}
|
||||
if (props.changingCmdId === undefined) {
|
||||
|
||||
if (props.changingCmdId === undefined || toolChanged) {
|
||||
if (liveTextBox !== undefined) {
|
||||
storeTextCommand(true)
|
||||
closeLiveTextBox()
|
||||
commitTextEdit({ deferCommandStore: true })
|
||||
replay = true
|
||||
}
|
||||
} else {
|
||||
if (liveTextBox === undefined) {
|
||||
makeLiveTextBox(props.changingCmdId)
|
||||
replay = true
|
||||
if (props.tool === 'text') {
|
||||
makeLiveTextBox(props.changingCmdId)
|
||||
replay = true
|
||||
}
|
||||
} else if (liveTextBox.cmdId !== props.changingCmdId) {
|
||||
storeTextCommand(true)
|
||||
closeLiveTextBox()
|
||||
commitTextEdit({ deferCommandStore: true })
|
||||
replay = true
|
||||
}
|
||||
}
|
||||
|
||||
if (syncToolCursor) {
|
||||
updateToolCursor()
|
||||
}
|
||||
@@ -1059,7 +1128,7 @@ export function drawing (
|
||||
x: Math.round(oldOffset.x + distance.x * fracDist),
|
||||
y: Math.round(oldOffset.y + distance.y * fracDist)
|
||||
}
|
||||
replayCommands()
|
||||
replayCommands(currentCommands)
|
||||
updatePersonCursor()
|
||||
if (fracTime >= 1) {
|
||||
isOffsetAnimating = false
|
||||
@@ -1071,7 +1140,7 @@ export function drawing (
|
||||
isOffsetAnimating = true
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
replayCommands()
|
||||
replayCommands(currentCommands)
|
||||
updatePersonCursor()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// 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 { makeCommandUid, type CommandUid, type DrawingCmd } from './drawing'
|
||||
import { type Array as YArray, type Doc as YDoc, UndoManager as YUndoManager } from 'yjs'
|
||||
|
||||
export class UndoRedoAvailability {
|
||||
constructor (
|
||||
private readonly disableUndo: boolean,
|
||||
private readonly disableRedo: boolean
|
||||
) {}
|
||||
|
||||
get undoDisabled (): boolean {
|
||||
return this.disableUndo
|
||||
}
|
||||
|
||||
get redoDisabled (): boolean {
|
||||
return this.disableRedo
|
||||
}
|
||||
}
|
||||
|
||||
export class DrawingCommandsProcessor {
|
||||
private readonly document: YDoc
|
||||
private readonly commands: YArray<DrawingCmd>
|
||||
private readonly undoManager: YUndoManager
|
||||
|
||||
private readonly UndoableOperationMarker = 137
|
||||
|
||||
constructor (document: YDoc, backend: YArray<DrawingCmd>) {
|
||||
this.document = document
|
||||
this.commands = backend
|
||||
this.undoManager = new YUndoManager(backend, {
|
||||
trackedOrigins: new Set([this.UndoableOperationMarker]),
|
||||
captureTimeout: 0
|
||||
})
|
||||
}
|
||||
|
||||
set (commands: DrawingCmd[]): void {
|
||||
this.document.transact(() => {
|
||||
this.removeAllCommands()
|
||||
for (const command of commands) {
|
||||
this.commands.push([command])
|
||||
}
|
||||
})
|
||||
this.undoManager.clear()
|
||||
}
|
||||
|
||||
getUndoRedoAvailability (): UndoRedoAvailability {
|
||||
const disableUndo = !this.undoManager.canUndo()
|
||||
const disableRedo = !this.undoManager.canRedo()
|
||||
return new UndoRedoAvailability(disableUndo, disableRedo)
|
||||
}
|
||||
|
||||
snapshot (): DrawingCmd[] {
|
||||
return this.commands.toArray()
|
||||
}
|
||||
|
||||
undo (): void {
|
||||
this.undoManager.undo()
|
||||
}
|
||||
|
||||
redo (): void {
|
||||
this.undoManager.redo()
|
||||
}
|
||||
|
||||
clear (): void {
|
||||
this.document.transact(() => {
|
||||
this.removeAllCommands()
|
||||
}, this.UndoableOperationMarker)
|
||||
}
|
||||
|
||||
ensureAllCommandsWithUids (): void {
|
||||
this.document.transact(() => {
|
||||
let anyLacksId = false
|
||||
for (let i = 0; i < this.commands.length; i++) {
|
||||
if (this.commands.get(i).id === undefined) {
|
||||
anyLacksId = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (anyLacksId) {
|
||||
const snapshot = this.commands.toArray()
|
||||
this.removeAllCommands()
|
||||
this.commands.push(snapshot.map((x: DrawingCmd) => ({ ...x, id: x.id ?? makeCommandUid() })))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addCommand (target: DrawingCmd): void {
|
||||
this.document.transact(() => {
|
||||
this.commands.push([target])
|
||||
}, this.UndoableOperationMarker)
|
||||
}
|
||||
|
||||
deleteCommand (id: CommandUid): void {
|
||||
this.document.transact(() => {
|
||||
for (let i = 0; i < this.commands.length; i++) {
|
||||
if (this.commands.get(i).id === id) {
|
||||
this.commands.delete(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, this.UndoableOperationMarker)
|
||||
}
|
||||
|
||||
changeCommand (newOne: DrawingCmd): void {
|
||||
this.document.transact(() => {
|
||||
let index = -1
|
||||
for (let i = 0; i < this.commands.length; i++) {
|
||||
if (this.commands.get(i).id === newOne.id) {
|
||||
this.commands.delete(i)
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
this.commands.insert(index, [newOne])
|
||||
} else {
|
||||
this.commands.push([newOne])
|
||||
}
|
||||
}, this.UndoableOperationMarker)
|
||||
}
|
||||
|
||||
private removeAllCommands (): void {
|
||||
this.commands.delete(0, this.commands.length)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// 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 interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
declare const NodePointBrand: unique symbol
|
||||
export interface NodePoint {
|
||||
readonly [NodePointBrand]: never
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
export function makeNodePoint (x: number, y: number): NodePoint {
|
||||
return { x, y } as any
|
||||
}
|
||||
|
||||
declare const MouseScaledPointBrand: unique symbol
|
||||
export interface MouseScaledPoint {
|
||||
readonly [MouseScaledPointBrand]: never
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
export function makeMouseScaledPoint (x: number, y: number): MouseScaledPoint {
|
||||
return { x, y } as any
|
||||
}
|
||||
|
||||
declare const CanvasPointBrand: unique symbol
|
||||
export interface CanvasPoint {
|
||||
readonly [CanvasPointBrand]: never
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
export function makeCanvasPoint (x: number, y: number): CanvasPoint {
|
||||
return { x, y } as any
|
||||
}
|
||||
|
||||
export function scalePoint (victim: Point, scale: Point): Point {
|
||||
return { x: victim.x * scale.x, y: victim.y * scale.y }
|
||||
}
|
||||
|
||||
export function offsetPoint (victim: Point, offset: Point): Point {
|
||||
return { x: victim.x + offset.x, y: victim.y + offset.y }
|
||||
}
|
||||
|
||||
export function offsetCanvasPoint (
|
||||
victim: CanvasPoint,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
offsetScale: number
|
||||
): CanvasPoint {
|
||||
return makeCanvasPoint(Math.round(victim.x + offsetX * offsetScale), Math.round(victim.y + offsetY * offsetScale))
|
||||
}
|
||||
|
||||
export function middlePoint (first: Point, second: Point): Point {
|
||||
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }
|
||||
}
|
||||
|
||||
export function easeInOutCubic (x: number): number {
|
||||
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
|
||||
}
|
||||
|
||||
export function offsetInParent (parent: HTMLElement, child: HTMLElement): Point {
|
||||
function evaluateOffset (parentSize: number, childSize: number): number {
|
||||
return (parentSize - childSize) * 0.5
|
||||
}
|
||||
|
||||
const childWidth = child.getBoundingClientRect().width
|
||||
const parentWidth = parent.getBoundingClientRect().width
|
||||
const offsetX = evaluateOffset(parentWidth, childWidth)
|
||||
|
||||
const childHeight = child.getBoundingClientRect().height
|
||||
const parentHeight = parent.getBoundingClientRect().height
|
||||
const offsetY = evaluateOffset(parentHeight, childHeight)
|
||||
|
||||
return { x: offsetX, y: offsetY }
|
||||
}
|
||||
|
||||
export function rescaleToFitAspectRatio (
|
||||
referenceWidth: number,
|
||||
referenceHeight: number,
|
||||
actualWidth: number,
|
||||
actualHeight: number
|
||||
): Point {
|
||||
let scaleX = 1
|
||||
let scaleY = 1
|
||||
|
||||
const desiredAspectRatio = referenceWidth / referenceHeight
|
||||
const actualAspectRatio = actualWidth / actualHeight
|
||||
|
||||
if (desiredAspectRatio !== actualAspectRatio) {
|
||||
const candidateScaleX = desiredAspectRatio / actualAspectRatio
|
||||
const candidateScaleY = actualAspectRatio / desiredAspectRatio
|
||||
if (desiredAspectRatio > 1) {
|
||||
if (actualAspectRatio > 1) {
|
||||
if (candidateScaleX > candidateScaleY) {
|
||||
scaleY = candidateScaleY
|
||||
} else {
|
||||
scaleX = candidateScaleX
|
||||
}
|
||||
} else {
|
||||
scaleY = candidateScaleY
|
||||
}
|
||||
} else {
|
||||
if (actualAspectRatio < 1) {
|
||||
if (candidateScaleX > candidateScaleY) {
|
||||
scaleY = candidateScaleY
|
||||
} else {
|
||||
scaleX = candidateScaleX
|
||||
}
|
||||
} else {
|
||||
scaleX = candidateScaleX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { x: scaleX, y: scaleY }
|
||||
}
|
||||
@@ -72,5 +72,7 @@ export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
export * from './drawing'
|
||||
export * from './drawingUtils'
|
||||
export * from './drawingCommandsProcessor'
|
||||
export * from './link-preview'
|
||||
export * from './communication'
|
||||
|
||||
@@ -105,6 +105,7 @@ export default plugin(presentationId, {
|
||||
Save: '' as IntlString,
|
||||
Saved: '' as IntlString,
|
||||
Download: '' as IntlString,
|
||||
DownloadOriginal: '' as IntlString,
|
||||
Delete: '' as IntlString,
|
||||
Close: '' as IntlString,
|
||||
NotSelected: '' as IntlString,
|
||||
@@ -145,7 +146,16 @@ export default plugin(presentationId, {
|
||||
DocumentUrlCopied: '' as IntlString,
|
||||
CopyLink: '' as IntlString,
|
||||
UnableToFollowMention: '' as IntlString,
|
||||
AccessDenied: '' as IntlString
|
||||
AccessDenied: '' as IntlString,
|
||||
Undo: '' as IntlString,
|
||||
Redo: '' as IntlString,
|
||||
ClearCanvas: '' as IntlString,
|
||||
PenTool: '' as IntlString,
|
||||
EraserTool: '' as IntlString,
|
||||
PanTool: '' as IntlString,
|
||||
TextTool: '' as IntlString,
|
||||
ColorTooltip: '' as IntlString,
|
||||
PaletteManagementMenu: '' as IntlString
|
||||
},
|
||||
extension: {
|
||||
FilePreviewExtension: '' as ComponentExtensionId,
|
||||
|
||||
@@ -99,12 +99,12 @@
|
||||
|
||||
--theme-clockface-sec-arrow: conic-gradient(at 50% -10px, rgba(255, 0, 0, 0), rgba(255, 0, 0, 0) 49%, #F47758 50%, rgba(255, 0, 0, 0) 51%, rgba(255, 0, 0, 0) 100%);
|
||||
--theme-clockface-sec-holder: #F47758;
|
||||
|
||||
--drawing-bg-color: #EEEEEE;
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
.theme-dark {
|
||||
--theme-drawing-bg-color: #282834;
|
||||
|
||||
--theme-text-primary-color: rgba(255, 255, 255, .8);
|
||||
--theme-text-placeholder-color: rgba(255, 255, 255, .4);
|
||||
|
||||
@@ -409,6 +409,8 @@
|
||||
|
||||
/* Light Theme */
|
||||
.theme-light {
|
||||
--theme-drawing-bg-color: #EEEEEE;
|
||||
|
||||
--theme-text-primary-color: rgba(0, 0, 0, .8);
|
||||
--theme-text-placeholder-color: rgba(0, 0, 0, .4);
|
||||
|
||||
@@ -716,5 +718,5 @@
|
||||
|
||||
--theme-label-gray-color: rgba(0, 0, 0, .6);
|
||||
--theme-label-gray-bg-color: rgba(0, 0, 0, .02);
|
||||
--theme-label-gray-border-color: rgba(0, 0, 0, .09)
|
||||
--theme-label-gray-border-color: rgba(0, 0, 0, .09);
|
||||
}
|
||||
|
||||
@@ -357,6 +357,7 @@
|
||||
board = savedBoards[id]
|
||||
}
|
||||
return {
|
||||
document: board.ydoc,
|
||||
props: board.ydoc.getMap('props'),
|
||||
commands: board.ydoc.getArray<DrawingCmd>('commands'),
|
||||
loading: !board.localSynced || !board.remoteSynced
|
||||
|
||||
@@ -19,17 +19,19 @@
|
||||
DrawingCmd,
|
||||
DrawingTool,
|
||||
DrawTextCmd,
|
||||
Point,
|
||||
drawing,
|
||||
makeCommandId
|
||||
CommandUid,
|
||||
Point,
|
||||
DrawingCommandsProcessor
|
||||
} from '@hcengineering/presentation'
|
||||
import presence from '@hcengineering/presence'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import { Loading, Component } from '@hcengineering/ui'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { Array as YArray, Map as YMap } from 'yjs'
|
||||
import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
|
||||
|
||||
export let boardId: string
|
||||
export let document: YDoc
|
||||
export let savedCmds: YArray<DrawingCmd>
|
||||
export let savedProps: YMap<any>
|
||||
export let grabFocus = false
|
||||
@@ -45,9 +47,9 @@
|
||||
let penWidth: number
|
||||
let eraserWidth: number
|
||||
let fontSize: number
|
||||
let commands: DrawingCmd[] = []
|
||||
let model: DrawingCmd[] = []
|
||||
let offset: { x: number, y: number } = { x: 0, y: 0 }
|
||||
let changingCmdId: string | undefined
|
||||
let changingCmdId: CommandUid | undefined
|
||||
let cmdEditor: HTMLDivElement | undefined
|
||||
let personCursorCanvasPos: Point | undefined
|
||||
let personCursorNodePos: Point | undefined
|
||||
@@ -62,22 +64,32 @@
|
||||
const dataTopicOffset = 'drawing-board-offset'
|
||||
const dataTopicCursor = 'drawing-board-cursor'
|
||||
|
||||
let disableUndo = false
|
||||
let disableRedo = false
|
||||
|
||||
const commandsProcessor = new DrawingCommandsProcessor(document, savedCmds)
|
||||
|
||||
$: onSelectedChanged(selected)
|
||||
$: onReadonlyChanged(readonly)
|
||||
$: onOffsetChanged(offset)
|
||||
|
||||
function listenSavedCommands (): void {
|
||||
commands = savedCmds.toArray()
|
||||
function onSavedCommandsChanged (): void {
|
||||
model = commandsProcessor.snapshot()
|
||||
setTimeout(() => {
|
||||
const status = commandsProcessor.getUndoRedoAvailability()
|
||||
disableUndo = status.undoDisabled
|
||||
disableRedo = status.redoDisabled
|
||||
})
|
||||
}
|
||||
|
||||
function showCommandProps (id: string): void {
|
||||
function showCommandProps (id: CommandUid): void {
|
||||
changingCmdId = id
|
||||
for (const cmd of commands) {
|
||||
if (cmd.id === id) {
|
||||
if (cmd.type === 'text') {
|
||||
const textCmd = cmd as DrawTextCmd
|
||||
penColor = textCmd.color
|
||||
fontSize = textCmd.fontSize
|
||||
for (const command of model) {
|
||||
if (command.id === id) {
|
||||
if (command.type === 'text') {
|
||||
const textWriting = command as DrawTextCmd
|
||||
penColor = textWriting.color
|
||||
fontSize = textWriting.fontSize
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -85,61 +97,33 @@
|
||||
}
|
||||
|
||||
function changeCommand (cmd: DrawingCmd): void {
|
||||
let index = -1
|
||||
for (let i = 0; i < savedCmds.length; i++) {
|
||||
if (savedCmds.get(i).id === cmd.id) {
|
||||
savedCmds.delete(i)
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (index >= 0) {
|
||||
savedCmds.insert(index, [cmd])
|
||||
} else {
|
||||
savedCmds.push([cmd])
|
||||
}
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
commandsProcessor.changeCommand(cmd)
|
||||
}
|
||||
|
||||
function deleteCommand (id: string): void {
|
||||
for (let i = 0; i < savedCmds.length; i++) {
|
||||
if (savedCmds.get(i).id === id) {
|
||||
savedCmds.delete(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
function deleteCommand (id: CommandUid): void {
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
commandsProcessor.deleteCommand(id)
|
||||
}
|
||||
|
||||
function onSelectedChanged (selected: boolean): void {
|
||||
if (oldSelected !== selected) {
|
||||
if (oldSelected && !selected && changingCmdId !== undefined) {
|
||||
function onSelectedChanged (newSelected: boolean): void {
|
||||
if (oldSelected !== newSelected) {
|
||||
if (oldSelected && !newSelected && changingCmdId !== undefined) {
|
||||
changingCmdId = undefined
|
||||
cmdEditor = undefined
|
||||
}
|
||||
oldSelected = selected
|
||||
oldSelected = newSelected
|
||||
}
|
||||
}
|
||||
|
||||
function onReadonlyChanged (readonly: boolean): void {
|
||||
if (oldReadonly !== readonly) {
|
||||
if (!readonly) {
|
||||
let allHaveIds = true
|
||||
for (let i = 0; i < savedCmds.length; i++) {
|
||||
if (savedCmds.get(i).id === undefined) {
|
||||
allHaveIds = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!allHaveIds) {
|
||||
const cmds = savedCmds.toArray()
|
||||
savedCmds.delete(0, savedCmds.length)
|
||||
savedCmds.push(cmds.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() })))
|
||||
}
|
||||
function onReadonlyChanged (newReadonly: boolean): void {
|
||||
if (oldReadonly !== newReadonly) {
|
||||
if (!newReadonly) {
|
||||
commandsProcessor.ensureAllCommandsWithUids()
|
||||
}
|
||||
oldReadonly = readonly
|
||||
oldReadonly = newReadonly
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +162,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
commands = savedCmds.toArray()
|
||||
savedCmds.observe(listenSavedCommands)
|
||||
onSavedCommandsChanged()
|
||||
savedCmds.observe(onSavedCommandsChanged)
|
||||
|
||||
getResource(presence.function.PublishData)
|
||||
.then((func) => {
|
||||
@@ -206,7 +190,7 @@
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
savedCmds.unobserve(listenSavedCommands)
|
||||
savedCmds.unobserve(onSavedCommandsChanged)
|
||||
|
||||
getResource(presence.function.FolloweeDataUnsubscribe)
|
||||
.then((unsubscribe) => {
|
||||
@@ -241,7 +225,7 @@
|
||||
use:drawing={{
|
||||
readonly,
|
||||
autoSize: true,
|
||||
commands,
|
||||
commands: model,
|
||||
offset,
|
||||
tool,
|
||||
penColor,
|
||||
@@ -250,8 +234,8 @@
|
||||
fontSize,
|
||||
personCursorPos: personCursorCanvasPos,
|
||||
changingCmdId,
|
||||
cmdAdded: (cmd) => {
|
||||
savedCmds.push([cmd])
|
||||
cmdAdded: (commandToAdd) => {
|
||||
commandsProcessor.addCommand(commandToAdd)
|
||||
changingCmdId = undefined
|
||||
},
|
||||
cmdChanging: showCommandProps,
|
||||
@@ -288,6 +272,8 @@
|
||||
placeInside={true}
|
||||
showPanTool={true}
|
||||
{cmdEditor}
|
||||
{disableUndo}
|
||||
{disableRedo}
|
||||
bind:toolbar
|
||||
bind:tool
|
||||
bind:penColor
|
||||
@@ -295,9 +281,15 @@
|
||||
bind:eraserWidth
|
||||
bind:fontSize
|
||||
on:clear={() => {
|
||||
savedCmds.delete(0, savedCmds.length)
|
||||
commandsProcessor.clear()
|
||||
offset = { x: 0, y: 0 }
|
||||
}}
|
||||
on:undo={() => {
|
||||
commandsProcessor.undo()
|
||||
}}
|
||||
on:redo={() => {
|
||||
commandsProcessor.redo()
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<slot />
|
||||
@@ -328,7 +320,7 @@
|
||||
.board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: var(--drawing-bg-color);
|
||||
background-color: var(--theme-drawing-bg-color);
|
||||
border-radius: var(--small-BorderRadius);
|
||||
border: 1px solid var(--theme-navpanel-border);
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@
|
||||
<NodeViewWrapper data-drag-handle="" data-type="drawingBoard" data-id={node.attrs.id}>
|
||||
<DrawingBoardEditor
|
||||
boardId={node.attrs.id}
|
||||
document={savedBoard.document}
|
||||
savedCmds={savedBoard.commands}
|
||||
savedProps={savedBoard.props}
|
||||
resizeable={true}
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
import textEditor from '@hcengineering/text-editor'
|
||||
import { Dialog } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Array as YArray, Map as YMap } from 'yjs'
|
||||
import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
|
||||
import DrawingBoardEditor from './DrawingBoardEditor.svelte'
|
||||
|
||||
export let boardId: string
|
||||
export let document: YDoc
|
||||
export let savedCmds: YArray<DrawingCmd>
|
||||
export let savedProps: YMap<any>
|
||||
export let readonly = false
|
||||
@@ -43,6 +44,6 @@
|
||||
dispatch('close')
|
||||
}}
|
||||
>
|
||||
<DrawingBoardEditor {boardId} {savedCmds} {savedProps} {readonly} selected fullSize grabFocus />
|
||||
<DrawingBoardEditor {boardId} {document} {savedCmds} {savedProps} {readonly} selected fullSize grabFocus />
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { type DrawingCmd } from '@hcengineering/presentation'
|
||||
import { showPopup } from '@hcengineering/ui'
|
||||
import { type Editor, mergeAttributes, Node } from '@tiptap/core'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
import type { Array as YArray, Map as YMap } from 'yjs'
|
||||
import type { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
|
||||
import DrawingBoardNodeView from '../DrawingBoardNodeView.svelte'
|
||||
import DrawingBoardPopup from '../DrawingBoardPopup.svelte'
|
||||
import { SvelteNodeViewRenderer } from '../node-view'
|
||||
@@ -27,6 +27,7 @@ export interface DrawingBoardOptions {
|
||||
}
|
||||
|
||||
export interface SavedBoard {
|
||||
document: YDoc
|
||||
commands: YArray<DrawingCmd>
|
||||
props: YMap<any>
|
||||
loading: boolean
|
||||
@@ -38,6 +39,7 @@ export function showBoardPopup (id: string, board: SavedBoard, editor: Editor):
|
||||
DrawingBoardPopup,
|
||||
{
|
||||
boardId: id,
|
||||
document: board.document,
|
||||
savedCmds: board.commands,
|
||||
savedProps: board.props,
|
||||
readonly: !editor.isEditable
|
||||
|
||||
Reference in New Issue
Block a user