diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 797fe2eacb..af641de4d8 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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' diff --git a/desktop/src/__test__/ui/menuBuilder.test.ts b/desktop/src/__test__/ui/menuBuilder.test.ts index 165c7e53cb..48b1d43913 100644 --- a/desktop/src/__test__/ui/menuBuilder.test.ts +++ b/desktop/src/__test__/ui/menuBuilder.test.ts @@ -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('Ф'); - }); - }); -}); \ No newline at end of file + 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('Ф') + }) + }) +}) diff --git a/desktop/src/main/customMenu.ts b/desktop/src/main/customMenu.ts index b3c1c93917..915dea39ae 100644 --- a/desktop/src/main/customMenu.ts +++ b/desktop/src/main/customMenu.ts @@ -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 } -} \ No newline at end of file + 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) + } +} diff --git a/desktop/src/main/standardMenu.ts b/desktop/src/main/standardMenu.ts index 9ae500ee5e..2cbc0b7d10 100644 --- a/desktop/src/main/standardMenu.ts +++ b/desktop/src/main/standardMenu.ts @@ -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' diff --git a/desktop/src/main/start.ts b/desktop/src/main/start.ts index 35fa9fad4e..021f96f71f 100644 --- a/desktop/src/main/start.ts +++ b/desktop/src/main/start.ts @@ -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) { diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index e2e2f72a01..924e30777b 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -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 } } diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index d397af6c49..d5ea5f61af 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -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) diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 971dae1070..a2f75bfa96 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -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> } -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 executeMenuBarAction: (action: MenuBarAction) => void diff --git a/desktop/src/ui/typesUtils.ts b/desktop/src/ui/typesUtils.ts index ee7f569b2d..fb66c2deab 100644 --- a/desktop/src/ui/typesUtils.ts +++ b/desktop/src/ui/typesUtils.ts @@ -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) } diff --git a/packages/presentation/jest.config.js b/packages/presentation/jest.config.js index 3656e284d3..3b91601ef3 100644 --- a/packages/presentation/jest.config.js +++ b/packages/presentation/jest.config.js @@ -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'] + } + ] } diff --git a/packages/presentation/lang/cs.json b/packages/presentation/lang/cs.json index 699e019a74..5dc57a2062 100644 --- a/packages/presentation/lang/cs.json +++ b/packages/presentation/lang/cs.json @@ -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ý" diff --git a/packages/presentation/lang/de.json b/packages/presentation/lang/de.json index 05773d2a99..c0fd76c7b2 100644 --- a/packages/presentation/lang/de.json +++ b/packages/presentation/lang/de.json @@ -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ß" diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index a1be455588..738fdf57cd 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -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" diff --git a/packages/presentation/lang/es.json b/packages/presentation/lang/es.json index 6dc27401bb..679e18495c 100644 --- a/packages/presentation/lang/es.json +++ b/packages/presentation/lang/es.json @@ -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" diff --git a/packages/presentation/lang/fr.json b/packages/presentation/lang/fr.json index 4c9e697f54..976f9a5d8d 100644 --- a/packages/presentation/lang/fr.json +++ b/packages/presentation/lang/fr.json @@ -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" diff --git a/packages/presentation/lang/it.json b/packages/presentation/lang/it.json index aab152f5d5..15bb5e73bf 100644 --- a/packages/presentation/lang/it.json +++ b/packages/presentation/lang/it.json @@ -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" diff --git a/packages/presentation/lang/ja.json b/packages/presentation/lang/ja.json index 5b02be1784..361e580128 100644 --- a/packages/presentation/lang/ja.json +++ b/packages/presentation/lang/ja.json @@ -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": "ファイルサイズが大きすぎます" diff --git a/packages/presentation/lang/pt.json b/packages/presentation/lang/pt.json index d1467665d7..bfb38e7d47 100644 --- a/packages/presentation/lang/pt.json +++ b/packages/presentation/lang/pt.json @@ -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" diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index ec460477ec..37e7f385aa 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -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": "Файл слишком большой" diff --git a/packages/presentation/lang/zh.json b/packages/presentation/lang/zh.json index a379464b39..506c50efa3 100644 --- a/packages/presentation/lang/zh.json +++ b/packages/presentation/lang/zh.json @@ -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": "文件太大" diff --git a/packages/presentation/package.json b/packages/presentation/package.json index 8b3e0408aa..c336487228 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -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": { diff --git a/packages/presentation/src/___tests___/drawing.test.ts b/packages/presentation/src/___tests___/drawing.test.ts new file mode 100644 index 0000000000..7d77d0057a --- /dev/null +++ b/packages/presentation/src/___tests___/drawing.test.ts @@ -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 = {} + ): { 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[1]> = {} + ): { drawingBoard: ReturnType, 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 } + ]) + }) + }) + }) +}) diff --git a/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts b/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts new file mode 100644 index 0000000000..3b971ce96b --- /dev/null +++ b/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts @@ -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 => ({ + id: makeCommandUid(), + type: 'text', + text: 'Default Text', + pos: makeCanvasPoint(13, 17), + fontSize: 11, + fontFace: 'Arial', + color: 'red', + ...overrides +}) + +const makeLineCommand = (overrides: Partial = {}): 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 + let systemUnderTest: DrawingCommandsProcessor + + beforeEach(() => { + document = new YDoc() + commands = document.getArray('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]) + }) + }) +}) diff --git a/packages/presentation/src/___tests___/drawingUtils.test.ts b/packages/presentation/src/___tests___/drawingUtils.test.ts new file mode 100644 index 0000000000..a2daee8983 --- /dev/null +++ b/packages/presentation/src/___tests___/drawingUtils.test.ts @@ -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) + }) + }) +}) diff --git a/packages/presentation/src/components/DownloadFileButton.svelte b/packages/presentation/src/components/DownloadFileButton.svelte index 9275126a08..78ca4d85a2 100644 --- a/packages/presentation/src/components/DownloadFileButton.svelte +++ b/packages/presentation/src/components/DownloadFileButton.svelte @@ -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 | 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 }} /> {/if} diff --git a/packages/presentation/src/components/DrawingBoard.svelte b/packages/presentation/src/components/DrawingBoard.svelte index 36e468e112..3d8cc7cf42 100644 --- a/packages/presentation/src/components/DrawingBoard.svelte +++ b/packages/presentation/src/components/DrawingBoard.svelte @@ -15,16 +15,18 @@ -{#if active && commands !== undefined} +{#if active && model !== undefined}
{ - commands = [] + commandProcessor.clear() + modified = true + }} + on:undo={() => { + commandProcessor.undo() + modified = true + }} + on:redo={() => { + commandProcessor.redo() modified = true }} /> diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte index 18dfa2dc19..61b2d0c303 100644 --- a/packages/presentation/src/components/DrawingBoardToolbar.svelte +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -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() 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 & { 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 @@
+
diff --git a/packages/presentation/src/components/FilePreviewPopup.svelte b/packages/presentation/src/components/FilePreviewPopup.svelte index d14e0596df..478d3e7f2c 100644 --- a/packages/presentation/src/components/FilePreviewPopup.svelte +++ b/packages/presentation/src/components/FilePreviewPopup.svelte @@ -198,7 +198,11 @@ />
{/if} - + 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 = ` ` +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() } } diff --git a/packages/presentation/src/drawingCommandsProcessor.ts b/packages/presentation/src/drawingCommandsProcessor.ts new file mode 100644 index 0000000000..b02396cdba --- /dev/null +++ b/packages/presentation/src/drawingCommandsProcessor.ts @@ -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 + private readonly undoManager: YUndoManager + + private readonly UndoableOperationMarker = 137 + + constructor (document: YDoc, backend: YArray) { + 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) + } +} diff --git a/packages/presentation/src/drawingUtils.ts b/packages/presentation/src/drawingUtils.ts new file mode 100644 index 0000000000..f7904e5337 --- /dev/null +++ b/packages/presentation/src/drawingUtils.ts @@ -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 } +} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 87632357be..680d3d7544 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -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' diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index b4c76e328a..7719bb0c44 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -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, diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index 355ac0de1f..0e82a22463 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -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); } diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index fd460e2a9c..81b171a0ae 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -357,6 +357,7 @@ board = savedBoards[id] } return { + document: board.ydoc, props: board.ydoc.getMap('props'), commands: board.ydoc.getArray('commands'), loading: !board.localSynced || !board.remoteSynced diff --git a/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte b/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte index 68e984b656..77e4b2d325 100644 --- a/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte +++ b/plugins/text-editor-resources/src/components/DrawingBoardEditor.svelte @@ -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 export let savedProps: YMap 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} @@ -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); diff --git a/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte b/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte index 4bb5085bf3..c4adcb6a36 100644 --- a/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte +++ b/plugins/text-editor-resources/src/components/DrawingBoardNodeView.svelte @@ -137,6 +137,7 @@ export let savedProps: YMap export let readonly = false @@ -43,6 +44,6 @@ dispatch('close') }} > - + {/if} diff --git a/plugins/text-editor-resources/src/components/extension/drawingBoard.ts b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts index c44d1395e9..39fb867d9f 100644 --- a/plugins/text-editor-resources/src/components/extension/drawingBoard.ts +++ b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts @@ -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 props: YMap 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