Drawing board undo/redo and bug fixes (#9726)

* Undo-redo for screenshot drawer, several bugfixes.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Undo-redo for collaborative drawer.

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Drawing board refinement: bugfixes, component refactoring and unit testing

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* More tests, canvas resize fix, types for different points

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Version lock

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Names refactoring

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Formatting

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

* Formatting

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>

---------

Signed-off-by: Denis Gladkiy <denis.gladkiy@hardcoreeng.com>
This commit is contained in:
Denis 'GeneralGDA' Gladkiy
2025-08-28 11:54:27 +06:00
committed by GitHub
parent ef0db5597e
commit 84431f7c6b
39 changed files with 2627 additions and 792 deletions
+10 -8
View File
@@ -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'
+345 -345
View File
@@ -13,353 +13,353 @@
// limitations under the License.
//
import '@testing-library/jest-dom';
import '@testing-library/jest-dom'
import { MenuBuilder } from '../../ui/titleBarMenu';
import { MenuBarAction } from '../../ui/types';
import { MenuBuilder } from '../../ui/titleBarMenu'
import { MenuBarAction } from '../../ui/types'
describe('MenuBuilder', () => {
let systemUnderTest: MenuBuilder;
let systemUnderTest: MenuBuilder
beforeEach(() => {
systemUnderTest = new MenuBuilder()
document.body.innerHTML = ''
})
function verifyTopLevelMenu (menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string): void {
const menuButton = menuBar.children[topLevelMenu].children[0] as HTMLElement
expect(menuButton.dataset.menu).toBe(expectedMenuName)
expect(menuButton.dataset.accelerator).toBe(expectedAccelerator)
}
describe('addTopLevelMenu', () => {
test('add a top-level menu', () => {
systemUnderTest.addTopLevelMenu('File', 'f')
const builtMenu = systemUnderTest.build()
expect(builtMenu.children).toHaveLength(1)
verifyTopLevelMenu(builtMenu, 0, 'file', 'f')
})
test('method chaining', () => {
const result = systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e')
expect(result).toBe(systemUnderTest)
})
test('add multiple top-level menus', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e')
.addTopLevelMenu('View', 'v')
const builtMenu = systemUnderTest.build()
expect(builtMenu.children).toHaveLength(3)
verifyTopLevelMenu(builtMenu, 0, 'file', 'f')
verifyTopLevelMenu(builtMenu, 1, 'edit', 'e')
verifyTopLevelMenu(builtMenu, 2, 'view', 'v')
})
})
describe('addMenuItem', () => {
beforeEach(() => {
systemUnderTest = new MenuBuilder();
document.body.innerHTML = '';
});
function verifyTopLevelMenu(menuBar: HTMLElement, topLevelMenu: number, expectedMenuName: string, expectedAccelerator: string) {
const menuButton = menuBar.children[topLevelMenu].children[0] as HTMLElement;
expect(menuButton.dataset.menu).toBe(expectedMenuName);
expect(menuButton.dataset.accelerator).toBe(expectedAccelerator);
}
describe('addTopLevelMenu', () => {
test('add a top-level menu', () => {
systemUnderTest.addTopLevelMenu('File', 'f');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
verifyTopLevelMenu(builtMenu, 0, 'file', 'f');
});
test('method chaining', () => {
const result = systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e');
expect(result).toBe(systemUnderTest);
});
test('add multiple top-level menus', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addTopLevelMenu('Edit', 'e')
.addTopLevelMenu('View', 'v');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(3);
verifyTopLevelMenu(builtMenu, 0, 'file', 'f');
verifyTopLevelMenu(builtMenu, 1, 'edit', 'e');
verifyTopLevelMenu(builtMenu, 2, 'view', 'v');
});
});
describe('addMenuItem', () => {
beforeEach(() => {
systemUnderTest.addTopLevelMenu('File', 'f');
});
test('add a menu item to existing top-level menu', () => {
systemUnderTest.addMenuItem(0, 'New', 'redo', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(1);
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.action).toBe('redo');
});
test('add menu item with custom accelerator character', () => {
systemUnderTest.addMenuItem(0, 'Save As', 'select-all', 'Ctrl+Shift+S', 'a');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.accelerator).toBe('a');
});
test('default accelerator', () => {
systemUnderTest.addMenuItem(0, 'Open', 'undo', 'Ctrl+O');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const menuItem = dropdown.children[0] as HTMLElement;
expect(menuItem.dataset.accelerator).toBe('o');
});
test.each([[999], [-1],])("invalid top-level menu index", (input: number) => {
systemUnderTest.addMenuItem(input, 'Invalid', 'invalid' as MenuBarAction, 'Ctrl+I');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(0);
});
test('add multiple menu items to the same top-level menu', () => {
systemUnderTest
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addMenuItem(0, 'Save', 'copy', 'Ctrl+S');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(3);
});
});
describe('addSeparator', () => {
beforeEach(() => {
systemUnderTest.addTopLevelMenu('File', 'f');
});
test('add a separator', () => {
systemUnderTest.addSeparator(0);
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(1);
expect(dropdown.children[0]).toHaveClass('desktop-app-dropdown-separator');
});
test.each([[999], [-1],])("invalid top-level menu index", (input: number) => {
systemUnderTest.addSeparator(999);
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
expect(dropdown.children).toHaveLength(0);
});
});
describe('build', () => {
test('build empty menu bar', () => {
const builtMenu = systemUnderTest.build();
expect(builtMenu).toHaveClass('desktop-app-menu-bar');
expect(builtMenu.children).toHaveLength(0);
});
test('menu bar with complex structure of a single top-level menu', () => {
const topLevelMenu = 0;
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(topLevelMenu, 'New', 'paste', 'Ctrl+N')
.addSeparator(topLevelMenu)
.addMenuItem(topLevelMenu, 'Exit', 'exit', 'Alt+F4');
const builtMenu = systemUnderTest.build();
expect(builtMenu).toHaveClass('desktop-app-menu-bar');
expect(builtMenu.children).toHaveLength(1);
const topLevelMenuItem = builtMenu.children[0];
expect(topLevelMenuItem).toHaveClass('desktop-app-menu-item');
expect(topLevelMenuItem.children).toHaveLength(2);
const topButton = topLevelMenuItem.children[0];
expect(topButton).toHaveClass('desktop-app-top-menu-button');
const dropdown = topLevelMenuItem.children[1];
expect(dropdown).toHaveClass('desktop-app-dropdown-menu');
expect(dropdown.id).toBe('file-menu');
expect(dropdown.children).toHaveLength(3); // New, separator, Exit
});
test('accelerator at the beginning of label', () => {
systemUnderTest.addTopLevelMenu('File', 'f');
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan).toBeTruthy();
expect(acceleratorSpan?.textContent).toBe('F');
});
test('accelerator in the middle of label', () => {
systemUnderTest.addTopLevelMenu('Edit', 'i'); // 'i' is in the middle of 'Edit'
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('Edit');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan).toBeTruthy();
expect(acceleratorSpan?.textContent).toBe('i');
});
test('accelerator not found in label', () => {
systemUnderTest.addTopLevelMenu('File', 'z'); // 'z' is not in 'File'
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File');
expect(topButton.querySelector('.desktop-app-accelerator')).toBeNull();
});
test('dropdown items with shortcuts', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement;
expect(dropdownItem).toHaveClass('desktop-app-dropdown-item');
expect(dropdownItem.dataset.action).toBe('paste');
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut');
expect(shortcutSpan).toBeTruthy();
expect(shortcutSpan?.textContent).toBe('Ctrl+N');
});
test('without shortcuts when empty', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', '');
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0];
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut');
expect(shortcutSpan).toBeNull();
});
test('accelerator characters in dropdown items', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O', 'p');
const builtMenu = systemUnderTest.build();
const dropdown = builtMenu.children[0].children[1];
const newItem = dropdown.children[0] as HTMLElement;
const openItem = dropdown.children[1] as HTMLElement;
expect(newItem.dataset.accelerator).toBe('n');
expect(openItem.dataset.accelerator).toBe('p');
const newAccelerator = newItem.querySelector('.desktop-app-accelerator');
const openAccelerator = openItem.querySelector('.desktop-app-accelerator');
expect(newAccelerator?.textContent).toBe('N');
expect(openAccelerator?.textContent).toBe('p');
});
test('complex menu structure', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addSeparator(0)
.addMenuItem(0, 'Save', 'cut', 'Ctrl+S')
.addMenuItem(0, 'Save As', 'cut', 'Ctrl+Shift+S')
.addSeparator(0)
.addMenuItem(0, 'Exit', 'cut', 'Alt+F4')
.addTopLevelMenu('Edit', 'e')
.addMenuItem(1, 'Undo', 'cut', 'Ctrl+Z')
.addMenuItem(1, 'Redo', 'cut', 'Ctrl+Y');
const builtMenu = systemUnderTest.build();
// Should have 2 top-level menus
expect(builtMenu.children).toHaveLength(2);
// File menu should have 7 items (5 menu items + 2 separators)
const fileDropdown = builtMenu.children[0].children[1];
expect(fileDropdown.children).toHaveLength(7);
// Edit menu should have 2 items
const editDropdown = builtMenu.children[1].children[1];
expect(editDropdown.children).toHaveLength(2);
});
});
describe('edge cases', () => {
test('empty labels', () => {
systemUnderTest
.addTopLevelMenu('', 'f')
.addMenuItem(0, '', 'cut', 'shortcut');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
const dropdown = builtMenu.children[0].children[1];
expect(dropdown).toHaveClass('desktop-app-dropdown-menu');
expect(dropdown.children).toHaveLength(1);
});
test('special characters in labels', () => {
systemUnderTest
.addTopLevelMenu('File & Edit', 'f')
.addMenuItem(0, 'Save & Exit', 'cut', 'Ctrl+S');
const builtMenu = systemUnderTest.build();
expect(builtMenu.children).toHaveLength(1);
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('File & Edit');
});
test('case-insensitive accelerator matching', () => {
systemUnderTest
.addTopLevelMenu('File', 'F') // uppercase F
.addMenuItem(0, 'New', 'paste', 'Ctrl+N', 'N'); // uppercase N
const builtMenu = systemUnderTest.build();
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement;
expect(dropdownItem.dataset.accelerator).toBe('n');
const topButton = builtMenu.children[0].children[0] as HTMLElement;
expect(topButton.dataset.accelerator).toBe('f');
// Check that the actual character from the label is preserved
const topAccelerator = topButton.querySelector('.desktop-app-accelerator');
const itemAccelerator = dropdownItem.querySelector('.desktop-app-accelerator');
expect(topAccelerator?.textContent).toBe('F');
expect(itemAccelerator?.textContent).toBe('N');
});
test('unicode characters', () => {
systemUnderTest
.addTopLevelMenu('Файл', 'ф')
.addMenuItem(0, 'Новый', 'undo', 'Ctrl+N');
const builtMenu = systemUnderTest.build();
const topButton = builtMenu.children[0].children[0];
expect(topButton.textContent).toBe('Файл');
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator');
expect(acceleratorSpan?.textContent).toBe('Ф');
});
});
});
systemUnderTest.addTopLevelMenu('File', 'f')
})
test('add a menu item to existing top-level menu', () => {
systemUnderTest.addMenuItem(0, 'New', 'redo', 'Ctrl+N')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
expect(dropdown.children).toHaveLength(1)
const menuItem = dropdown.children[0] as HTMLElement
expect(menuItem.dataset.action).toBe('redo')
})
test('add menu item with custom accelerator character', () => {
systemUnderTest.addMenuItem(0, 'Save As', 'select-all', 'Ctrl+Shift+S', 'a')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
const menuItem = dropdown.children[0] as HTMLElement
expect(menuItem.dataset.accelerator).toBe('a')
})
test('default accelerator', () => {
systemUnderTest.addMenuItem(0, 'Open', 'undo', 'Ctrl+O')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
const menuItem = dropdown.children[0] as HTMLElement
expect(menuItem.dataset.accelerator).toBe('o')
})
test.each([[999], [-1]])('invalid top-level menu index', (input: number) => {
systemUnderTest.addMenuItem(input, 'Invalid', 'invalid' as MenuBarAction, 'Ctrl+I')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
expect(dropdown.children).toHaveLength(0)
})
test('add multiple menu items to the same top-level menu', () => {
systemUnderTest
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addMenuItem(0, 'Save', 'copy', 'Ctrl+S')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
expect(dropdown.children).toHaveLength(3)
})
})
describe('addSeparator', () => {
beforeEach(() => {
systemUnderTest.addTopLevelMenu('File', 'f')
})
test('add a separator', () => {
systemUnderTest.addSeparator(0)
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
expect(dropdown.children).toHaveLength(1)
expect(dropdown.children[0]).toHaveClass('desktop-app-dropdown-separator')
})
test.each([[999], [-1]])('invalid top-level menu index', (input: number) => {
systemUnderTest.addSeparator(999)
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
expect(dropdown.children).toHaveLength(0)
})
})
describe('build', () => {
test('build empty menu bar', () => {
const builtMenu = systemUnderTest.build()
expect(builtMenu).toHaveClass('desktop-app-menu-bar')
expect(builtMenu.children).toHaveLength(0)
})
test('menu bar with complex structure of a single top-level menu', () => {
const topLevelMenu = 0
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(topLevelMenu, 'New', 'paste', 'Ctrl+N')
.addSeparator(topLevelMenu)
.addMenuItem(topLevelMenu, 'Exit', 'exit', 'Alt+F4')
const builtMenu = systemUnderTest.build()
expect(builtMenu).toHaveClass('desktop-app-menu-bar')
expect(builtMenu.children).toHaveLength(1)
const topLevelMenuItem = builtMenu.children[0]
expect(topLevelMenuItem).toHaveClass('desktop-app-menu-item')
expect(topLevelMenuItem.children).toHaveLength(2)
const topButton = topLevelMenuItem.children[0]
expect(topButton).toHaveClass('desktop-app-top-menu-button')
const dropdown = topLevelMenuItem.children[1]
expect(dropdown).toHaveClass('desktop-app-dropdown-menu')
expect(dropdown.id).toBe('file-menu')
expect(dropdown.children).toHaveLength(3) // New, separator, Exit
})
test('accelerator at the beginning of label', () => {
systemUnderTest.addTopLevelMenu('File', 'f')
const builtMenu = systemUnderTest.build()
const topButton = builtMenu.children[0].children[0]
expect(topButton.textContent).toBe('File')
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
expect(acceleratorSpan).toBeTruthy()
expect(acceleratorSpan?.textContent).toBe('F')
})
test('accelerator in the middle of label', () => {
systemUnderTest.addTopLevelMenu('Edit', 'i') // 'i' is in the middle of 'Edit'
const builtMenu = systemUnderTest.build()
const topButton = builtMenu.children[0].children[0]
expect(topButton.textContent).toBe('Edit')
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
expect(acceleratorSpan).toBeTruthy()
expect(acceleratorSpan?.textContent).toBe('i')
})
test('accelerator not found in label', () => {
systemUnderTest.addTopLevelMenu('File', 'z') // 'z' is not in 'File'
const builtMenu = systemUnderTest.build()
const topButton = builtMenu.children[0].children[0]
expect(topButton.textContent).toBe('File')
expect(topButton.querySelector('.desktop-app-accelerator')).toBeNull()
})
test('dropdown items with shortcuts', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
const builtMenu = systemUnderTest.build()
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement
expect(dropdownItem).toHaveClass('desktop-app-dropdown-item')
expect(dropdownItem.dataset.action).toBe('paste')
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut')
expect(shortcutSpan).toBeTruthy()
expect(shortcutSpan?.textContent).toBe('Ctrl+N')
})
test('without shortcuts when empty', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', '')
const builtMenu = systemUnderTest.build()
const dropdownItem = builtMenu.children[0].children[1].children[0]
const shortcutSpan = dropdownItem.querySelector('.desktop-app-shortcut')
expect(shortcutSpan).toBeNull()
})
test('accelerator characters in dropdown items', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O', 'p')
const builtMenu = systemUnderTest.build()
const dropdown = builtMenu.children[0].children[1]
const newItem = dropdown.children[0] as HTMLElement
const openItem = dropdown.children[1] as HTMLElement
expect(newItem.dataset.accelerator).toBe('n')
expect(openItem.dataset.accelerator).toBe('p')
const newAccelerator = newItem.querySelector('.desktop-app-accelerator')
const openAccelerator = openItem.querySelector('.desktop-app-accelerator')
expect(newAccelerator?.textContent).toBe('N')
expect(openAccelerator?.textContent).toBe('p')
})
test('complex menu structure', () => {
systemUnderTest
.addTopLevelMenu('File', 'f')
.addMenuItem(0, 'New', 'paste', 'Ctrl+N')
.addMenuItem(0, 'Open', 'cut', 'Ctrl+O')
.addSeparator(0)
.addMenuItem(0, 'Save', 'cut', 'Ctrl+S')
.addMenuItem(0, 'Save As', 'cut', 'Ctrl+Shift+S')
.addSeparator(0)
.addMenuItem(0, 'Exit', 'cut', 'Alt+F4')
.addTopLevelMenu('Edit', 'e')
.addMenuItem(1, 'Undo', 'cut', 'Ctrl+Z')
.addMenuItem(1, 'Redo', 'cut', 'Ctrl+Y')
const builtMenu = systemUnderTest.build()
// Should have 2 top-level menus
expect(builtMenu.children).toHaveLength(2)
// File menu should have 7 items (5 menu items + 2 separators)
const fileDropdown = builtMenu.children[0].children[1]
expect(fileDropdown.children).toHaveLength(7)
// Edit menu should have 2 items
const editDropdown = builtMenu.children[1].children[1]
expect(editDropdown.children).toHaveLength(2)
})
})
describe('edge cases', () => {
test('empty labels', () => {
systemUnderTest
.addTopLevelMenu('', 'f')
.addMenuItem(0, '', 'cut', 'shortcut')
const builtMenu = systemUnderTest.build()
expect(builtMenu.children).toHaveLength(1)
const dropdown = builtMenu.children[0].children[1]
expect(dropdown).toHaveClass('desktop-app-dropdown-menu')
expect(dropdown.children).toHaveLength(1)
})
test('special characters in labels', () => {
systemUnderTest
.addTopLevelMenu('File & Edit', 'f')
.addMenuItem(0, 'Save & Exit', 'cut', 'Ctrl+S')
const builtMenu = systemUnderTest.build()
expect(builtMenu.children).toHaveLength(1)
const topButton = builtMenu.children[0].children[0]
expect(topButton.textContent).toBe('File & Edit')
})
test('case-insensitive accelerator matching', () => {
systemUnderTest
.addTopLevelMenu('File', 'F') // uppercase F
.addMenuItem(0, 'New', 'paste', 'Ctrl+N', 'N') // uppercase N
const builtMenu = systemUnderTest.build()
const dropdownItem = builtMenu.children[0].children[1].children[0] as HTMLElement
expect(dropdownItem.dataset.accelerator).toBe('n')
const topButton = builtMenu.children[0].children[0] as HTMLElement
expect(topButton.dataset.accelerator).toBe('f')
// Check that the actual character from the label is preserved
const topAccelerator = topButton.querySelector('.desktop-app-accelerator')
const itemAccelerator = dropdownItem.querySelector('.desktop-app-accelerator')
expect(topAccelerator?.textContent).toBe('F')
expect(itemAccelerator?.textContent).toBe('N')
})
test('unicode characters', () => {
systemUnderTest
.addTopLevelMenu('Файл', 'ф')
.addMenuItem(0, 'Новый', 'undo', 'Ctrl+N')
const builtMenu = systemUnderTest.build()
const topButton = builtMenu.children[0].children[0]
expect(topButton.textContent).toBe('Файл')
const acceleratorSpan = topButton.querySelector('.desktop-app-accelerator')
expect(acceleratorSpan?.textContent).toBe('Ф')
})
})
})
+72 -72
View File
@@ -16,77 +16,77 @@
import { app, BrowserWindow } from 'electron'
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types'
export function dipatchMenuBarAction(mainWindow: BrowserWindow | undefined, action: MenuBarAction) {
if (mainWindow == null) {
return
}
function performZoom(increment: number): void {
if (mainWindow == null) {
return
}
const currentZoom = mainWindow.webContents.getZoomFactor();
mainWindow.webContents.setZoomFactor(currentZoom + increment);
}
export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction): void {
if (mainWindow == null) {
return
}
const zoomStep = 0.1;
switch (action) {
case 'settings':
mainWindow.webContents.send(CommandOpenSettings)
break;
case 'select-workspace':
mainWindow.webContents.send(CommandSelectWorkspace)
break;
case 'logout':
mainWindow.webContents.send(CommandLogout)
break;
case 'exit':
app.quit();
break;
case 'undo':
mainWindow.webContents.undo();
break;
case 'redo':
mainWindow.webContents.redo();
break;
case 'cut':
mainWindow.webContents.cut();
break;
case 'copy':
mainWindow.webContents.copy();
break;
case 'paste':
mainWindow.webContents.paste();
break;
case 'delete':
mainWindow.webContents.delete();
break;
case 'select-all':
mainWindow.webContents.selectAll();
break;
case 'reload':
mainWindow?.reload();
break;
case 'force-reload':
mainWindow.webContents.reloadIgnoringCache();
break;
case 'toggle-devtools':
mainWindow.webContents.toggleDevTools();
break;
case 'zoom-in':
performZoom(+zoomStep);
break;
case 'zoom-out':
performZoom(-zoomStep);
break;
case 'restore-size':
mainWindow.webContents.setZoomFactor(1.0);
break;
case 'toggle-fullscreen':
mainWindow.setFullScreen(!mainWindow.isFullScreen());
break;
default:
console.log('unknown menu action:', action);
function performZoom (increment: number): void {
if (mainWindow == null) {
return
}
}
const currentZoom = mainWindow.webContents.getZoomFactor()
mainWindow.webContents.setZoomFactor(currentZoom + increment)
}
const zoomStep = 0.1
switch (action) {
case 'settings':
mainWindow.webContents.send(CommandOpenSettings)
break
case 'select-workspace':
mainWindow.webContents.send(CommandSelectWorkspace)
break
case 'logout':
mainWindow.webContents.send(CommandLogout)
break
case 'exit':
app.quit()
break
case 'undo':
mainWindow.webContents.undo()
break
case 'redo':
mainWindow.webContents.redo()
break
case 'cut':
mainWindow.webContents.cut()
break
case 'copy':
mainWindow.webContents.copy()
break
case 'paste':
mainWindow.webContents.paste()
break
case 'delete':
mainWindow.webContents.delete()
break
case 'select-all':
mainWindow.webContents.selectAll()
break
case 'reload':
mainWindow?.reload()
break
case 'force-reload':
mainWindow.webContents.reloadIgnoringCache()
break
case 'toggle-devtools':
mainWindow.webContents.toggleDevTools()
break
case 'zoom-in':
performZoom(+zoomStep)
break
case 'zoom-out':
performZoom(-zoomStep)
break
case 'restore-size':
mainWindow.webContents.setZoomFactor(1.0)
break
case 'toggle-fullscreen':
mainWindow.setFullScreen(!mainWindow.isFullScreen())
break
default:
console.log('unknown menu action:', action)
}
}
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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) {
+9 -7
View File
@@ -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
}
}
+4 -4
View File
@@ -65,11 +65,11 @@ const expose: IPCMainExposed = {
minimizeWindow: () => {
ipcRenderer.invoke('window-minimize')
},
maximizeWindow: () => {
ipcRenderer.invoke('window-maximize')
},
closeWindow: () => {
ipcRenderer.invoke('window-close')
},
@@ -82,7 +82,7 @@ const expose: IPCMainExposed = {
ipcRenderer.on('window-focus-loss', callback)
},
isOsUsingDarkTheme: async () => {
isOsUsingDarkTheme: async () => {
return await ipcRenderer.invoke('get-is-os-using-dark-theme')
},
@@ -181,6 +181,6 @@ const expose: IPCMainExposed = {
cancelBackup: () => { ipcRenderer.send('cancel-backup') },
startBackup: (token, endpoint, wsIds) => { ipcRenderer.send('start-backup', token, endpoint, wsIds) },
rebuildJumpList: (spares: JumpListSpares) => ipcRenderer.send('rebuild-user-jump-list', spares)
rebuildJumpList: (spares: JumpListSpares) => { ipcRenderer.send('rebuild-user-jump-list', spares) }
}
contextBridge.exposeInMainWorld('electron', expose)
+13 -13
View File
@@ -83,12 +83,12 @@ export const CommandSelectWorkspace = 'select-workspace' as const
export const CommandLogout = 'logout' as const
export const CommandOpenApplication = 'open-application' as const
export type Command =
typeof CommandOpenSettings |
typeof CommandOpenInbox |
typeof CommandOpenOffice |
typeof CommandOpenPlanner |
typeof CommandSelectWorkspace |
export type Command =
typeof CommandOpenSettings |
typeof CommandOpenInbox |
typeof CommandOpenOffice |
typeof CommandOpenPlanner |
typeof CommandSelectWorkspace |
typeof CommandLogout |
typeof CommandOpenApplication
@@ -102,15 +102,15 @@ export interface NotificationParams {
objectClass?: Ref<Class<Doc>>
}
export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools'
, 'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const;
export const MenuBarActions = ['settings', 'select-workspace', 'logout', 'exit', 'undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'reload', 'force-reload', 'toggle-devtools',
'zoom-in', 'zoom-out', 'restore-size', 'toggle-fullscreen'] as const
export type MenuBarAction = typeof MenuBarActions[number];
export type MenuBarAction = typeof MenuBarActions[number]
export interface JumpListSpares {
applications: LaunchApplication[],
settingsLabel: string,
inboxLabel: string,
applications: LaunchApplication[]
settingsLabel: string
inboxLabel: string
}
export interface LaunchApplication {
@@ -144,7 +144,7 @@ export interface IPCMainExposed {
closeWindow: () => void
onWindowStateChange: (callback: (event: IpcRendererEvent, newState: string) => void) => void
onWindowFocusLoss: (callback: () => void) => void
isOsUsingDarkTheme: () => Promise<boolean>
executeMenuBarAction: (action: MenuBarAction) => void
+3 -3
View File
@@ -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)
}
+17 -3
View File
@@ -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']
}
]
}
+11 -1
View File
@@ -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ý"
+11 -1
View File
@@ -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ß"
+11 -1
View File
@@ -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"
+11 -1
View File
@@ -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"
+11 -1
View File
@@ -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"
+11 -1
View File
@@ -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"
+11 -1
View File
@@ -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": "ファイルサイズが大きすぎます"
+11 -1
View File
@@ -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"
+11 -1
View File
@@ -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": "Файл слишком большой"
+11 -1
View File
@@ -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": "文件太大"
+3 -1
View File
@@ -35,6 +35,7 @@
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"@testing-library/jest-dom": "^6.6.3",
"svelte-eslint-parser": "^0.33.1",
"@types/png-chunks-extract": "^1.0.2",
"@types/uuid": "^8.3.1"
@@ -61,7 +62,8 @@
"fast-equals": "^5.2.2",
"png-chunks-extract": "^1.0.0",
"svelte": "^4.2.19",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"yjs": "^13.6.23"
},
"repository": "https://github.com/hcengineering/platform",
"publishConfig": {
@@ -0,0 +1,578 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import '@testing-library/jest-dom'
import { makeCommandUid, drawing } from '../drawing'
import type { DrawTextCmd, CommandUid, DrawingTool, DrawingProps, DrawingCmd } from '../drawing'
import { makeCanvasPoint } from '../drawingUtils'
const fakeCanvasContext = {
clearRect: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fill: jest.fn(),
strokeText: jest.fn(),
fillText: jest.fn(),
measureText: jest.fn(() => ({ width: 50 })),
save: jest.fn(),
restore: jest.fn(),
reset: jest.fn(),
scale: jest.fn(),
translate: jest.fn(),
rotate: jest.fn(),
setTransform: jest.fn(),
transform: jest.fn(),
createLinearGradient: jest.fn(),
createRadialGradient: jest.fn(),
createPattern: jest.fn(),
arc: jest.fn(),
arcTo: jest.fn(),
bezierCurveTo: jest.fn(),
quadraticCurveTo: jest.fn(),
closePath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
isPointInPath: jest.fn(),
drawImage: jest.fn(),
putImageData: jest.fn(),
getImageData: jest.fn(),
createImageData: jest.fn(),
globalAlpha: 1,
globalCompositeOperation: 'source-over',
strokeStyle: '#000000',
fillStyle: '#000000',
lineWidth: 1,
lineCap: 'butt',
lineJoin: 'miter',
miterLimit: 10,
lineDashOffset: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
shadowBlur: 0,
shadowColor: 'rgba(0, 0, 0, 0)',
font: '10px sans-serif',
textAlign: 'start',
textBaseline: 'alphabetic',
direction: 'inherit'
}
describe('drawing module tests', () => {
describe('utilities tests', () => {
describe('makeCommandUid', () => {
it('id length', () => {
const uid = makeCommandUid()
expect(uid.length).toBeGreaterThan(0)
})
it('values uniqueness', () => {
const first = makeCommandUid()
const second = makeCommandUid()
expect(first).not.toBe(second)
})
})
})
describe('drawing factory function tests', () => {
function prepareFakeCanvas (): void {
HTMLCanvasElement.prototype.getContext = jest.fn(() => fakeCanvasContext) as any
;(globalThis as any).ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn()
}))
}
let drawingPlugInPoint: HTMLElement
beforeEach(() => {
prepareFakeCanvas()
jest.useFakeTimers()
drawingPlugInPoint = document.createElement('div')
document.body.appendChild(drawingPlugInPoint)
jest.clearAllMocks()
})
afterEach(() => {
jest.useRealTimers()
if (drawingPlugInPoint.parentNode != null) {
drawingPlugInPoint.parentNode.removeChild(drawingPlugInPoint)
}
})
it('create a drawing board', () => {
const drawingBoard = drawing(drawingPlugInPoint, {
readonly: false,
imageWidth: 40,
imageHeight: 40,
commands: []
})
expect(drawingBoard).toBeDefined()
})
describe('text editing', () => {
const DefaultPenColor: string = 'red'
const DefaultTool: DrawingTool = 'pen'
const EmptyCommandUid = '' as CommandUid
const DefaultDrawingBoardWidth = 200
const DefaultDrawingBoardHeight = 200
const createTextCommandStub = (
overrides: Partial<DrawTextCmd> = {}
): { textCommandUid: CommandUid, textCommand: DrawTextCmd } => {
const textCommandUid = makeCommandUid()
const textCommand: DrawTextCmd = {
id: textCommandUid,
type: 'text',
text: 'hello',
pos: makeCanvasPoint(10, 10),
fontSize: 12,
fontFace: '"IBM Plex Sans"',
color: 'green',
...overrides
}
return { textCommandUid, textCommand }
}
const createDrawingBoard = (
existingTextCommand: DrawTextCmd | undefined,
overrides: Partial<Parameters<typeof drawing>[1]> = {}
): { drawingBoard: ReturnType<typeof drawing>, initialState: DrawingProps } => {
const commands = existingTextCommand === undefined ? [] : [existingTextCommand]
const initialState = {
readonly: false,
imageWidth: DefaultDrawingBoardWidth,
imageHeight: DefaultDrawingBoardHeight,
drawing: commands,
penColor: DefaultPenColor,
tool: DefaultTool,
...overrides
}
return { drawingBoard: drawing(drawingPlugInPoint, initialState), initialState }
}
const findContentEditableElement = (container: Element): Element | null => {
const allDivs = container.querySelectorAll('div')
for (let i = 0; i < allDivs.length; i++) {
const div = allDivs[i] as HTMLElement
if (div.contentEditable === 'true') {
return div
}
}
return null
}
const isLiveTextEditorPresent = (container: Element): boolean => {
return findContentEditableElement(container) !== null
}
const setTextEditorText = (container: Element, text: string): void => {
const editor = findContentEditableElement(container)
if (editor != null) {
const editorElement = editor as HTMLElement
editorElement.innerText = text
}
}
const getTextEditorColor = (container: Element): string | null => {
const editor = findContentEditableElement(container)
if (editor != null) {
const editorElement = editor as HTMLElement
const color = editorElement.style.color
return color !== '' ? color : null
}
return null
}
const sendCtrlEnterToTextEditor = (container: Element): void => {
const editor = findContentEditableElement(container)
if (editor != null) {
const event = new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
bubbles: true,
cancelable: true
})
editor.dispatchEvent(event)
}
}
it('changing color for text editor', () => {
const commandAddedSpy = jest.fn()
const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, { cmdAdded: commandAddedSpy })
{
const colorToSet = 'blue'
// simulating: user selected text tool
drawingBoard.update?.({ ...boardState, tool: 'text' })
// simulating: user selected a color
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet })
// simulating: user clicked somewhere on the board
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid })
expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet)
}
// change color to something else
{
const colorToSet = 'blue'
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid })
expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet)
}
const lastSetColor = 'red'
drawingBoard.update?.({ ...boardState, tool: 'text', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
expect(getTextEditorColor(drawingPlugInPoint)).toBe(lastSetColor)
const setTextContent = 'New Text In The Box'
setTextEditorText(drawingPlugInPoint, setTextContent)
// new tool selection - editor should have been closed
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: lastSetColor, changingCmdId: EmptyCommandUid })
// changing color for the pen
drawingBoard.update?.({ ...boardState, tool: 'pen', penColor: 'magenta', changingCmdId: undefined })
setTextEditorText(drawingPlugInPoint, setTextContent + setTextContent)
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
jest.runOnlyPendingTimers()
// created text command should have correct color and text
const storedTextDrawCommand = commandAddedSpy.mock.calls[0][0] as DrawTextCmd | undefined
expect(storedTextDrawCommand).toBeDefined()
expect(storedTextDrawCommand?.color).toBe(lastSetColor)
expect(storedTextDrawCommand?.text).toBe(setTextContent)
})
it('commit text editing by changing tool', () => {
const commandChangedSpy = jest.fn()
const commandAddedSpy = jest.fn()
const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, {
cmdChanged: commandChangedSpy,
cmdAdded: commandAddedSpy
})
// simulating: user selected text tool
drawingBoard.update?.({ ...boardState, tool: 'text' })
// simulating: user clicked somewhere on the board, starting new text editing
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: EmptyCommandUid })
// editor should be present in the DOM
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
expect(getTextEditorColor(drawingPlugInPoint)).toBe(boardState.penColor)
const setTextContent = 'New Text In The Box'
setTextEditorText(drawingPlugInPoint, setTextContent)
// changing tool
drawingBoard.update?.({ ...boardState, tool: 'pen', changingCmdId: EmptyCommandUid })
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
jest.runOnlyPendingTimers()
// editor should be removed
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
expect(getTextEditorColor(drawingPlugInPoint)).toBeNull()
// commandAdded should have been called once
expect(commandChangedSpy).toHaveBeenCalledTimes(0)
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
// new command should have passed to the delegate
const newlyCreatedTextDrawCommand = commandAddedSpy.mock.calls[0][0] as DrawingCmd | undefined
expect(newlyCreatedTextDrawCommand).toBeDefined()
expect(newlyCreatedTextDrawCommand?.type).toBe('text')
// further actions should not create additional commandChanged / commandAdded calls
drawingBoard.update?.({ ...boardState, tool: 'erase', changingCmdId: undefined })
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: undefined })
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: EmptyCommandUid })
jest.runOnlyPendingTimers()
expect(commandChangedSpy).toHaveBeenCalledTimes(0)
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
})
it('editing existing text', () => {
const commandChangedSpy = jest.fn()
const commandAddedSpy = jest.fn()
const { textCommandUid, textCommand } = createTextCommandStub()
const { drawingBoard, initialState: boardState } = createDrawingBoard(textCommand, {
cmdChanged: commandChangedSpy,
cmdAdded: commandAddedSpy
})
// simulating: user selected text tool
drawingBoard.update?.({ ...boardState, tool: 'text' })
// simulating: user clicked on the existing text
drawingBoard.update?.({
...boardState,
tool: 'text',
changingCmdId: textCommandUid,
penColor: textCommand.color
})
// editor should be present in the DOM
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
expect(getTextEditorColor(drawingPlugInPoint)).toBe(textCommand.color)
const newText = 'New Text'
setTextEditorText(drawingPlugInPoint, newText)
const newColor = 'yellow'
drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: textCommandUid, penColor: newColor })
// commit text editing
sendCtrlEnterToTextEditor(drawingPlugInPoint)
// The storeTextCommand uses a deferred notify (setTimeout). Flush timers to execute it.
jest.runOnlyPendingTimers()
// editor should be removed
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
expect(getTextEditorColor(drawingPlugInPoint)).toBeNull()
// commandChangedSpy should have been called once
expect(commandChangedSpy).toHaveBeenCalledTimes(1)
expect(commandAddedSpy).toHaveBeenCalledTimes(0)
// new command should have passed to the delegate
const changedCommand = commandChangedSpy.mock.calls[0][0] as DrawTextCmd | undefined
expect(changedCommand).toBeDefined()
expect(changedCommand?.type).toBe('text')
expect(changedCommand?.text).toBe(newText)
expect(changedCommand?.color).toBe(newColor)
})
it('text editor closing with tool change', () => {
const { drawingBoard, initialState } = createDrawingBoard(undefined, {})
const textCmdId = makeCommandUid()
drawingBoard.update?.({ ...initialState, tool: 'text' })
drawingBoard.update?.({ ...initialState, tool: 'text', changingCmdId: textCmdId })
jest.runOnlyPendingTimers()
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(true)
drawingBoard.update?.({ ...initialState, tool: 'pen', changingCmdId: textCmdId })
jest.runOnlyPendingTimers()
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
drawingBoard.update?.({ ...initialState, tool: 'erase', changingCmdId: undefined })
jest.runOnlyPendingTimers()
expect(isLiveTextEditorPresent(drawingPlugInPoint)).toBe(false)
})
})
describe('canvas rescaling via ResizeObserver', () => {
const resizeObserverSpyType = jest.fn()
let resizeObserverSpy: any
const DefaultPointerEventOffsetX = 13
const DefaultPointerEventOffsetY = 17
function flushRequestAnimationFrameCallbacks (): void {
jest.runOnlyPendingTimers()
}
const makePointerEvent = (type: string, offsetX: number, offsetY: number): PointerEvent => {
const event = new PointerEvent(type, { pointerId: 1, button: 0, bubbles: true, cancelable: true })
Object.defineProperty(event, 'offsetX', { value: offsetX, writable: false })
Object.defineProperty(event, 'offsetY', { value: offsetY, writable: false })
return event
}
function expectSingleLineCommand (
commandAddedSpy: jest.Mock,
expectedPoints: Array<{ x: number, y: number }>
): void {
expect(commandAddedSpy).toHaveBeenCalledTimes(1)
const actualAddedCommand = commandAddedSpy.mock.calls[0][0]
expect(actualAddedCommand).toBeDefined()
expect(actualAddedCommand.type).toBe('line')
expect(actualAddedCommand.points).toBeDefined()
expect(actualAddedCommand.points.length).toBe(expectedPoints.length)
expectedPoints.forEach((expectedPoint, index) => {
expect(actualAddedCommand.points[index]).toStrictEqual(expectedPoint)
})
}
beforeEach(() => {
resizeObserverSpy = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn()
}
resizeObserverSpyType.mockImplementation(() => resizeObserverSpy)
;(globalThis as any).ResizeObserver = resizeObserverSpyType
if (typeof PointerEvent === 'undefined') {
;(globalThis as any).PointerEvent = class MockPointerEvent extends Event {
pointerId: number
button: number
offsetX: number
offsetY: number
constructor (type: string, options: any = {}) {
super(type, options)
this.pointerId = options.pointerId ?? 1
this.button = options.button ?? 0
this.offsetX = DefaultPointerEventOffsetX
this.offsetY = DefaultPointerEventOffsetY
}
}
}
})
it('basic pointer move', () => {
const pointerMovedSpy = jest.fn()
drawing(drawingPlugInPoint, {
readonly: false,
imageWidth: 400,
imageHeight: 300,
commands: [],
tool: 'pen',
pointerMoved: pointerMovedSpy
})
const canvas = drawingPlugInPoint.querySelector('canvas') as HTMLCanvasElement
canvas.setPointerCapture = jest.fn()
canvas.releasePointerCapture = jest.fn()
expect(canvas.onpointermove).toBeDefined()
expect(typeof canvas.onpointermove).toBe('function')
const expectedPointerX = 50
const expectedPointerY = 40
const dummyPointerMoveEvent = makePointerEvent('pointermove', expectedPointerX, expectedPointerY)
canvas.onpointermove?.(dummyPointerMoveEvent)
expect(pointerMovedSpy).toHaveBeenCalledTimes(1)
const actualPosition = pointerMovedSpy.mock.calls[0][0]
expect(actualPosition).toStrictEqual({ x: expectedPointerX, y: expectedPointerY })
})
it('canvas resize followed by pen drawing', () => {
const commandAddedSpy = jest.fn()
const pointerMovedSpy = jest.fn()
const backgroundImageWidth = 400
const backgroundImageHeight = 300
drawing(drawingPlugInPoint, {
readonly: false,
autoSize: false,
imageWidth: backgroundImageWidth,
imageHeight: backgroundImageHeight,
commands: [],
tool: 'pen',
penColor: 'red',
cmdAdded: commandAddedSpy,
pointerMoved: pointerMovedSpy
})
// ResizeObserver was created and canvas is being observed
expect(resizeObserverSpyType).toHaveBeenCalledTimes(1)
const actualResizeObserverCallback = resizeObserverSpyType.mock.calls[0][0]
const canvas = drawingPlugInPoint.querySelector('canvas') as HTMLCanvasElement
canvas.setPointerCapture = jest.fn()
canvas.releasePointerCapture = jest.fn()
expect(resizeObserverSpy.observe).toHaveBeenCalledTimes(1)
expect(resizeObserverSpy.observe.mock.calls[0][0]).toBe(canvas)
// trigger the resize observer callback
actualResizeObserverCallback([
{
target: canvas,
contentRect: { width: 100, height: 150 }
}
])
// verify that CSS transform has been applied (should scale to fit aspect ratio)
expect(canvas.style.transform).toContain('scale(1, 0.5)')
jest.clearAllMocks()
const drawStartPoint = { x: 50, y: 40 }
const drawContinuePoint = { x: drawStartPoint.x + 25, y: drawStartPoint.y + 25 }
const drawEndPoint = { x: drawContinuePoint.x + 25, y: drawContinuePoint.y + 25 }
// simulate pointerdown (drawStart) by calling the handler directly
canvas.onpointerdown?.(makePointerEvent('pointerdown', drawStartPoint.x, drawStartPoint.y))
// simulate pointermove (drawContinue) - this should trigger pointerMoved callback
canvas.onpointermove?.(makePointerEvent('pointermove', drawContinuePoint.x, drawContinuePoint.y))
flushRequestAnimationFrameCallbacks()
expect(pointerMovedSpy).toHaveBeenCalled()
const actualFirstPointerPosition = pointerMovedSpy.mock.calls[0][0]
expect(actualFirstPointerPosition).toStrictEqual({ x: 300, y: 130 })
// simulate another pointermove to create a line with sufficient distance for drawing
canvas.onpointermove?.(makePointerEvent('pointermove', drawEndPoint.x, drawEndPoint.y))
flushRequestAnimationFrameCallbacks()
// simulate pointerup (drawEnd)
canvas.onpointerup?.(makePointerEvent('pointerup', drawEndPoint.x, drawEndPoint.y))
flushRequestAnimationFrameCallbacks()
expectSingleLineCommand(commandAddedSpy, [
{ x: 200, y: 80 },
{ x: 300, y: 130 },
{ x: 400, y: 180 }
])
// test that subsequent resize events properly update the transform
jest.clearAllMocks()
// trigger another resize
actualResizeObserverCallback([{ target: canvas, contentRect: { width: 300, height: 600 } }])
expect(canvas.style.transform).toContain('scale(1, 0.375)')
// another drawing operation
canvas.onpointerdown?.(makePointerEvent('pointerdown', 80, 80))
canvas.onpointermove?.(makePointerEvent('pointermove', 110, 110))
flushRequestAnimationFrameCallbacks()
canvas.onpointerup?.(makePointerEvent('pointerup', 110, 110))
flushRequestAnimationFrameCallbacks()
expect(pointerMovedSpy).toHaveBeenCalledTimes(1)
expectSingleLineCommand(commandAddedSpy, [
{ x: 107, y: 40 },
{ x: 147, y: 55 }
])
})
})
})
})
@@ -0,0 +1,478 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { DrawingCommandsProcessor, UndoRedoAvailability } from '../drawingCommandsProcessor'
import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawing'
import { type Array as YArray, Doc as YDoc } from 'yjs'
import { makeCanvasPoint } from '../drawingUtils'
const makeTextCommand = (overrides: Partial<DrawTextCmd> = {}): DrawTextCmd => ({
id: makeCommandUid(),
type: 'text',
text: 'Default Text',
pos: makeCanvasPoint(13, 17),
fontSize: 11,
fontFace: 'Arial',
color: 'red',
...overrides
})
const makeLineCommand = (overrides: Partial<DrawLineCmd> = {}): DrawLineCmd => ({
id: makeCommandUid(),
type: 'line',
lineWidth: 3,
erasing: false,
penColor: 'blue',
points: [makeCanvasPoint(1, 3), makeCanvasPoint(11, 13)],
...overrides
})
describe('DrawingCommandsProcessor Tests', () => {
let document: YDoc
let commands: YArray<DrawingCmd>
let systemUnderTest: DrawingCommandsProcessor
beforeEach(() => {
document = new YDoc()
commands = document.getArray<DrawingCmd>('test-commands')
systemUnderTest = new DrawingCommandsProcessor(document, commands)
})
afterEach(() => {
document.destroy()
})
describe('UndoRedoAvailability', () => {
it.each([
{ undoDisabled: true, redoDisabled: false },
{ undoDisabled: true, redoDisabled: true },
{ undoDisabled: false, redoDisabled: false },
{ undoDisabled: false, redoDisabled: true }
])(
'construction test with undoDisabled = $undoDisabled, redoDisabled = $redoDisabled',
({ undoDisabled, redoDisabled }) => {
const availability = new UndoRedoAvailability(undoDisabled, redoDisabled)
expect(availability.undoDisabled).toBe(undoDisabled)
expect(availability.redoDisabled).toBe(redoDisabled)
}
)
})
describe('after construction', () => {
it('snapshot', () => {
expect(systemUnderTest.snapshot()).toEqual([])
})
it('undo/redo availability', () => {
const availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(true)
expect(availability.redoDisabled).toBe(true)
})
})
describe('after set', () => {
it('undo/redo availability', () => {
const testCommands: DrawingCmd[] = [makeTextCommand(), makeLineCommand()]
systemUnderTest.set(testCommands)
const availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(true)
expect(availability.redoDisabled).toBe(true)
})
it('snapshot', () => {
const initialCommands: DrawingCmd[] = [makeTextCommand()]
const newCommands: DrawingCmd[] = [makeLineCommand()]
systemUnderTest.set(initialCommands)
systemUnderTest.set(newCommands)
expect(systemUnderTest.snapshot()).toEqual(newCommands)
expect(systemUnderTest.snapshot()).not.toEqual(initialCommands)
})
})
describe('addCommand', () => {
it('single invocation', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
expect(systemUnderTest.snapshot()).toContain(command)
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
})
it('multiple invocations', () => {
const first = makeTextCommand()
const second = makeLineCommand()
systemUnderTest.addCommand(first)
systemUnderTest.addCommand(second)
expect(systemUnderTest.snapshot()).toEqual([first, second])
})
})
describe('deleteCommand', () => {
it('existing command deletion', () => {
const toBeDeleted = makeTextCommand()
const toBeKept = makeLineCommand()
systemUnderTest.addCommand(toBeDeleted)
systemUnderTest.addCommand(toBeKept)
systemUnderTest.deleteCommand(toBeDeleted.id)
expect(systemUnderTest.snapshot()).toEqual([toBeKept])
})
it('non-existent command deletion', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
const nonExistentId = makeCommandUid()
systemUnderTest.deleteCommand(nonExistentId)
expect(systemUnderTest.snapshot()).toEqual([command])
})
it('undo/redo availability', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
systemUnderTest.deleteCommand(command.id)
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
})
})
describe('changeCommand', () => {
it('existing command change', () => {
const originalCommand = makeTextCommand({
text: 'Original',
color: 'red'
})
const changedCommand: DrawTextCmd = {
...originalCommand,
text: 'Changed',
color: 'blue'
}
systemUnderTest.addCommand(originalCommand)
systemUnderTest.changeCommand(changedCommand)
expect(systemUnderTest.snapshot()).toEqual([changedCommand])
})
it('absent command change', () => {
const newCommand = makeTextCommand()
systemUnderTest.changeCommand(newCommand)
expect(systemUnderTest.snapshot()).toEqual([newCommand])
})
it('order preservation', () => {
const first = makeTextCommand({
text: 'First',
color: 'red'
})
const second = makeTextCommand({
text: 'Second',
pos: makeCanvasPoint(20, 20),
color: 'blue'
})
const third = makeTextCommand({
text: 'Third',
pos: makeCanvasPoint(30, 30),
color: 'green'
})
systemUnderTest.addCommand(first)
systemUnderTest.addCommand(second)
systemUnderTest.addCommand(third)
const changedSecond: DrawTextCmd = {
...second,
text: 'Second Changed'
}
systemUnderTest.changeCommand(changedSecond)
expect(systemUnderTest.snapshot()).toEqual([first, changedSecond, third])
})
})
describe('undo/redo', () => {
it('addCommand undo', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
systemUnderTest.undo()
expect(systemUnderTest.snapshot().length).toBe(0)
})
it('addCommand undo, than redo', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
systemUnderTest.undo()
systemUnderTest.redo()
expect(systemUnderTest.snapshot()).toEqual([command])
})
it('multiple undo/redo', () => {
const first = makeTextCommand()
const second = makeLineCommand()
systemUnderTest.addCommand(first)
systemUnderTest.addCommand(second)
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([first])
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([])
systemUnderTest.redo()
expect(systemUnderTest.snapshot()).toEqual([first])
systemUnderTest.redo()
expect(systemUnderTest.snapshot()).toEqual([first, second])
})
it('undo/redo availability reporting', () => {
const command = makeTextCommand()
let availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(true)
expect(availability.redoDisabled).toBe(true)
systemUnderTest.addCommand(command)
availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(false)
expect(availability.redoDisabled).toBe(true)
systemUnderTest.undo()
availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(true)
expect(availability.redoDisabled).toBe(false)
systemUnderTest.redo()
availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(false)
expect(availability.redoDisabled).toBe(true)
})
})
describe('clear', () => {
it('execution', () => {
systemUnderTest.addCommand(makeTextCommand())
systemUnderTest.addCommand(makeLineCommand())
systemUnderTest.clear()
expect(systemUnderTest.snapshot().length).toBe(0)
expect(systemUnderTest.getUndoRedoAvailability().undoDisabled).toBe(false)
})
it('undo', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
systemUnderTest.clear()
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([command])
})
})
describe('ensureAllCommandsWithUids', () => {
it('absent uids addition', () => {
const commandWithoutUid = {
type: 'text',
text: 'No UID',
pos: { x: 10, y: 10 },
fontSize: 12,
fontFace: 'Arial',
color: 'red'
} as any
commands.push([commandWithoutUid])
systemUnderTest.ensureAllCommandsWithUids()
const snapshot = systemUnderTest.snapshot()
expect(snapshot.length).toBe(1)
expect(snapshot[0].id).toBeDefined()
expect(typeof snapshot[0].id).toBe('string')
})
it('should preserve existing UIDs', () => {
const existingUid = makeCommandUid()
const commandWithUid = makeTextCommand({
id: existingUid
})
commands.push([commandWithUid])
systemUnderTest.ensureAllCommandsWithUids()
expect(systemUnderTest.snapshot()).toEqual([commandWithUid])
})
it('should handle mixed commands with and without UIDs', () => {
const existingUid = makeCommandUid()
const commandWithUid = makeTextCommand({
id: existingUid
})
const commandWithoutUid = {
type: 'line',
lineWidth: 2,
erasing: false,
penColor: 'blue',
points: [
{ x: 0, y: 0 },
{ x: 20, y: 20 }
]
} as any
commands.push([commandWithUid, commandWithoutUid])
systemUnderTest.ensureAllCommandsWithUids()
const snapshot = systemUnderTest.snapshot()
expect(snapshot.length).toBe(2)
expect(snapshot[0].id).toBe(existingUid)
expect(snapshot[1].id).toBeDefined()
expect(typeof snapshot[1].id).toBe('string')
})
})
describe('snapshot', () => {
it('consecutive executions', () => {
const command = makeTextCommand()
systemUnderTest.addCommand(command)
const firstSnapshot = systemUnderTest.snapshot()
const secondSnapshot = systemUnderTest.snapshot()
expect(firstSnapshot).toEqual(secondSnapshot)
expect(firstSnapshot).not.toBe(secondSnapshot)
})
})
describe('integration tests', () => {
it('complex workflow with all operations', () => {
const textCommand = makeTextCommand()
const lineCommand = makeLineCommand()
systemUnderTest.addCommand(textCommand)
systemUnderTest.addCommand(lineCommand)
expect(systemUnderTest.snapshot().length).toBe(2)
const changedTextCommand: DrawTextCmd = {
...textCommand,
text: 'Changed Text',
color: 'green'
}
systemUnderTest.changeCommand(changedTextCommand)
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand])
systemUnderTest.deleteCommand(lineCommand.id)
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand])
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand])
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([textCommand, lineCommand])
systemUnderTest.clear()
expect(systemUnderTest.snapshot().length).toBe(0)
systemUnderTest.undo()
expect(systemUnderTest.snapshot()).toEqual([textCommand, lineCommand])
})
it('rapid command additions and deletions', () => {
const commands: DrawTextCmd[] = []
for (let i = 0; i < 10; i++) {
const cmd = makeTextCommand({
text: `Command ${i}`,
pos: makeCanvasPoint(i * 10, i * 10),
color: 'black'
})
commands.push(cmd)
systemUnderTest.addCommand(cmd)
}
expect(systemUnderTest.snapshot().length).toBe(10)
for (let i = 0; i < commands.length; i += 2) {
systemUnderTest.deleteCommand(commands[i].id)
}
expect(systemUnderTest.snapshot().length).toBe(5)
for (let i = 0; i < 5; i++) {
systemUnderTest.undo()
}
expect(systemUnderTest.snapshot().length).toBe(10)
})
it('multiple undo/redo cycles', () => {
systemUnderTest.addCommand(makeTextCommand())
systemUnderTest.addCommand(makeLineCommand())
const initialSnapshot = systemUnderTest.snapshot()
for (let cycle = 0; cycle < 3; cycle++) {
systemUnderTest.undo()
systemUnderTest.undo()
systemUnderTest.undo()
expect(systemUnderTest.snapshot().length).toBe(0)
systemUnderTest.redo()
systemUnderTest.redo()
systemUnderTest.redo()
expect(systemUnderTest.snapshot()).toEqual(initialSnapshot)
}
})
it('set after undo/redo', () => {
systemUnderTest.addCommand(makeTextCommand())
systemUnderTest.undo()
expect(systemUnderTest.getUndoRedoAvailability().redoDisabled).toBe(false)
const newCommand = makeLineCommand()
systemUnderTest.set([newCommand])
const availability = systemUnderTest.getUndoRedoAvailability()
expect(availability.undoDisabled).toBe(true)
expect(availability.redoDisabled).toBe(true)
expect(systemUnderTest.snapshot()).toEqual([newCommand])
})
})
})
@@ -0,0 +1,213 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { rescaleToFitAspectRatio, scalePoint, offsetPoint, offsetInParent, type Point } from '../drawingUtils'
describe('drawingUtils module tests', () => {
describe('scalePoint', () => {
it('execution', () => {
const point = { x: 10, y: 30 }
const scale = { x: 5, y: 7 }
const scaled = scalePoint(point, scale)
expect(scaled).toEqual({ x: 50, y: 210 })
})
})
describe('rescaleToFitAspectRatio', () => {
interface ReferenceAndActual {
referenceWidth: number
referenceHeight: number
actualWidth: number
actualHeight: number
}
interface TestCase {
description: string
data: ReferenceAndActual
}
const testCases: TestCase[] = [
{
description: 'horizontal reference, horizontal target, less width, greater height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 700, actualHeight: 650 }
},
{
description: 'horizontal reference, horizontal target, greater width, greater height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 650 }
},
{
description: 'horizontal reference, horizontal target, less width, less height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 700, actualHeight: 500 }
},
{
description: 'horizontal reference, horizontal target, greater width, less height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 500 }
},
{
description: 'horizontal reference, vertical target, less height, greater width',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 90, actualHeight: 7000 }
},
{
description: 'horizontal reference, vertical target, greater width, greater height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 900, actualHeight: 7000 }
},
{
description: 'horizontal reference, vertical target, less width, less height',
data: { referenceWidth: 800, referenceHeight: 600, actualWidth: 90, actualHeight: 170 }
},
{
description: 'vertical reference, horizontal target, greater width, greater height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 903, actualHeight: 803 }
},
{
description: 'vertical reference, horizontal target, less width, less height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 100 }
},
{
description: 'vertical reference, horizontal target, greater width, less height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 900, actualHeight: 500 }
},
{
description: 'vertical reference, vertical target, less width, greater height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 900 }
},
{
description: 'vertical reference, vertical target, greater width, greater height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 700, actualHeight: 900 }
},
{
description: 'vertical reference, vertical target, less width, less height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 500, actualHeight: 700 }
},
{
description: 'vertical reference, vertical target, greater width, less height',
data: { referenceWidth: 600, referenceHeight: 800, actualWidth: 650, actualHeight: 700 }
}
]
it.each(testCases)('$description', ({ data: { referenceWidth, referenceHeight, actualWidth, actualHeight } }) => {
const scale = rescaleToFitAspectRatio(referenceWidth, referenceHeight, actualWidth, actualHeight)
const scaledWidth = actualWidth * scale.x
const scaledHeight = actualHeight * scale.y
const resultAspectRatio = scaledWidth / scaledHeight
const desiredAspectRatio = referenceWidth / referenceHeight
expect(resultAspectRatio).toBeCloseTo(desiredAspectRatio, 6)
expect(scale.x === 1 || scale.y === 1).toBe(true)
})
})
describe('offsetPoint', () => {
interface TestCase {
description: string
victim: Point
offset: Point
expected: Point
}
const testCases: TestCase[] = [
{
description: 'positive offset',
victim: { x: 10, y: 20 },
offset: { x: 5, y: 15 },
expected: { x: 15, y: 35 }
},
{
description: 'negative offset',
victim: { x: 10, y: 20 },
offset: { x: -3, y: -8 },
expected: { x: 7, y: 12 }
},
{
description: 'zero offset',
victim: { x: 10, y: 20 },
offset: { x: 0, y: 0 },
expected: { x: 10, y: 20 }
},
{
description: 'mixed positive and negative offset',
victim: { x: 10, y: 20 },
offset: { x: -5, y: 10 },
expected: { x: 5, y: 30 }
}
]
it.each(testCases)('$description', ({ victim, offset, expected }) => {
const result = offsetPoint(victim, offset)
expect(result).toEqual(expected)
})
})
describe('offsetInParent', () => {
interface TestCase {
description: string
parentSize: { width: number, height: number }
childSize: { width: number, height: number }
expected: Point
}
const testCases: TestCase[] = [
{
description: 'child smaller than parent',
parentSize: { width: 200, height: 100 },
childSize: { width: 100, height: 50 },
expected: { x: 50, y: 25 }
},
{
description: 'child same size as parent',
parentSize: { width: 100, height: 100 },
childSize: { width: 100, height: 100 },
expected: { x: 0, y: 0 }
},
{
description: 'child larger than parent',
parentSize: { width: 100, height: 50 },
childSize: { width: 200, height: 100 },
expected: { x: -50, y: -25 }
},
{
description: 'asymmetric sizes',
parentSize: { width: 300, height: 80 },
childSize: { width: 100, height: 40 },
expected: { x: 100, y: 20 }
}
]
it.each(testCases)('$description', ({ parentSize, childSize, expected }) => {
// Create mock HTML elements
const mockParent = {
getBoundingClientRect: jest.fn().mockReturnValue({
width: parentSize.width,
height: parentSize.height
})
} as unknown as HTMLElement
const mockChild = {
getBoundingClientRect: jest.fn().mockReturnValue({
width: childSize.width,
height: childSize.height
})
} as unknown as HTMLElement
const result = offsetInParent(mockParent, mockChild)
expect(result).toEqual(expected)
})
})
})
@@ -19,9 +19,11 @@
import { getFileUrl } from '../file'
import Download from './icons/Download.svelte'
import presentation from '../plugin'
import { IntlString } from '@hcengineering/platform'
export let file: Ref<Blob> | undefined
export let name: string
export let tooltip: IntlString | undefined = presentation.string.Download
let download: HTMLAnchorElement
$: srcRef = file !== undefined ? getFileUrl(file, name) : undefined
@@ -36,7 +38,7 @@
on:click={() => {
download.click()
}}
showTooltip={{ label: presentation.string.Download }}
showTooltip={{ label: tooltip ?? presentation.string.Download }}
/>
</a>
{/if}
@@ -15,16 +15,18 @@
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { resizeObserver } from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import {
CommandUid,
drawing,
makeCommandId,
type DrawingCmd,
type DrawingData,
type DrawingTool,
type DrawTextCmd
} from '../drawing'
import DrawingBoardToolbar from './DrawingBoardToolbar.svelte'
import { DrawingCommandsProcessor } from '../drawingCommandsProcessor'
import { Doc as YDoc } from 'yjs'
export let active = false
export let readonly = true
@@ -38,15 +40,30 @@
let penWidth: number
let eraserWidth: number
let fontSize: number
let commands: DrawingCmd[] | undefined
let model: DrawingCmd[] | undefined
let modified = false
let board: HTMLDivElement
let toolbar: HTMLDivElement
let toolbarInside = false
let oldReadonly: boolean
let oldDrawings: DrawingData[]
let modified = false
let changingCmdId: string | undefined
let currentReadonly: boolean
let currentDrawings: DrawingData[]
let changingCmdId: CommandUid | undefined
let cmdEditor: HTMLDivElement | undefined
let disableUndo: boolean = false
let disableRedo: boolean = false
const document: YDoc = new YDoc()
const undoableCommands = document.getArray<DrawingCmd>('drawing-commands')
const commandProcessor = new DrawingCommandsProcessor(document, undoableCommands)
function onSavedCommandsChanged (): void {
model = commandProcessor.snapshot()
setTimeout(() => {
const status = commandProcessor.getUndoRedoAvailability()
disableUndo = currentReadonly || status.undoDisabled
disableRedo = currentReadonly || status.redoDisabled
})
}
$: updateToolbarPosition(readonly, board, toolbar)
$: updateEditableState(drawings, readonly)
@@ -59,50 +76,66 @@
}
}
function dropTextEditor (): void {
changingCmdId = undefined
cmdEditor = undefined
}
function handleModification (): void {
modified = true
dropTextEditor()
}
function updateEditableState (drawings: DrawingData[], readonly: boolean): void {
if (readonly !== oldReadonly || drawings !== oldDrawings) {
if (drawings !== undefined) {
if (readonly) {
saveDrawing()
parseDrawing(drawings[0])
modified = false
} else {
if (commands === undefined) {
commands = []
} else {
// Edit current content as a new drawing
commands = commands.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() }))
}
modified = false
}
} else {
commands = undefined
}
changingCmdId = undefined
cmdEditor = undefined
oldDrawings = drawings
oldReadonly = readonly
const readOnlyStatusChanged = readonly !== currentReadonly
const drawingsChanged = drawings !== currentDrawings
if (!readOnlyStatusChanged && !drawingsChanged) {
return
}
if (drawings !== undefined) {
if (readonly) {
saveDrawing()
parseDrawing(drawings[0])
modified = false
} else {
if (model === undefined) {
commandProcessor.set([])
onSavedCommandsChanged()
} else {
commandProcessor.ensureAllCommandsWithUids()
}
modified = false
}
} else {
model = undefined
}
dropTextEditor()
currentDrawings = drawings
currentReadonly = readonly
}
function parseDrawing (data: DrawingData | undefined): void {
if (data?.content !== undefined && data?.content !== null) {
try {
commands = JSON.parse(data.content)
const commands: DrawingCmd[] = JSON.parse(data.content)
commandProcessor.set(commands)
} catch (error: any) {
commands = []
commandProcessor.set([])
Analytics.handleError(error)
console.error('Failed to parse drawing content', error)
}
} else {
commands = []
commandProcessor.set([])
}
onSavedCommandsChanged()
}
function saveDrawing (): void {
if (modified && commands !== undefined) {
if (modified && model !== undefined) {
const data: DrawingData = {
content: JSON.stringify(commands)
content: JSON.stringify(model)
}
createDrawing(data).catch((error) => {
Analytics.handleError(error)
@@ -111,23 +144,21 @@
}
}
function addCommand (cmd: DrawingCmd): void {
if (commands !== undefined) {
commands = [...commands, cmd]
changingCmdId = undefined
cmdEditor = undefined
modified = true
function addCommand (command: DrawingCmd): void {
if (model !== undefined) {
commandProcessor.addCommand(command)
handleModification()
}
}
function showCommandProps (id: string): void {
function showCommandProps (id: CommandUid): void {
changingCmdId = id
for (const cmd of commands ?? []) {
if (cmd.id === id) {
if (cmd.type === 'text') {
const textCmd = cmd as DrawTextCmd
penColor = textCmd.color
fontSize = textCmd.fontSize
for (const command of model ?? []) {
if (command.id === id) {
if (command.type === 'text') {
const textCommand = command as DrawTextCmd
penColor = textCommand.color
fontSize = textCommand.fontSize
}
break
}
@@ -135,29 +166,31 @@
}
function changeCommand (cmd: DrawingCmd): void {
if (commands !== undefined) {
commands = commands.map((c) => (c.id === cmd.id ? cmd : c))
changingCmdId = undefined
cmdEditor = undefined
modified = true
if (model !== undefined) {
commandProcessor.changeCommand(cmd)
handleModification()
}
}
function deleteCommand (id: string): void {
if (commands !== undefined) {
commands = commands.filter((c) => c.id !== id)
changingCmdId = undefined
cmdEditor = undefined
modified = true
function deleteCommand (id: CommandUid): void {
if (model !== undefined) {
commandProcessor.deleteCommand(id)
handleModification()
}
}
onMount(() => {
onSavedCommandsChanged()
undoableCommands.observe(onSavedCommandsChanged)
})
onDestroy(() => {
undoableCommands.unobserve(onSavedCommandsChanged)
saveDrawing()
})
</script>
{#if active && commands !== undefined}
{#if active && model !== undefined}
<div
{...$$restProps}
style:position="relative"
@@ -170,7 +203,7 @@
readonly,
imageWidth,
imageHeight,
commands,
commands: model,
tool,
penColor,
penWidth,
@@ -199,8 +232,18 @@
bind:penWidth
bind:eraserWidth
bind:fontSize
bind:disableUndo
bind:disableRedo
on:clear={() => {
commands = []
commandProcessor.clear()
modified = true
}}
on:undo={() => {
commandProcessor.undo()
modified = true
}}
on:redo={() => {
commandProcessor.redo()
modified = true
}}
/>
@@ -20,19 +20,27 @@
IconEdit,
IconMoreH,
IconRedo,
IconUndo,
SelectPopup,
SelectPopupValueType,
eventToHTMLElement,
showPopup
} from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
interface DrawingBoardToolbarEvents {
undo: undefined
redo: undefined
clear: undefined
}
import IconEraser from './icons/Eraser.svelte'
import IconMove from './icons/Move.svelte'
import IconText from './icons/Text.svelte'
import { DrawingTool } from '../drawing'
import presentation from '../plugin'
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<DrawingBoardToolbarEvents>()
const maxColors = 8
const minColors = 0
const defaultColor = '#0000ff'
@@ -54,20 +62,24 @@
export let showPanTool = false
export let toolbar: HTMLDivElement | undefined
export let cmdEditor: HTMLDivElement | undefined
export let disableUndo: boolean = false
export let disableRedo: boolean = false
let colorSelector: HTMLInputElement
let penColors: string[] = defaultColors
let colorsPalette: string[] = defaultColors
function showMenu (ev: MouseEvent): void {
const items: SelectPopupValueType[] = []
if (penColors.length < maxColors) {
type PaletteCommandId = 'add-color' | 'remove-color' | 'reset-colors'
function showPaletteManagementMenu (ev: MouseEvent): void {
const items: Array<Omit<SelectPopupValueType, 'id'> & { id: PaletteCommandId }> = []
if (colorsPalette.length < maxColors) {
items.push({
id: 'add-color',
label: presentation.string.ColorAdd,
icon: IconAdd
})
}
if (penColors.length > minColors) {
if (colorsPalette.length > minColors) {
items.push({
id: 'remove-color',
label: presentation.string.ColorRemove,
@@ -79,7 +91,7 @@
label: presentation.string.ColorReset,
icon: IconRedo
})
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id) => {
showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id: PaletteCommandId | undefined) => {
switch (id) {
case 'add-color': {
if (colorSelector !== undefined) {
@@ -89,16 +101,16 @@
break
}
case 'remove-color': {
penColors = penColors.filter((c: string) => c !== penColor)
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
selectColor(penColors[0])
colorsPalette = colorsPalette.filter((c: string) => c !== penColor)
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
selectColor(colorsPalette[0])
focusEditor()
break
}
case 'reset-colors': {
penColors = defaultColors
colorsPalette = defaultColors
localStorage.removeItem(storageKey.colors)
selectColor(penColors[0])
selectColor(colorsPalette[0])
focusEditor()
break
}
@@ -106,6 +118,8 @@
break
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustive: never = id
console.error('Unknown command id', id)
}
}
@@ -114,9 +128,9 @@
function addColorPreset (): void {
penColor = penColor.toLowerCase()
if (!penColors.includes(penColor)) {
penColors = [...penColors, penColor]
localStorage.setItem(storageKey.colors, JSON.stringify(penColors))
if (!colorsPalette.includes(penColor)) {
colorsPalette = [...colorsPalette, penColor]
localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette))
}
focusEditor()
}
@@ -129,13 +143,13 @@
onMount(() => {
try {
const savedColors = localStorage.getItem(storageKey.colors)
penColors = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
colorsPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors
} catch {
penColors = defaultColors
colorsPalette = defaultColors
}
penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor).toLowerCase()
if (!penColors.includes(penColor)) {
penColor = penColors[0] ?? defaultColor
if (!colorsPalette.includes(penColor)) {
penColor = colorsPalette[0] ?? defaultColor
}
penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4')
eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '50')
@@ -165,9 +179,31 @@
</script>
<div class="toolbar" class:inside={placeInside} bind:this={toolbar}>
<Button
icon={IconUndo}
kind="icon"
showTooltip={{ label: presentation.string.Undo }}
noFocus
disabled={disableUndo}
on:click={() => {
dispatch('undo')
}}
/>
<Button
icon={IconRedo}
kind="icon"
showTooltip={{ label: presentation.string.Redo }}
noFocus
disabled={disableRedo}
on:click={() => {
dispatch('redo')
}}
/>
<div class="divider buttons-divider" />
<Button
icon={IconDelete}
kind="icon"
showTooltip={{ label: presentation.string.ClearCanvas }}
noFocus
on:click={() => {
tool = 'pen'
@@ -178,6 +214,7 @@
<Button
icon={IconEdit}
kind="icon"
showTooltip={{ label: presentation.string.PenTool }}
noFocus
selected={tool === 'pen'}
on:click={() => {
@@ -187,6 +224,7 @@
<Button
icon={IconEraser}
kind="icon"
showTooltip={{ label: presentation.string.EraserTool }}
noFocus
selected={tool === 'erase'}
on:click={() => {
@@ -197,6 +235,7 @@
<Button
icon={IconMove}
kind="icon"
showTooltip={{ label: presentation.string.PanTool }}
noFocus
selected={tool === 'pan'}
on:click={() => {
@@ -207,6 +246,7 @@
<Button
icon={IconText}
kind="icon"
showTooltip={{ label: presentation.string.TextTool }}
noFocus
selected={tool === 'text'}
on:click={() => {
@@ -248,11 +288,12 @@
/>
<div class="divider buttons-divider" />
{/if}
{#each penColors as color}
{#each colorsPalette as color}
<Button
kind="icon"
noFocus
selected={penColor === color}
showTooltip={{ label: presentation.string.ColorTooltip, props: { color } }}
on:click={() => {
if (tool === 'erase') {
tool = 'pen'
@@ -272,7 +313,13 @@
bind:value={penColor}
on:change={addColorPreset}
/>
<Button kind="icon" icon={IconMoreH} noFocus on:click={showMenu} />
<Button
kind="icon"
icon={IconMoreH}
noFocus
showTooltip={{ label: presentation.string.PaletteManagementMenu }}
on:click={showPaletteManagementMenu}
/>
</div>
</div>
@@ -198,7 +198,11 @@
/>
<div class="buttons-divider" />
{/if}
<DownloadFileButton {name} {file} />
<DownloadFileButton
{name}
{file}
tooltip={props.drawingAvailable === true ? presentation.string.DownloadOriginal : undefined}
/>
<ComponentExtensions
extension={presentation.extension.FilePreviewPopupActions}
props={{
+239 -170
View File
@@ -13,6 +13,24 @@
// limitations under the License.
//
import { generateId } from '@hcengineering/core'
import {
type CanvasPoint,
easeInOutCubic,
middlePoint,
offsetInParent,
offsetPoint,
rescaleToFitAspectRatio,
scalePoint,
type Point,
makeCanvasPoint,
type NodePoint,
type MouseScaledPoint,
makeMouseScaledPoint,
makeNodePoint,
offsetCanvasPoint
} from './drawingUtils'
export interface DrawingData {
content?: string
}
@@ -22,7 +40,7 @@ export interface DrawingProps {
autoSize?: boolean
imageWidth?: number
imageHeight?: number
commands: DrawingCmd[]
commands?: DrawingCmd[]
offset?: Point
tool?: DrawingTool
penColor?: string
@@ -30,14 +48,14 @@ export interface DrawingProps {
eraserWidth?: number
fontSize?: number
defaultCursor?: string
changingCmdId?: string
changingCmdId?: CommandUid
personCursorPos?: Point
personCursorVisible?: boolean
cmdAdded?: (cmd: DrawingCmd) => void
cmdChanging?: (id: string) => void
cmdUnchanged?: (id: string) => void
cmdChanging?: (id: CommandUid) => void
cmdUnchanged?: (id: CommandUid) => void
cmdChanged?: (cmd: DrawingCmd) => void
cmdDeleted?: (id: string) => void
cmdDeleted?: (id: CommandUid) => void
editorCreated?: (editor: HTMLDivElement) => void
pointerMoved?: (canvasPos: Point) => void
personCursorMoved?: (nodePos: Point) => void
@@ -45,14 +63,16 @@ export interface DrawingProps {
panned?: (offset: Point) => void
}
export type CommandUid = string & { readonly __brand: 'CommandUid' }
export interface DrawingCmd {
id: string
id: CommandUid
type: 'line' | 'text'
}
export interface DrawTextCmd extends DrawingCmd {
text: string
pos: Point
pos: CanvasPoint
fontSize: number
fontFace: string
color: string
@@ -62,34 +82,23 @@ export interface DrawLineCmd extends DrawingCmd {
lineWidth: number
erasing: boolean
penColor: string
points: Point[]
points: CanvasPoint[]
}
export type DrawingTool = 'pen' | 'erase' | 'pan' | 'text'
export interface Point {
x: number
y: number
}
function avgPoint (p1: Point, p2: Point): Point {
return { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
}
const maxTextLength = 500
export const makeCommandId = (): string => {
return crypto.randomUUID().toString()
}
function easeInOutCubic (x: number): number {
return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2
export const makeCommandUid = (): CommandUid => {
return (crypto?.randomUUID?.() ?? generateId()) as CommandUid
}
const crossSvg = `<svg height="8" width="8" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="m1.29 2.71 5.3 5.29-5.3 5.29c-.92.92.49 2.34 1.41 1.41l5.3-5.29 5.29 5.3c.92.92 2.34-.49 1.41-1.41l-5.29-5.3 5.3-5.29c.92-.93-.49-2.34-1.42-1.42l-5.29 5.3-5.29-5.3c-.93-.92-2.34.49-1.42 1.42z"/>
</svg>`
type PointStatus = 'last-point' | 'intermediate-point'
class DrawState {
on = false
tool: DrawingTool = 'pen'
@@ -101,8 +110,9 @@ class DrawState {
fontFace = '"IBM Plex Sans"'
center: Point = { x: 0, y: 0 }
offset: Point = { x: 0, y: 0 }
points: Point[] = []
points: CanvasPoint[] = []
scale: Point = { x: 1, y: 1 }
cssTransformScale: Point = { x: 1, y: 1 }
ctx: CanvasRenderingContext2D
constructor (ctx: CanvasRenderingContext2D) {
@@ -117,22 +127,22 @@ class DrawState {
return (this.scale.x + this.scale.y) / 2
}
addPoint = (mouseX: number, mouseY: number): void => {
this.points.push(this.mouseToCanvasPoint({ x: mouseX, y: mouseY }))
addPoint = (target: MouseScaledPoint): void => {
this.points.push(this.mouseToCanvasPoint(target))
}
mouseToCanvasPoint = (mouse: Point): Point => {
return {
x: Math.round(mouse.x * this.scale.x - this.offset.x - this.center.x),
y: Math.round(mouse.y * this.scale.y - this.offset.y - this.center.y)
}
mouseToCanvasPoint = (mouse: MouseScaledPoint): CanvasPoint => {
return makeCanvasPoint(
Math.round(mouse.x * this.scale.x - this.offset.x - this.center.x),
Math.round(mouse.y * this.scale.y - this.offset.y - this.center.y)
)
}
canvasToMousePoint = (canvas: Point): Point => {
return {
x: Math.round(canvas.x / this.scale.x + this.offset.x + this.center.x),
y: Math.round(canvas.y / this.scale.y + this.offset.y + this.center.y)
}
canvasToMousePoint = (canvas: CanvasPoint): MouseScaledPoint => {
return makeMouseScaledPoint(
Math.round(canvas.x / this.scale.x + this.offset.x + this.center.x),
Math.round(canvas.y / this.scale.y + this.offset.y + this.center.y)
)
}
isDrawingTool = (): boolean => {
@@ -143,26 +153,29 @@ class DrawState {
this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y)
}
drawLive = (x: number, y: number, lastPoint = false): void => {
drawLine = (point: MouseScaledPoint, status: PointStatus): void => {
window.requestAnimationFrame(() => {
if (!lastPoint || this.points.length > 1) {
this.addPoint(x, y)
if (status === 'intermediate-point' || this.points.length <= 1) {
this.addPoint(point)
}
const erasing = this.tool === 'erase'
this.ctx.save()
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = this.penColor
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
if (this.points.length === 1) {
this.drawPoint(this.points[0], erasing)
} else {
this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint)
this.ctx.stroke()
try {
this.translateCtx()
this.ctx.beginPath()
this.ctx.lineCap = 'round'
this.ctx.strokeStyle = this.penColor
this.ctx.lineWidth = erasing ? this.eraserWidth : this.penWidth
this.ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'
if (this.points.length === 1) {
this.drawPoint(this.points[0], erasing)
} else {
this.drawSmoothSegment(this.points, this.points.length - 1, status)
this.ctx.stroke()
}
} finally {
this.ctx.restore()
}
this.ctx.restore()
})
}
@@ -186,7 +199,8 @@ class DrawState {
this.drawPoint(cmd.points[0], cmd.erasing)
} else {
for (let i = 1; i < cmd.points.length; i++) {
this.drawSmoothSegment(cmd.points, i, i === cmd.points.length - 1)
const pointStatus: PointStatus = i === cmd.points.length - 1 ? 'last-point' : 'intermediate-point'
this.drawSmoothSegment(cmd.points, i, pointStatus)
}
this.ctx.stroke()
}
@@ -209,7 +223,7 @@ class DrawState {
this.ctx.restore()
}
isPointInText = (p: Point, cmd: DrawTextCmd): boolean => {
isPointInText = (p: CanvasPoint, cmd: DrawTextCmd): boolean => {
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFace}`
const lines = cmd.text.split('\n').map((l) => l.trim())
for (let i = 0; i < lines.length; i++) {
@@ -239,21 +253,21 @@ class DrawState {
this.ctx.fill()
}
drawSmoothSegment = (points: Point[], index: number, lastPoint: boolean): void => {
drawSmoothSegment = (points: Point[], index: number, status: PointStatus): void => {
const curPos = points[index]
const prevPos = points[index - 1]
const avg = avgPoint(prevPos, curPos)
const avg = middlePoint(prevPos, curPos)
if (index === 1) {
this.ctx.moveTo(prevPos.x, prevPos.y)
if (lastPoint) {
if (status === 'last-point') {
this.ctx.lineTo(curPos.x, curPos.y)
} else {
this.ctx.quadraticCurveTo(curPos.x, curPos.y, avg.x, avg.y)
}
} else {
const prevAvg = avgPoint(points[index - 2], prevPos)
const prevAvg = middlePoint(points[index - 2], prevPos)
this.ctx.moveTo(prevAvg.x, prevAvg.y)
if (lastPoint) {
if (status === 'last-point') {
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, curPos.x, curPos.y)
} else {
this.ctx.quadraticCurveTo(prevPos.x, prevPos.y, avg.x, avg.y)
@@ -300,8 +314,8 @@ export function drawing (
node.appendChild(toolCursor)
let readonly = props.readonly ?? false
let prevPos: Point = { x: 0, y: 0 }
let personCursorPos: Point = { x: 0, y: 0 }
let prevPos: MouseScaledPoint = makeMouseScaledPoint(0, 0)
let personCursorPos: CanvasPoint = makeCanvasPoint(0, 0)
let isPersonCursorAnimating = false
const draw = new DrawState(ctx)
@@ -317,15 +331,27 @@ export function drawing (
updateCanvasTouchAction()
interface LiveTextBox {
pos: Point
pos: MouseScaledPoint
box: HTMLDivElement
editor: HTMLDivElement
cmdId: string
cmdId: CommandUid
}
let liveTextBox: LiveTextBox | undefined
let commands = props.commands
replayCommands()
let currentCommands = props.commands
function traverseCommands (target: DrawingCmd[] | undefined, delegate: (command: DrawingCmd) => boolean): void {
if (undefined === target) {
return
}
for (let i = 0; i < target.length; i++) {
if (delegate(target[i])) {
break
}
}
}
replayCommands(currentCommands)
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
@@ -334,14 +360,26 @@ export function drawing (
draw.scale = { x: 1, y: 1 }
canvas.width = Math.floor(entry.contentRect.width)
canvas.height = Math.floor(entry.contentRect.height)
draw.center.x = canvas.width / 2
draw.center.y = canvas.height / 2
replayCommands()
} else {
draw.scale = {
x: canvas.width / entry.contentRect.width,
y: canvas.height / entry.contentRect.height
draw.center = {
x: canvas.width / 2,
y: canvas.height / 2
}
replayCommands(currentCommands)
} else {
const imageWidth = props.imageWidth ?? 1
const imageHeight = props.imageHeight ?? 1
const scale = rescaleToFitAspectRatio(
imageWidth,
imageHeight,
entry.contentRect.width,
entry.contentRect.height
)
canvas.style.transform = `scale(${scale.x}, ${scale.y})`
draw.scale = {
x: canvas.width / entry.contentRect.width / scale.x,
y: canvas.height / entry.contentRect.height / scale.y
}
draw.cssTransformScale = scale
}
}
}
@@ -359,19 +397,13 @@ export function drawing (
}
}
function touchToNodePoint (touch: Touch, node: HTMLElement): Point {
function touchToNodePoint (touch: Touch, node: HTMLElement): NodePoint {
const rect = node.getBoundingClientRect()
return {
x: Math.round(touch.clientX - rect.left),
y: Math.round(touch.clientY - rect.top)
}
return makeNodePoint(Math.round(touch.clientX - rect.left), Math.round(touch.clientY - rect.top))
}
function pointerToNodePoint (e: PointerEvent): Point {
return {
x: Math.round(e.offsetX),
y: Math.round(e.offsetY)
}
function pointerToNodePoint (e: PointerEvent): NodePoint {
return makeNodePoint(Math.round(e.offsetX), Math.round(e.offsetY))
}
canvas.ontouchstart = (e) => {
@@ -449,34 +481,46 @@ export function drawing (
}
}
function drawStart (p: Point): void {
function rescaleWithCss (target: NodePoint): MouseScaledPoint {
const scaled = scalePoint(target, draw.cssTransformScale)
return makeMouseScaledPoint(scaled.x, scaled.y)
}
function drawStart (p: NodePoint): void {
const scaledPoint = rescaleWithCss(p)
draw.on = true
draw.points = []
prevPos = p
prevPos = scaledPoint
if (draw.isDrawingTool()) {
draw.addPoint(p.x, p.y)
draw.addPoint(scaledPoint)
}
}
function drawContinue (p: Point): void {
function drawContinue (p: NodePoint): void {
const scaledPoint = rescaleWithCss(p)
if (draw.isDrawingTool()) {
const w = draw.cursorWidth()
toolCursor.style.left = `${p.x - w / 2}px`
toolCursor.style.top = `${p.y - w / 2}px`
const cursorSize = draw.cursorWidth()
const canvasOffsetInParent = offsetInParent(node, canvas)
const parentRelativeLocation = offsetPoint(scaledPoint, canvasOffsetInParent)
toolCursor.style.left = `${parentRelativeLocation.x - cursorSize / 2}px`
toolCursor.style.top = `${parentRelativeLocation.y - cursorSize / 2}px`
if (draw.on) {
if (Math.hypot(prevPos.x - p.x, prevPos.y - p.y) >= draw.minLineLength) {
draw.drawLive(p.x, p.y)
prevPos = p
if (Math.hypot(prevPos.x - scaledPoint.x, prevPos.y - scaledPoint.y) >= draw.minLineLength) {
draw.drawLine(scaledPoint, 'intermediate-point')
prevPos = scaledPoint
}
}
}
if (draw.on && draw.tool === 'pan') {
requestAnimationFrame(() => {
draw.offset.x += p.x - prevPos.x
draw.offset.y += p.y - prevPos.y
replayCommands()
prevPos = p
draw.offset.x += scaledPoint.x - prevPos.x
draw.offset.y += scaledPoint.y - prevPos.y
replayCommands(currentCommands)
prevPos = scaledPoint
if (props.panning !== undefined) {
props.panning(draw.offset)
}
@@ -484,40 +528,43 @@ export function drawing (
}
if (draw.on && draw.tool === 'text') {
prevPos = p
prevPos = scaledPoint
}
if (props.pointerMoved !== undefined) {
props.pointerMoved(draw.mouseToCanvasPoint(p))
props.pointerMoved(draw.mouseToCanvasPoint(scaledPoint))
}
}
function drawEnd (p: Point): void {
function drawEnd (p: NodePoint): void {
const scaledPoint = rescaleWithCss(p)
if (draw.on) {
if (draw.isDrawingTool()) {
draw.drawLive(p.x, p.y, true)
draw.drawLine(scaledPoint, 'last-point')
storeLineCommand()
} else if (draw.tool === 'pan') {
props.panned?.(draw.offset)
} else if (draw.tool === 'text') {
if (liveTextBox !== undefined) {
storeTextCommand()
closeLiveTextBox()
commitTextEdit({ deferCommandStore: false })
} else {
const cmd = findTextCommand(prevPos)
props.cmdChanging?.(cmd?.id ?? '')
props.cmdChanging?.(cmd?.id ?? ('' as CommandUid))
}
}
draw.on = false
}
}
function findTextCommand (mousePos: Point): DrawTextCmd | undefined {
function findTextCommand (mousePos: MouseScaledPoint): DrawTextCmd | undefined {
if (currentCommands === undefined) {
return undefined
}
const pos = draw.mouseToCanvasPoint(mousePos)
for (let i = commands.length - 1; i >= 0; i--) {
const anyCmd = commands[i]
if (anyCmd.type === 'text') {
const cmd = anyCmd as DrawTextCmd
for (let i = currentCommands.length - 1; i >= 0; i--) {
const candidate = currentCommands[i]
if (candidate.type === 'text') {
const cmd = candidate as DrawTextCmd
if (draw.isPointInText(pos, cmd)) {
return cmd
}
@@ -526,25 +573,32 @@ export function drawing (
return undefined
}
function makeLiveTextBox (cmdId: string): void {
function makeLiveTextBox (targetCommandId: CommandUid): void {
let pos = prevPos
let existingCmd: DrawTextCmd | undefined
for (const cmd of commands) {
if (cmd.id === cmdId && cmd.type === 'text') {
existingCmd = cmd as DrawTextCmd
pos = draw.canvasToMousePoint(existingCmd.pos)
break
let foundTextCommand: DrawTextCmd | undefined
traverseCommands(currentCommands, (candidate) => {
if (candidate.id === targetCommandId && candidate.type === 'text') {
foundTextCommand = candidate as DrawTextCmd
pos = draw.canvasToMousePoint(foundTextCommand.pos)
return true
}
}
return false
})
const padding = 6
const handleSize = 14
const box = document.createElement('div')
const editor = document.createElement('div')
box.appendChild(editor)
const canvasOffsetInParent = offsetInParent(node, canvas)
const parentRelativeLocation = offsetPoint(pos, canvasOffsetInParent)
box.style.zIndex = '1'
box.style.position = 'absolute'
box.style.left = `calc(${pos.x}px - ${padding}px)`
box.style.top = `calc(${pos.y}px - ${padding}px)`
box.style.left = `calc(${parentRelativeLocation.x}px - ${padding}px)`
box.style.top = `calc(${parentRelativeLocation.y}px - ${padding}px)`
box.style.border = '1px solid var(--theme-editbox-focus-border)'
box.style.borderRadius = 'var(--small-BorderRadius)'
box.style.padding = `${padding}px`
@@ -558,15 +612,14 @@ export function drawing (
editor.focus()
})
const editor = document.createElement('div')
editor.style.cursor = 'text'
editor.style.padding = '0'
editor.contentEditable = 'true'
editor.style.outline = 'none'
editor.style.minWidth = '2rem'
editor.style.whiteSpace = 'nowrap'
if (existingCmd !== undefined) {
editor.innerText = existingCmd.text
if (foundTextCommand !== undefined) {
editor.innerText = foundTextCommand.text
}
editor.addEventListener('input', (e) => {
if (editor.innerText.length > maxTextLength) {
@@ -615,14 +668,12 @@ export function drawing (
}, 0)
}
closeLiveTextBox()
replayCommands()
replayCommands(currentCommands)
} else if (e.key === 'Enter' && e.ctrlKey) {
e.preventDefault()
storeTextCommand()
closeLiveTextBox()
commitTextEdit({ deferCommandStore: false })
}
})
box.appendChild(editor)
const moveCaretToEnd = (): void => {
const selection = window.getSelection()
@@ -657,25 +708,28 @@ export function drawing (
}
const moveTextBox = (dx: number, dy: number): void => {
let newX = box.offsetLeft + dx
let newY = box.offsetTop + dy
const canvasOffset = offsetInParent(node, canvas)
let actualNewX = box.offsetLeft + dx
let actualNewY = box.offsetTop + dy
// For screenshots the canvas always has the same size as the underlying image
// and we should not be able to drag the text box outside of the screenshot
if (props.autoSize !== true) {
newX = Math.max(0, newX)
newY = Math.max(0, newY)
if (newX + box.offsetWidth > node.clientWidth) {
newX = node.clientWidth - box.offsetWidth
const canvasSize: Point = { x: canvas.getBoundingClientRect().width, y: canvas.getBoundingClientRect().height }
actualNewX = Math.max(canvasOffset.x, actualNewX)
actualNewY = Math.max(canvasOffset.y, actualNewY)
if (actualNewX > canvasOffset.x + canvasSize.x) {
actualNewX = canvasOffset.x + canvasSize.x
}
if (newY + box.offsetHeight > node.clientHeight) {
newY = node.clientHeight - box.offsetHeight
if (actualNewY > canvasOffset.y + canvasSize.y) {
actualNewY = canvasOffset.y + canvasSize.y
}
}
box.style.left = `${newX}px`
box.style.top = `${newY}px`
box.style.left = `${actualNewX}px`
box.style.top = `${actualNewY}px`
if (liveTextBox !== undefined) {
liveTextBox.pos.x = newX + padding
liveTextBox.pos.y = newY + padding
liveTextBox.pos.x = actualNewX - canvasOffset.x + padding
liveTextBox.pos.y = actualNewY - canvasOffset.y + padding
}
}
@@ -687,12 +741,12 @@ export function drawing (
e.preventDefault()
dragHandle.style.cursor = 'grabbing'
dragHandle.setPointerCapture(e.pointerId)
let prevPos = { x: e.clientX, y: e.clientY }
let previousDragPosition = { x: e.clientX, y: e.clientY }
const pointerMove = (e: PointerEvent): void => {
e.preventDefault()
const p = { x: e.clientX, y: e.clientY }
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
prevPos = p
const currentDragPosition = { x: e.clientX, y: e.clientY }
moveTextBox(currentDragPosition.x - previousDragPosition.x, currentDragPosition.y - previousDragPosition.y)
previousDragPosition = currentDragPosition
}
const pointerUp = (e: PointerEvent): void => {
setTimeout(() => {
@@ -713,13 +767,13 @@ export function drawing (
dragHandle.style.cursor = 'grabbing'
const touch = e.changedTouches[0]
const touchId = touch.identifier
let prevPos = touchToNodePoint(touch, dragHandle)
let prevPos: MouseScaledPoint = rescaleWithCss(touchToNodePoint(touch, dragHandle))
const touchMove = (e: TouchEvent): void => {
const touch = findTouch(e.changedTouches, touchId)
if (touch !== undefined) {
const p = touchToNodePoint(touch, dragHandle)
moveTextBox(p.x - prevPos.x, p.y - prevPos.y)
prevPos = p
const scaledPoint: MouseScaledPoint = rescaleWithCss(touchToNodePoint(touch, dragHandle))
moveTextBox(scaledPoint.x - prevPos.x, scaledPoint.y - prevPos.y)
prevPos = scaledPoint
}
}
const touchEnd = (e: TouchEvent): void => {
@@ -751,7 +805,7 @@ export function drawing (
box.appendChild(deleteButton)
node.appendChild(box)
liveTextBox = { box, editor, pos, cmdId }
liveTextBox = { box, editor, pos, cmdId: targetCommandId }
updateLiveTextBox()
setTimeout(() => {
editor.focus()
@@ -776,13 +830,13 @@ export function drawing (
}
}
function storeTextCommand (defer = false): void {
function storeTextCommand (parameters: { defer: boolean }): void {
if (liveTextBox !== undefined) {
const text = (liveTextBox.editor.innerText ?? '').trim()
if (text !== '') {
const cmdId = liveTextBox.cmdId
const cmd: DrawTextCmd = {
id: cmdId === '' ? makeCommandId() : cmdId,
id: cmdId === '' ? makeCommandUid() : cmdId,
type: 'text',
text,
pos: draw.mouseToCanvasPoint(liveTextBox.pos),
@@ -797,7 +851,7 @@ export function drawing (
props.cmdAdded?.(cmd)
}
}
if (defer) {
if (parameters.defer) {
setTimeout(notify, 0)
} else {
notify()
@@ -808,11 +862,16 @@ export function drawing (
}
}
function commitTextEdit (parameters: { deferCommandStore: boolean }): void {
storeTextCommand({ defer: parameters.deferCommandStore })
closeLiveTextBox()
}
function storeLineCommand (): void {
if (draw.points.length > 0) {
const erasing = draw.tool === 'erase'
const cmd: DrawLineCmd = {
id: makeCommandId(),
id: makeCommandUid(),
type: 'line',
lineWidth: erasing ? draw.eraserWidth : draw.penWidth,
erasing,
@@ -896,10 +955,7 @@ export function drawing (
if (deltaTime > frameInterval) {
lastTime = currentTime - (deltaTime % frameInterval)
const frac = Math.min((lastTime - startTime) / animDuration, 1)
personCursorPos = {
x: Math.round(oldPos.x + distanceX * frac),
y: Math.round(oldPos.y + distanceY * frac)
}
personCursorPos = offsetCanvasPoint(oldPos, distanceX, distanceY, frac)
if (props.personCursorMoved !== undefined) {
const p = draw.canvasToMousePoint(personCursorPos)
const x = Math.max(0, Math.min(p.x, canvas.width))
@@ -929,14 +985,23 @@ export function drawing (
canvas.style.touchAction = readonly ? 'unset' : 'none'
}
function replayCommands (): void {
function replayCommands (drawing: DrawingCmd[] | undefined): void {
draw.ctx.reset()
for (const cmd of commands) {
if (cmd.id !== undefined && liveTextBox?.cmdId === cmd.id) {
continue
/*
On Safari (checked on 22.08.2025) reset() does not immediatly resets the canvas.
The result looks like the "reset" command being cached and executed upon some
action like mouse entering the canvas. Anyway with this line undo/redo (instantly
adding commands) works good.
*/
draw.ctx.clearRect(0, 0, canvas.width, canvas.height)
traverseCommands(drawing, (command) => {
if (command.id === undefined || liveTextBox?.cmdId !== command.id) {
draw.drawCommand(command)
}
draw.drawCommand(cmd)
}
return false
})
}
return {
@@ -946,6 +1011,7 @@ export function drawing (
let offsetDelta: Point | undefined
let replay = false
let syncToolCursor = false
let toolChanged = false
let syncPersonCursor: Point | undefined
let syncLiveTextBox = false
if (props.offset !== undefined && !isOffsetAnimating) {
@@ -961,8 +1027,8 @@ export function drawing (
}
}
if (props.commands !== undefined) {
if (commands !== props.commands) {
commands = props.commands
if (currentCommands !== props.commands) {
currentCommands = props.commands
replay = true
}
}
@@ -970,6 +1036,7 @@ export function drawing (
if (draw.tool !== props.tool) {
draw.tool = props.tool
syncToolCursor = true
toolChanged = true
}
}
if (props.penColor !== undefined) {
@@ -1009,22 +1076,24 @@ export function drawing (
syncPersonCursor = props.personCursorPos
}
}
if (props.changingCmdId === undefined) {
if (props.changingCmdId === undefined || toolChanged) {
if (liveTextBox !== undefined) {
storeTextCommand(true)
closeLiveTextBox()
commitTextEdit({ deferCommandStore: true })
replay = true
}
} else {
if (liveTextBox === undefined) {
makeLiveTextBox(props.changingCmdId)
replay = true
if (props.tool === 'text') {
makeLiveTextBox(props.changingCmdId)
replay = true
}
} else if (liveTextBox.cmdId !== props.changingCmdId) {
storeTextCommand(true)
closeLiveTextBox()
commitTextEdit({ deferCommandStore: true })
replay = true
}
}
if (syncToolCursor) {
updateToolCursor()
}
@@ -1059,7 +1128,7 @@ export function drawing (
x: Math.round(oldOffset.x + distance.x * fracDist),
y: Math.round(oldOffset.y + distance.y * fracDist)
}
replayCommands()
replayCommands(currentCommands)
updatePersonCursor()
if (fracTime >= 1) {
isOffsetAnimating = false
@@ -1071,7 +1140,7 @@ export function drawing (
isOffsetAnimating = true
requestAnimationFrame(animate)
} else {
replayCommands()
replayCommands(currentCommands)
updatePersonCursor()
}
}
@@ -0,0 +1,139 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { makeCommandUid, type CommandUid, type DrawingCmd } from './drawing'
import { type Array as YArray, type Doc as YDoc, UndoManager as YUndoManager } from 'yjs'
export class UndoRedoAvailability {
constructor (
private readonly disableUndo: boolean,
private readonly disableRedo: boolean
) {}
get undoDisabled (): boolean {
return this.disableUndo
}
get redoDisabled (): boolean {
return this.disableRedo
}
}
export class DrawingCommandsProcessor {
private readonly document: YDoc
private readonly commands: YArray<DrawingCmd>
private readonly undoManager: YUndoManager
private readonly UndoableOperationMarker = 137
constructor (document: YDoc, backend: YArray<DrawingCmd>) {
this.document = document
this.commands = backend
this.undoManager = new YUndoManager(backend, {
trackedOrigins: new Set([this.UndoableOperationMarker]),
captureTimeout: 0
})
}
set (commands: DrawingCmd[]): void {
this.document.transact(() => {
this.removeAllCommands()
for (const command of commands) {
this.commands.push([command])
}
})
this.undoManager.clear()
}
getUndoRedoAvailability (): UndoRedoAvailability {
const disableUndo = !this.undoManager.canUndo()
const disableRedo = !this.undoManager.canRedo()
return new UndoRedoAvailability(disableUndo, disableRedo)
}
snapshot (): DrawingCmd[] {
return this.commands.toArray()
}
undo (): void {
this.undoManager.undo()
}
redo (): void {
this.undoManager.redo()
}
clear (): void {
this.document.transact(() => {
this.removeAllCommands()
}, this.UndoableOperationMarker)
}
ensureAllCommandsWithUids (): void {
this.document.transact(() => {
let anyLacksId = false
for (let i = 0; i < this.commands.length; i++) {
if (this.commands.get(i).id === undefined) {
anyLacksId = true
break
}
}
if (anyLacksId) {
const snapshot = this.commands.toArray()
this.removeAllCommands()
this.commands.push(snapshot.map((x: DrawingCmd) => ({ ...x, id: x.id ?? makeCommandUid() })))
}
})
}
addCommand (target: DrawingCmd): void {
this.document.transact(() => {
this.commands.push([target])
}, this.UndoableOperationMarker)
}
deleteCommand (id: CommandUid): void {
this.document.transact(() => {
for (let i = 0; i < this.commands.length; i++) {
if (this.commands.get(i).id === id) {
this.commands.delete(i)
break
}
}
}, this.UndoableOperationMarker)
}
changeCommand (newOne: DrawingCmd): void {
this.document.transact(() => {
let index = -1
for (let i = 0; i < this.commands.length; i++) {
if (this.commands.get(i).id === newOne.id) {
this.commands.delete(i)
index = i
break
}
}
if (index >= 0) {
this.commands.insert(index, [newOne])
} else {
this.commands.push([newOne])
}
}, this.UndoableOperationMarker)
}
private removeAllCommands (): void {
this.commands.delete(0, this.commands.length)
}
}
+131
View File
@@ -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 }
}
+2
View File
@@ -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'
+11 -1
View File
@@ -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,
+5 -3
View File
@@ -99,12 +99,12 @@
--theme-clockface-sec-arrow: conic-gradient(at 50% -10px, rgba(255, 0, 0, 0), rgba(255, 0, 0, 0) 49%, #F47758 50%, rgba(255, 0, 0, 0) 51%, rgba(255, 0, 0, 0) 100%);
--theme-clockface-sec-holder: #F47758;
--drawing-bg-color: #EEEEEE;
}
/* Dark Theme */
.theme-dark {
--theme-drawing-bg-color: #282834;
--theme-text-primary-color: rgba(255, 255, 255, .8);
--theme-text-placeholder-color: rgba(255, 255, 255, .4);
@@ -409,6 +409,8 @@
/* Light Theme */
.theme-light {
--theme-drawing-bg-color: #EEEEEE;
--theme-text-primary-color: rgba(0, 0, 0, .8);
--theme-text-placeholder-color: rgba(0, 0, 0, .4);
@@ -716,5 +718,5 @@
--theme-label-gray-color: rgba(0, 0, 0, .6);
--theme-label-gray-bg-color: rgba(0, 0, 0, .02);
--theme-label-gray-border-color: rgba(0, 0, 0, .09)
--theme-label-gray-border-color: rgba(0, 0, 0, .09);
}
@@ -357,6 +357,7 @@
board = savedBoards[id]
}
return {
document: board.ydoc,
props: board.ydoc.getMap('props'),
commands: board.ydoc.getArray<DrawingCmd>('commands'),
loading: !board.localSynced || !board.remoteSynced
@@ -19,17 +19,19 @@
DrawingCmd,
DrawingTool,
DrawTextCmd,
Point,
drawing,
makeCommandId
CommandUid,
Point,
DrawingCommandsProcessor
} from '@hcengineering/presentation'
import presence from '@hcengineering/presence'
import { getResource } from '@hcengineering/platform'
import { Loading, Component } from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte'
import { Array as YArray, Map as YMap } from 'yjs'
import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
export let boardId: string
export let document: YDoc
export let savedCmds: YArray<DrawingCmd>
export let savedProps: YMap<any>
export let grabFocus = false
@@ -45,9 +47,9 @@
let penWidth: number
let eraserWidth: number
let fontSize: number
let commands: DrawingCmd[] = []
let model: DrawingCmd[] = []
let offset: { x: number, y: number } = { x: 0, y: 0 }
let changingCmdId: string | undefined
let changingCmdId: CommandUid | undefined
let cmdEditor: HTMLDivElement | undefined
let personCursorCanvasPos: Point | undefined
let personCursorNodePos: Point | undefined
@@ -62,22 +64,32 @@
const dataTopicOffset = 'drawing-board-offset'
const dataTopicCursor = 'drawing-board-cursor'
let disableUndo = false
let disableRedo = false
const commandsProcessor = new DrawingCommandsProcessor(document, savedCmds)
$: onSelectedChanged(selected)
$: onReadonlyChanged(readonly)
$: onOffsetChanged(offset)
function listenSavedCommands (): void {
commands = savedCmds.toArray()
function onSavedCommandsChanged (): void {
model = commandsProcessor.snapshot()
setTimeout(() => {
const status = commandsProcessor.getUndoRedoAvailability()
disableUndo = status.undoDisabled
disableRedo = status.redoDisabled
})
}
function showCommandProps (id: string): void {
function showCommandProps (id: CommandUid): void {
changingCmdId = id
for (const cmd of commands) {
if (cmd.id === id) {
if (cmd.type === 'text') {
const textCmd = cmd as DrawTextCmd
penColor = textCmd.color
fontSize = textCmd.fontSize
for (const command of model) {
if (command.id === id) {
if (command.type === 'text') {
const textWriting = command as DrawTextCmd
penColor = textWriting.color
fontSize = textWriting.fontSize
}
break
}
@@ -85,61 +97,33 @@
}
function changeCommand (cmd: DrawingCmd): void {
let index = -1
for (let i = 0; i < savedCmds.length; i++) {
if (savedCmds.get(i).id === cmd.id) {
savedCmds.delete(i)
index = i
break
}
}
if (index >= 0) {
savedCmds.insert(index, [cmd])
} else {
savedCmds.push([cmd])
}
changingCmdId = undefined
cmdEditor = undefined
commandsProcessor.changeCommand(cmd)
}
function deleteCommand (id: string): void {
for (let i = 0; i < savedCmds.length; i++) {
if (savedCmds.get(i).id === id) {
savedCmds.delete(i)
break
}
}
function deleteCommand (id: CommandUid): void {
changingCmdId = undefined
cmdEditor = undefined
commandsProcessor.deleteCommand(id)
}
function onSelectedChanged (selected: boolean): void {
if (oldSelected !== selected) {
if (oldSelected && !selected && changingCmdId !== undefined) {
function onSelectedChanged (newSelected: boolean): void {
if (oldSelected !== newSelected) {
if (oldSelected && !newSelected && changingCmdId !== undefined) {
changingCmdId = undefined
cmdEditor = undefined
}
oldSelected = selected
oldSelected = newSelected
}
}
function onReadonlyChanged (readonly: boolean): void {
if (oldReadonly !== readonly) {
if (!readonly) {
let allHaveIds = true
for (let i = 0; i < savedCmds.length; i++) {
if (savedCmds.get(i).id === undefined) {
allHaveIds = false
break
}
}
if (!allHaveIds) {
const cmds = savedCmds.toArray()
savedCmds.delete(0, savedCmds.length)
savedCmds.push(cmds.map((cmd) => ({ ...cmd, id: cmd.id ?? makeCommandId() })))
}
function onReadonlyChanged (newReadonly: boolean): void {
if (oldReadonly !== newReadonly) {
if (!newReadonly) {
commandsProcessor.ensureAllCommandsWithUids()
}
oldReadonly = readonly
oldReadonly = newReadonly
}
}
@@ -178,8 +162,8 @@
}
onMount(() => {
commands = savedCmds.toArray()
savedCmds.observe(listenSavedCommands)
onSavedCommandsChanged()
savedCmds.observe(onSavedCommandsChanged)
getResource(presence.function.PublishData)
.then((func) => {
@@ -206,7 +190,7 @@
})
onDestroy(() => {
savedCmds.unobserve(listenSavedCommands)
savedCmds.unobserve(onSavedCommandsChanged)
getResource(presence.function.FolloweeDataUnsubscribe)
.then((unsubscribe) => {
@@ -241,7 +225,7 @@
use:drawing={{
readonly,
autoSize: true,
commands,
commands: model,
offset,
tool,
penColor,
@@ -250,8 +234,8 @@
fontSize,
personCursorPos: personCursorCanvasPos,
changingCmdId,
cmdAdded: (cmd) => {
savedCmds.push([cmd])
cmdAdded: (commandToAdd) => {
commandsProcessor.addCommand(commandToAdd)
changingCmdId = undefined
},
cmdChanging: showCommandProps,
@@ -288,6 +272,8 @@
placeInside={true}
showPanTool={true}
{cmdEditor}
{disableUndo}
{disableRedo}
bind:toolbar
bind:tool
bind:penColor
@@ -295,9 +281,15 @@
bind:eraserWidth
bind:fontSize
on:clear={() => {
savedCmds.delete(0, savedCmds.length)
commandsProcessor.clear()
offset = { x: 0, y: 0 }
}}
on:undo={() => {
commandsProcessor.undo()
}}
on:redo={() => {
commandsProcessor.redo()
}}
/>
{/if}
<slot />
@@ -328,7 +320,7 @@
.board {
position: relative;
width: 100%;
background-color: var(--drawing-bg-color);
background-color: var(--theme-drawing-bg-color);
border-radius: var(--small-BorderRadius);
border: 1px solid var(--theme-navpanel-border);
@@ -137,6 +137,7 @@
<NodeViewWrapper data-drag-handle="" data-type="drawingBoard" data-id={node.attrs.id}>
<DrawingBoardEditor
boardId={node.attrs.id}
document={savedBoard.document}
savedCmds={savedBoard.commands}
savedProps={savedBoard.props}
resizeable={true}
@@ -17,10 +17,11 @@
import textEditor from '@hcengineering/text-editor'
import { Dialog } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { Array as YArray, Map as YMap } from 'yjs'
import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
import DrawingBoardEditor from './DrawingBoardEditor.svelte'
export let boardId: string
export let document: YDoc
export let savedCmds: YArray<DrawingCmd>
export let savedProps: YMap<any>
export let readonly = false
@@ -43,6 +44,6 @@
dispatch('close')
}}
>
<DrawingBoardEditor {boardId} {savedCmds} {savedProps} {readonly} selected fullSize grabFocus />
<DrawingBoardEditor {boardId} {document} {savedCmds} {savedProps} {readonly} selected fullSize grabFocus />
</Dialog>
{/if}
@@ -17,7 +17,7 @@ import { type DrawingCmd } from '@hcengineering/presentation'
import { showPopup } from '@hcengineering/ui'
import { type Editor, mergeAttributes, Node } from '@tiptap/core'
import { NodeSelection } from '@tiptap/pm/state'
import type { Array as YArray, Map as YMap } from 'yjs'
import type { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs'
import DrawingBoardNodeView from '../DrawingBoardNodeView.svelte'
import DrawingBoardPopup from '../DrawingBoardPopup.svelte'
import { SvelteNodeViewRenderer } from '../node-view'
@@ -27,6 +27,7 @@ export interface DrawingBoardOptions {
}
export interface SavedBoard {
document: YDoc
commands: YArray<DrawingCmd>
props: YMap<any>
loading: boolean
@@ -38,6 +39,7 @@ export function showBoardPopup (id: string, board: SavedBoard, editor: Editor):
DrawingBoardPopup,
{
boardId: id,
document: board.document,
savedCmds: board.commands,
savedProps: board.props,
readonly: !editor.isEditable