diff --git a/desktop/jest.config.js b/desktop/jest.config.js index a447f7dd26..c4de2bbf84 100644 --- a/desktop/jest.config.js +++ b/desktop/jest.config.js @@ -1,3 +1,5 @@ +const SVELTE_MOCKS_PATH = '/../packages/presentation/src/__mocks__' + module.exports = { projects: [ { @@ -10,7 +12,16 @@ module.exports = { displayName: 'jsdom', testEnvironment: 'jsdom', preset: 'ts-jest', - testMatch: ['/src/__test__/ui/**/*.test.ts'] + testMatch: ['/src/__test__/ui/**/*.test.ts'], + moduleNameMapper: { + '^@hcengineering/platform-rig/profiles/ui/svelte$': `${SVELTE_MOCKS_PATH}/svelte-runtime.ts`, + '^svelte/store$': `${SVELTE_MOCKS_PATH}/svelte-store.ts`, + '^svelte/transition$': `${SVELTE_MOCKS_PATH}/svelte-transition.ts`, + '^svelte/animate$': `${SVELTE_MOCKS_PATH}/svelte-animate.ts`, + '^svelte$': `${SVELTE_MOCKS_PATH}/svelte.ts`, + '\\.svelte$': `${SVELTE_MOCKS_PATH}/svelte-component.ts` + }, + setupFilesAfterEnv: [`${SVELTE_MOCKS_PATH}/setup.ts`] } ], roots: ["./src", "./tests"], diff --git a/desktop/src/ui/index.ejs b/desktop/src/ui/index.ejs index 2ab6900c73..ae893ba49a 100644 --- a/desktop/src/ui/index.ejs +++ b/desktop/src/ui/index.ejs @@ -38,7 +38,7 @@ --huly-history-box-left-indent: 0; } - [data-theme="dark"] { + [data-theme="theme-dark"] { --bg-secondary: #323233; --bg-tertiary: #252526; --bg-hover: #2a2d2e; @@ -125,7 +125,7 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } - [data-theme="dark"] .desktop-app-dropdown-menu { + [data-theme="theme-dark"] .desktop-app-dropdown-menu { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } diff --git a/desktop/src/ui/index.ts b/desktop/src/ui/index.ts index 571e0a6a0d..5e8ad392b3 100644 --- a/desktop/src/ui/index.ts +++ b/desktop/src/ui/index.ts @@ -29,7 +29,7 @@ import { setupTitleBarMenu } from './titleBarMenu' import { defineScreenShare, defineGetDisplayMedia } from './screenShare' import { CommandLogout, CommandSelectWorkspace, CommandOpenSettings, CommandOpenInbox, CommandOpenPlanner, CommandOpenOffice, CommandOpenApplication, LaunchApplication, NotificationParams } from './types' import { ipcMainExposed } from './typesUtils' -import { themeStore } from '@hcengineering/theme' +import { themeStore, ThemeVariant } from '@hcengineering/theme' import type { Application } from '@hcengineering/workbench' import { isAllowedToRole } from '@hcengineering/workbench-resources' @@ -50,15 +50,14 @@ window.addEventListener('DOMContentLoaded', () => { themeStore.subscribe((themeOptions) => { if (themeOptions != null) { - const isDarkTheme = themeOptions.dark - menuBar.setTheme(isDarkTheme ? 'dark' : 'light') + menuBar.setTheme(themeOptions.variant) } }) void ipcMain.isOsUsingDarkTheme().then((isDarkTheme) => { - menuBar.setTheme(isDarkTheme ? 'dark' : 'light') + menuBar.setTheme(isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light) }).catch(() => { - menuBar.setTheme('light') // fallback + menuBar.setTheme(ThemeVariant.Light) // fallback }) } } diff --git a/desktop/src/ui/titleBarMenu.ts b/desktop/src/ui/titleBarMenu.ts index 1263571f35..f0c5c1987f 100644 --- a/desktop/src/ui/titleBarMenu.ts +++ b/desktop/src/ui/titleBarMenu.ts @@ -16,540 +16,540 @@ import { IPCMainExposed, MenuBarAction } from './types' import { isMenuBarAction } from './typesUtils' import { TitleBarMenuState } from './titleBarMenuState' +import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme' -export function setupTitleBarMenu(ipcMain: IPCMainExposed, root: HTMLElement): MenuBar { - const themeManager = new ThemeManager('light') - const menuManager = new MenuBarManager(root) +export function setupTitleBarMenu (ipcMain: IPCMainExposed, root: HTMLElement): MenuBar { + const themeManager = new ThemeManager(ThemeVariant.Light) + const menuManager = new MenuBarManager(root) - const menuBar = menuManager.getView(); + const menuBar = menuManager.getView() - const menuContainer = root.querySelector('.desktop-app-menu-container') - if (menuContainer) { - const existingMenuBar = menuContainer.querySelector('.desktop-app-menu-bar') - if (existingMenuBar) { - existingMenuBar.remove() - } - menuContainer.appendChild(menuBar) + const menuContainer = root.querySelector('.desktop-app-menu-container') + if (menuContainer != null) { + const existingMenuBar = menuContainer.querySelector('.desktop-app-menu-bar') + if (existingMenuBar != null) { + existingMenuBar.remove() } + menuContainer.appendChild(menuBar) + } - menuManager.attachEventListeners(ipcMain) + menuManager.attachEventListeners(ipcMain) - ipcMain.onWindowStateChange((_event, state) => { - const maximizeButton = root.querySelector('#maximize-button') - if (maximizeButton) { - if (state === 'maximized') { - maximizeButton.textContent = '❐' - } else { - maximizeButton.textContent = '□' - } - } - }) + ipcMain.onWindowStateChange((_event, state) => { + const maximizeButton = root.querySelector('#maximize-button') + if (maximizeButton != null) { + if (state === 'maximized') { + maximizeButton.textContent = '❐' + } else { + maximizeButton.textContent = '□' + } + } + }) - return new MenuBar(themeManager) + return new MenuBar(themeManager) } -type TitleBarTheme = 'dark' | 'light'; - export class MenuBar { - constructor(private readonly theme: ThemeManager) { - } + constructor (private readonly theme: ThemeManager) { + } - public setTheme(theme: TitleBarTheme): void { - this.theme.setTheme(theme) - } + public setTheme (theme: ThemeVariantType): void { + this.theme.setTheme(theme) + } } -export function buildHulyApplicationMenu(): HTMLElement { - const menuBuilder = new MenuBuilder() - - const MenuFileIndex = 0 - menuBuilder.addTopLevelMenu('File', 'f') - .addMenuItem(MenuFileIndex, 'Settings', 'settings', undefined, 's') - .addMenuItem(MenuFileIndex, 'Select Workspace', 'select-workspace', undefined, 'w') - .addMenuItem(MenuFileIndex, 'Logout', 'logout', undefined, 'l') - .addSeparator(MenuFileIndex) - .addMenuItem(MenuFileIndex, 'Exit', 'exit', 'Alt+F4', 'x') - - const MenuEditIndex = 1 - menuBuilder.addTopLevelMenu('Edit', 'e') - .addMenuItem(MenuEditIndex, 'Undo', 'undo', 'Ctrl+Z', 'u') - .addMenuItem(MenuEditIndex, 'Redo', 'redo', 'Ctrl+Y', 'r') - .addSeparator(MenuEditIndex) - .addMenuItem(MenuEditIndex, 'Cut', 'cut', 'Ctrl+X', 't') - .addMenuItem(MenuEditIndex, 'Copy', 'copy', 'Ctrl+C', 'c') - .addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p') - .addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd') - .addSeparator(MenuEditIndex) - .addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a') - - const MenuViewIndex = 2 - menuBuilder.addTopLevelMenu('View', 'v') - .addMenuItem(MenuViewIndex, 'Reload', 'reload', 'Ctrl+R', 'r') - .addMenuItem(MenuViewIndex, 'Force Reload', 'force-reload', 'Ctrl+Shift+R', 'o') - .addMenuItem(MenuViewIndex, 'Toggle Developer Tools', 'toggle-devtools', 'Ctrl+Shift+I', 'd') - .addSeparator(MenuViewIndex) - .addMenuItem(MenuViewIndex, 'Zoom In', 'zoom-in', 'Ctrl+\'+\'', 'i') - .addMenuItem(MenuViewIndex, 'Zoom Out', 'zoom-out', 'Ctrl+\'-\'', 'u') - .addMenuItem(MenuViewIndex, 'Actual Size', 'restore-size', 'Ctrl+0', 'a') - .addSeparator(MenuViewIndex) - .addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l') - - return menuBuilder.build() +export function buildHulyApplicationMenu (): HTMLElement { + const menuBuilder = new MenuBuilder() + + const MenuFileIndex = 0 + menuBuilder.addTopLevelMenu('File', 'f') + .addMenuItem(MenuFileIndex, 'Settings', 'settings', undefined, 's') + .addMenuItem(MenuFileIndex, 'Select Workspace', 'select-workspace', undefined, 'w') + .addMenuItem(MenuFileIndex, 'Logout', 'logout', undefined, 'l') + .addSeparator(MenuFileIndex) + .addMenuItem(MenuFileIndex, 'Exit', 'exit', 'Alt+F4', 'x') + + const MenuEditIndex = 1 + menuBuilder.addTopLevelMenu('Edit', 'e') + .addMenuItem(MenuEditIndex, 'Undo', 'undo', 'Ctrl+Z', 'u') + .addMenuItem(MenuEditIndex, 'Redo', 'redo', 'Ctrl+Y', 'r') + .addSeparator(MenuEditIndex) + .addMenuItem(MenuEditIndex, 'Cut', 'cut', 'Ctrl+X', 't') + .addMenuItem(MenuEditIndex, 'Copy', 'copy', 'Ctrl+C', 'c') + .addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p') + .addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd') + .addSeparator(MenuEditIndex) + .addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a') + + const MenuViewIndex = 2 + menuBuilder.addTopLevelMenu('View', 'v') + .addMenuItem(MenuViewIndex, 'Reload', 'reload', 'Ctrl+R', 'r') + .addMenuItem(MenuViewIndex, 'Force Reload', 'force-reload', 'Ctrl+Shift+R', 'o') + .addMenuItem(MenuViewIndex, 'Toggle Developer Tools', 'toggle-devtools', 'Ctrl+Shift+I', 'd') + .addSeparator(MenuViewIndex) + .addMenuItem(MenuViewIndex, 'Zoom In', 'zoom-in', 'Ctrl+\'+\'', 'i') + .addMenuItem(MenuViewIndex, 'Zoom Out', 'zoom-out', 'Ctrl+\'-\'', 'u') + .addMenuItem(MenuViewIndex, 'Actual Size', 'restore-size', 'Ctrl+0', 'a') + .addSeparator(MenuViewIndex) + .addMenuItem(MenuViewIndex, 'Toggle Fullscreen', 'toggle-fullscreen', 'F11', 'l') + + return menuBuilder.build() } class ThemeManager { - private readonly domThemeKey = 'data-theme' + private readonly domThemeKey = 'data-theme' - constructor(theme: TitleBarTheme) { - this.setTheme(theme) - } + constructor (theme: ThemeVariantType) { + this.setTheme(theme) + } - public setTheme(theme: TitleBarTheme): void { - document.body.setAttribute(this.domThemeKey, theme) - } + public setTheme (theme: ThemeVariantType): void { + document.body.setAttribute(this.domThemeKey, theme) + } } interface MenuItem { - type: 'item' | 'separator' - label?: string - action?: MenuBarAction - shortcut?: string - acceleratorChar?: string + type: 'item' | 'separator' + label?: string + action?: MenuBarAction + shortcut?: string + acceleratorChar?: string } interface TopLevelMenu { - label: string - accelerator: string - subMenus: MenuItem[] + label: string + accelerator: string + subMenus: MenuItem[] } export class MenuBuilder { - private menus: TopLevelMenu[] = [] + private readonly menus: TopLevelMenu[] = [] - public addTopLevelMenu(label: string, accelerator: string): this { - const menu: TopLevelMenu = { - label, - accelerator: accelerator.toLowerCase(), - subMenus: [] - } - this.menus.push(menu) - return this + public addTopLevelMenu (label: string, accelerator: string): this { + const menu: TopLevelMenu = { + label, + accelerator: accelerator.toLowerCase(), + subMenus: [] } + this.menus.push(menu) + return this + } - public addMenuItem( - topLevelMenuIndex: number, - label: string, - action: MenuBarAction, - shortcut?: string, - acceleratorChar: string | null = null - ): this { - if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { - const item: MenuItem = { - type: 'item', - label, - action, - shortcut, - acceleratorChar: (acceleratorChar || label.charAt(0)).toLowerCase(), + public addMenuItem ( + topLevelMenuIndex: number, + label: string, + action: MenuBarAction, + shortcut?: string, + acceleratorChar: string | null = null + ): this { + if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { + const item: MenuItem = { + type: 'item', + label, + action, + shortcut, + acceleratorChar: (acceleratorChar || label.charAt(0)).toLowerCase() + } + this.menus[topLevelMenuIndex].subMenus.push(item) + } + return this + } + + public addSeparator (topLevelMenuIndex: number): this { + if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { + this.menus[topLevelMenuIndex].subMenus.push({ type: 'separator' }) + } + return this + } + + public build (): HTMLElement { + const menuBar = document.createElement('ul') + menuBar.className = 'desktop-app-menu-bar' + + this.menus.forEach((topLevelMenu) => { + const topLevelMenuView = document.createElement('li') + topLevelMenuView.className = 'desktop-app-menu-item' + + const menuButton = document.createElement('button') + menuButton.className = 'desktop-app-top-menu-button' + menuButton.dataset.menu = topLevelMenu.label.toLowerCase() + menuButton.dataset.accelerator = topLevelMenu.accelerator + + const acceleratorSpan = document.createElement('span') + acceleratorSpan.className = 'desktop-app-accelerator' + acceleratorSpan.dataset.menu = topLevelMenu.label.toLowerCase() + + const labelText = topLevelMenu.label + const acceleratorIndex = labelText.toLowerCase().indexOf(topLevelMenu.accelerator) + + if (acceleratorIndex === 0) { + acceleratorSpan.textContent = topLevelMenu.accelerator.toUpperCase() + menuButton.appendChild(acceleratorSpan) + menuButton.appendChild(document.createTextNode(labelText.substring(1))) + } else if (acceleratorIndex > 0) { + acceleratorSpan.textContent = topLevelMenu.accelerator.toLowerCase() + menuButton.appendChild(document.createTextNode(labelText.substring(0, acceleratorIndex))) + menuButton.appendChild(acceleratorSpan) + menuButton.appendChild(document.createTextNode(labelText.substring(acceleratorIndex + 1))) + } else { + menuButton.textContent = labelText + } + + const dropdown = document.createElement('div') + dropdown.className = 'desktop-app-dropdown-menu' + dropdown.id = `${topLevelMenu.label.toLowerCase()}-menu` + + topLevelMenu.subMenus.forEach(item => { + if (item.type === 'separator') { + const separator = document.createElement('div') + separator.className = 'desktop-app-dropdown-separator' + dropdown.appendChild(separator) + } else if (item.type === 'item' && item.label != null) { + const menuItemButton = document.createElement('button') + menuItemButton.className = 'desktop-app-dropdown-item' + menuItemButton.dataset.accelerator = item.acceleratorChar + menuItemButton.dataset.action = item.action + + const labelSpan = document.createElement('span') + + const itemAcceleratorSpan = document.createElement('span') + itemAcceleratorSpan.className = 'desktop-app-accelerator' + + if (item.acceleratorChar) { + const labelParts = this.splitLabelByAccelerator(item.label, item.acceleratorChar) + + const actualChar = item.label.charAt( + item.label.toLowerCase().indexOf(item.acceleratorChar.toLowerCase()) + ) + itemAcceleratorSpan.textContent = actualChar + + if (labelParts.before) { + labelSpan.appendChild(document.createTextNode(labelParts.before)) } - this.menus[topLevelMenuIndex].subMenus.push(item) - } - return this - } - - public addSeparator(topLevelMenuIndex: number): this { - if (topLevelMenuIndex >= 0 && topLevelMenuIndex < this.menus.length) { - this.menus[topLevelMenuIndex].subMenus.push({ type: 'separator' }) - } - return this - } - - public build(): HTMLElement { - const menuBar = document.createElement('ul') - menuBar.className = 'desktop-app-menu-bar' - - this.menus.forEach((topLevelMenu) => { - const topLevelMenuView = document.createElement('li') - topLevelMenuView.className = 'desktop-app-menu-item' - - const menuButton = document.createElement('button') - menuButton.className = 'desktop-app-top-menu-button' - menuButton.dataset.menu = topLevelMenu.label.toLowerCase() - menuButton.dataset.accelerator = topLevelMenu.accelerator - - const acceleratorSpan = document.createElement('span') - acceleratorSpan.className = 'desktop-app-accelerator' - acceleratorSpan.dataset.menu = topLevelMenu.label.toLowerCase() - - const labelText = topLevelMenu.label - const acceleratorIndex = labelText.toLowerCase().indexOf(topLevelMenu.accelerator) - - if (acceleratorIndex === 0) { - acceleratorSpan.textContent = topLevelMenu.accelerator.toUpperCase() - menuButton.appendChild(acceleratorSpan) - menuButton.appendChild(document.createTextNode(labelText.substring(1))) - } else if (acceleratorIndex > 0) { - acceleratorSpan.textContent = topLevelMenu.accelerator.toLowerCase() - menuButton.appendChild(document.createTextNode(labelText.substring(0, acceleratorIndex))) - menuButton.appendChild(acceleratorSpan) - menuButton.appendChild(document.createTextNode(labelText.substring(acceleratorIndex + 1))) - } else { - menuButton.textContent = labelText + labelSpan.appendChild(itemAcceleratorSpan) + if (labelParts.after) { + labelSpan.appendChild(document.createTextNode(labelParts.after)) } + } - const dropdown = document.createElement('div') - dropdown.className = 'desktop-app-dropdown-menu' - dropdown.id = `${topLevelMenu.label.toLowerCase()}-menu` + menuItemButton.appendChild(labelSpan) - topLevelMenu.subMenus.forEach(item => { - if (item.type === 'separator') { - const separator = document.createElement('div') - separator.className = 'desktop-app-dropdown-separator' - dropdown.appendChild(separator) - } else if (item.type === 'item' && item.label != null) { - const menuItemButton = document.createElement('button') - menuItemButton.className = 'desktop-app-dropdown-item' - menuItemButton.dataset.accelerator = item.acceleratorChar - menuItemButton.dataset.action = item.action + if (item.shortcut) { + const shortcutSpan = document.createElement('span') + shortcutSpan.className = 'desktop-app-shortcut' + shortcutSpan.textContent = item.shortcut + menuItemButton.appendChild(shortcutSpan) + } - const labelSpan = document.createElement('span') - - const itemAcceleratorSpan = document.createElement('span') - itemAcceleratorSpan.className = 'desktop-app-accelerator' - - if (item.acceleratorChar) { - const labelParts = this.splitLabelByAccelerator(item.label, item.acceleratorChar) - - const actualChar = item.label.charAt( - item.label.toLowerCase().indexOf(item.acceleratorChar.toLowerCase()) - ) - itemAcceleratorSpan.textContent = actualChar - - if (labelParts.before) { - labelSpan.appendChild(document.createTextNode(labelParts.before)) - } - labelSpan.appendChild(itemAcceleratorSpan) - if (labelParts.after) { - labelSpan.appendChild(document.createTextNode(labelParts.after)) - } - } - - menuItemButton.appendChild(labelSpan) - - if (item.shortcut) { - const shortcutSpan = document.createElement('span') - shortcutSpan.className = 'desktop-app-shortcut' - shortcutSpan.textContent = item.shortcut - menuItemButton.appendChild(shortcutSpan) - } - - dropdown.appendChild(menuItemButton) - } - }) - - topLevelMenuView.appendChild(menuButton) - topLevelMenuView.appendChild(dropdown) - menuBar.appendChild(topLevelMenuView) - }) - - return menuBar - } - - private splitLabelByAccelerator(label: string, acceleratorChar: string): { before: string; after: string } { - const index = label.toLowerCase().indexOf(acceleratorChar.toLowerCase()) - if (index === -1) { - return { before: label, after: '' } - } - return { - before: label.substring(0, index), - after: label.substring(index + 1) + dropdown.appendChild(menuItemButton) } + }) + + topLevelMenuView.appendChild(menuButton) + topLevelMenuView.appendChild(dropdown) + menuBar.appendChild(topLevelMenuView) + }) + + return menuBar + } + + private splitLabelByAccelerator (label: string, acceleratorChar: string): { before: string, after: string } { + const index = label.toLowerCase().indexOf(acceleratorChar.toLowerCase()) + if (index === -1) { + return { before: label, after: '' } } + return { + before: label.substring(0, index), + after: label.substring(index + 1) + } + } } class MenuBarManager { - private readonly state: TitleBarMenuState - private readonly view: HTMLElement - - private altPressed: boolean = false - private controlKeysActivated: boolean = false - - private readonly TopMenuStyle = '.desktop-app-top-menu-button' - private readonly DropdownMenuStyle = '.desktop-app-dropdown-menu' - private readonly DropdownItemStyle = '.desktop-app-dropdown-item' - private readonly MenuItemStyle = '.desktop-app-menu-item' + private readonly state: TitleBarMenuState + private readonly view: HTMLElement - private readonly StateStyleAltMode = 'desktop-app-alt-mode' - private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected' - private readonly StateStyleAltModeActive = 'desktop-app-alt-active' + private altPressed: boolean = false + private controlKeysActivated: boolean = false - constructor(private readonly root: HTMLElement) { - this.state = new TitleBarMenuState( - () => this.topLevelMenus().length, - (topLevelMenuIndex: number) => { - const children = this.childrenOfTopLevelMenu(topLevelMenuIndex) - return children.length - } - ) + private readonly TopMenuStyle = '.desktop-app-top-menu-button' + private readonly DropdownMenuStyle = '.desktop-app-dropdown-menu' + private readonly DropdownItemStyle = '.desktop-app-dropdown-item' + private readonly MenuItemStyle = '.desktop-app-menu-item' - this.view = buildHulyApplicationMenu() + private readonly StateStyleAltMode = 'desktop-app-alt-mode' + private readonly StateStyleKeyboardSelected = 'desktop-app-keyboard-selected' + private readonly StateStyleAltModeActive = 'desktop-app-alt-active' + + constructor (private readonly root: HTMLElement) { + this.state = new TitleBarMenuState( + () => this.topLevelMenus().length, + (topLevelMenuIndex: number) => { + const children = this.childrenOfTopLevelMenu(topLevelMenuIndex) + return children.length + } + ) + + this.view = buildHulyApplicationMenu() + } + + public getView (): HTMLElement { + return this.view + } + + private topLevelMenus (): NodeListOf { + return this.root.querySelectorAll(this.TopMenuStyle) + } + + private onButtonClick (id: string, callback: () => void): void { + const button = this.root.querySelector(`#${id}`) + if (button) { + button.addEventListener('click', callback) + } + } + + public attachEventListeners (ipcMain: IPCMainExposed): void { + this.onButtonClick('minimize-button', () => { + ipcMain.minimizeWindow() + }) + + this.onButtonClick('maximize-button', () => { + ipcMain.maximizeWindow() + }) + + this.onButtonClick('close-button', () => { + ipcMain.closeWindow() + }) + + document.addEventListener('keydown', (e) => { this.handleKeyDown(ipcMain, e) }) + document.addEventListener('keyup', (e) => { this.handleKeyUp(e) }) + + this.topLevelMenus().forEach((button, index) => { + button.addEventListener('click', (e) => { this.handleTopLevelMenuButtonClick(e, index) }) + }) + + document.addEventListener('click', (e) => { this.handleDocumentClick(e) }) + + document.querySelectorAll(this.DropdownItemStyle + '[data-action]').forEach(item => { + item.addEventListener('click', () => { this.handleMenuButtonClick(ipcMain, item) }) + }) + + ipcMain.onWindowFocusLoss(() => { + this.state.exitAltMode() + this.altPressed = false + this.controlKeysActivated = false + this.renderState() + }) + } + + private renderState (): void { + if (this.state.isAltModeActive) { + this.root.classList.add(this.StateStyleAltModeActive) + } else { + this.root.classList.remove(this.StateStyleAltModeActive) } - public getView(): HTMLElement { - return this.view - } + this.root.querySelectorAll(this.DropdownMenuStyle).forEach(menu => { + menu.style.display = 'none' + }) - private topLevelMenus() { - return this.root.querySelectorAll(this.TopMenuStyle) - } + const topLevelMenus = this.topLevelMenus() - private onButtonClick(id: string, callback: () => void): void { - const button = this.root.querySelector(`#${id}`) - if (button) { - button.addEventListener('click', callback) + topLevelMenus.forEach((button, index) => { + button.classList.remove(this.StateStyleAltMode) + + if (index === this.state.FocusedTopLevelMenuIndex) { + button.classList.add(this.StateStyleAltMode) + button.focus() + + if (this.state.isTopLevelMenuExpanded && button.dataset.menu) { + const menuType = button.dataset.menu + const dropdown: HTMLElement | null = this.root.querySelector(`#${menuType}-menu`) + if (dropdown) { + dropdown.style.display = 'block' + } } - } + } else { + button.blur() + } + }) - public attachEventListeners(ipcMain: IPCMainExposed): void { - this.onButtonClick('minimize-button', () => { - ipcMain.minimizeWindow() - }) - - this.onButtonClick('maximize-button', () => { - ipcMain.maximizeWindow() - }) - - this.onButtonClick('close-button', () => { - ipcMain.closeWindow() - }) - - document.addEventListener('keydown', (e) => this.handleKeyDown(ipcMain, e)) - document.addEventListener('keyup', (e) => this.handleKeyUp(e)) - - this.topLevelMenus().forEach((button, index) => { - button.addEventListener('click', (e) => this.handleTopLevelMenuButtonClick(e, index)) - }) - - document.addEventListener('click', (e) => this.handleDocumentClick(e)) - - document.querySelectorAll(this.DropdownItemStyle + '[data-action]').forEach(item => { - item.addEventListener('click', async () => this.handleMenuButtonClick(ipcMain, item)) - }) - - ipcMain.onWindowFocusLoss(() => { - this.state.exitAltMode() - this.altPressed = false - this.controlKeysActivated = false - this.renderState() - }) - } - - private renderState() { - if (this.state.isAltModeActive) { - this.root.classList.add(this.StateStyleAltModeActive) + if (this.state.FocusedTopLevelMenuIndex != null && this.state.isTopLevelMenuExpanded) { + const candidates = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + candidates.forEach((menu, index) => { + if (index === this.state.FocusedChildMenuIndex) { + menu.classList.add(this.StateStyleKeyboardSelected) } else { - this.root.classList.remove(this.StateStyleAltModeActive) + menu.classList.remove(this.StateStyleKeyboardSelected) } + }) + } + } - this.root.querySelectorAll(this.DropdownMenuStyle).forEach(menu => { - menu.style.display = 'none' - }) + private childrenOfTopLevelMenu (index: number): NodeListOf { + const topLevelMenus = this.topLevelMenus() + const menuButton = topLevelMenus[index] + const menuType = menuButton.dataset.menu + const dropdown = this.root.querySelector(`#${menuType}-menu`) + if (dropdown) { + return dropdown.querySelectorAll(this.DropdownItemStyle) + } + return document.createDocumentFragment().querySelectorAll('*') + } - const topLevelMenus = this.topLevelMenus() - - topLevelMenus.forEach((button, index) => { - button.classList.remove(this.StateStyleAltMode) + private executeMenuAction (ipcMain: IPCMainExposed, action: MenuBarAction): void { + try { + ipcMain.executeMenuBarAction(action) + } catch (error) { + console.error('error executing action:', error) + } + } - if (index === this.state.FocusedTopLevelMenuIndex) { - button.classList.add(this.StateStyleAltMode) - button.focus() - - if (this.state.isTopLevelMenuExpanded && button.dataset.menu) { - const menuType = button.dataset.menu - const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null - if (dropdown) { - dropdown.style.display = 'block' - } - } - } else { - button.blur() - } - }) - - if (this.state.FocusedTopLevelMenuIndex != null && this.state.isTopLevelMenuExpanded) { - const candidates = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) - candidates.forEach((menu, index) => { - if (index === this.state.FocusedChildMenuIndex) { - menu.classList.add(this.StateStyleKeyboardSelected) - } else { - menu.classList.remove(this.StateStyleKeyboardSelected) - } - }) - } + private handleKeyDown (ipcMain: IPCMainExposed, e: KeyboardEvent): void { + if (e.altKey) { + this.altPressed = true } - private childrenOfTopLevelMenu(index: number): NodeListOf { - const topLevelMenus = this.topLevelMenus() - const menuButton = topLevelMenus[index] - const menuType = menuButton.dataset.menu - const dropdown = this.root.querySelector(`#${menuType}-menu`) as HTMLElement | null - if (dropdown) { - return dropdown.querySelectorAll(this.DropdownItemStyle) - } - return document.createDocumentFragment().querySelectorAll('*') + if (e.shiftKey || e.ctrlKey || e.metaKey) { + if (this.altPressed) { + this.controlKeysActivated = true + return + } } - private async executeMenuAction(ipcMain: IPCMainExposed, action: MenuBarAction): Promise { - try { - await ipcMain.executeMenuBarAction(action); - } catch (error) { - console.error('error executing action:', error) + if (e.altKey) { + if (this.state.isAltModeActive) { + if (this.state.FocusedTopLevelMenuIndex != null) { + this.state.closeAll() + this.renderState() + return } - } - - private handleKeyDown(ipcMain: IPCMainExposed, e: KeyboardEvent): void { - if (e.altKey) { - this.altPressed = true - } - - if (e.shiftKey || e.ctrlKey || e.metaKey) { - if (this.altPressed) { - this.controlKeysActivated = true - return - } - } - - if (e.altKey) { - if (this.state.isAltModeActive) { - if (this.state.FocusedTopLevelMenuIndex != null) { - this.state.closeAll() - this.renderState() - return - } - } else { - this.state.enterAltMode(null) - this.renderState() - } - } - - switch (e.key) { - case 'ArrowLeft': - this.state.moveFocusHorizontal(-1) - this.renderState() - break - - case 'ArrowRight': - this.state.moveFocusHorizontal(+1) - this.renderState() - break - - case 'ArrowDown': - this.state.moveFocusVertical(+1) - this.renderState() - break - - case 'ArrowUp': - this.state.moveFocusVertical(-1) - this.renderState() - break - } - - switch (e.key) { - case 'Escape': - this.state.defocus() - this.renderState() - break - - case 'Enter': - case ' ': - if (this.state.FocusedTopLevelMenuIndex != null){ - if (this.state.isTopLevelMenuExpanded) { - if (this.state.FocusedChildMenuIndex != null) { - const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) - const focused = children[this.state.FocusedChildMenuIndex] - this.state.closeAll() - this.renderState() - if (focused.dataset.action && isMenuBarAction(focused.dataset.action)) { - this.executeMenuAction(ipcMain, focused.dataset.action) - } - this.renderState() - } - } - } - break - - default: - const key = e.key.toLowerCase() - if (false == this.state.isAltModeActive) { - return - } - - if (this.state.isTopLevelMenuExpanded && this.state.FocusedTopLevelMenuIndex != null) { - const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) - for (let i = 0; i < children.length; i++) { - if (children[i].dataset.accelerator === key) { - const action = children[i].dataset.action - if (action) { - if (isMenuBarAction(action)) { - this.executeMenuAction(ipcMain, action) - } - this.state.closeAll() - this.renderState() - return - } - } - } - } - - const menuButtons = this.root.querySelectorAll(this.TopMenuStyle + '[data-accelerator]') - for (let i = 0; i < menuButtons.length; i++) { - if (menuButtons[i].dataset.accelerator === key) { - this.state.expandTopLevelMenu(i) - this.state.focusChildMenu() - this.renderState() - return - } - } - break - } - } - - private handleKeyUp(e: KeyboardEvent): void { - if (e.key === 'Alt') { - if (this.controlKeysActivated) { - this.state.exitAltMode() - this.renderState() - } else { - if (this.state.FocusedTopLevelMenuIndex == null) { - this.state.enterAltMode(0) - this.renderState() - } - } - this.controlKeysActivated = false - this.altPressed = false - } - } - - private async handleMenuButtonClick(ipcMain: IPCMainExposed, item: HTMLButtonElement): Promise { - const action = item.dataset.action - if (action) { - if (isMenuBarAction(action)) { - await this.executeMenuAction(ipcMain, action) - } - this.state.closeAll() - this.renderState() - } - } - - private handleTopLevelMenuButtonClick(_e: Event, index: number): void { - this.state.expandTopLevelMenu(index) + } else { + this.state.enterAltMode(null) this.renderState() + } } - private handleDocumentClick(e: Event): void { - const target = e.target as HTMLElement - if (!target.closest(this.MenuItemStyle)) { - this.state.closeAll() - this.renderState() - } + switch (e.key) { + case 'ArrowLeft': + this.state.moveFocusHorizontal(-1) + this.renderState() + break + + case 'ArrowRight': + this.state.moveFocusHorizontal(+1) + this.renderState() + break + + case 'ArrowDown': + this.state.moveFocusVertical(+1) + this.renderState() + break + + case 'ArrowUp': + this.state.moveFocusVertical(-1) + this.renderState() + break } + + switch (e.key) { + case 'Escape': + this.state.defocus() + this.renderState() + break + + case 'Enter': + case ' ': + if (this.state.FocusedTopLevelMenuIndex != null) { + if (this.state.isTopLevelMenuExpanded) { + if (this.state.FocusedChildMenuIndex != null) { + const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + const focused = children[this.state.FocusedChildMenuIndex] + this.state.closeAll() + this.renderState() + if (focused.dataset.action && isMenuBarAction(focused.dataset.action)) { + this.executeMenuAction(ipcMain, focused.dataset.action) + } + this.renderState() + } + } + } + break + + default: { + const key = e.key.toLowerCase() + if (!this.state.isAltModeActive) { + return + } + + if (this.state.isTopLevelMenuExpanded && this.state.FocusedTopLevelMenuIndex != null) { + const children = this.childrenOfTopLevelMenu(this.state.FocusedTopLevelMenuIndex) + for (let i = 0; i < children.length; i++) { + if (children[i].dataset.accelerator === key) { + const action = children[i].dataset.action + if (action) { + if (isMenuBarAction(action)) { + this.executeMenuAction(ipcMain, action) + } + this.state.closeAll() + this.renderState() + return + } + } + } + } + + const menuButtons = this.root.querySelectorAll(this.TopMenuStyle + '[data-accelerator]') + for (let i = 0; i < menuButtons.length; i++) { + if (menuButtons[i].dataset.accelerator === key) { + this.state.expandTopLevelMenu(i) + this.state.focusChildMenu() + this.renderState() + return + } + } + break + } + } + } + + private handleKeyUp (e: KeyboardEvent): void { + if (e.key === 'Alt') { + if (this.controlKeysActivated) { + this.state.exitAltMode() + this.renderState() + } else { + if (this.state.FocusedTopLevelMenuIndex == null) { + this.state.enterAltMode(0) + this.renderState() + } + } + this.controlKeysActivated = false + this.altPressed = false + } + } + + private handleMenuButtonClick (ipcMain: IPCMainExposed, item: HTMLButtonElement): void { + const action = item.dataset.action + if (action) { + if (isMenuBarAction(action)) { + this.executeMenuAction(ipcMain, action) + } + this.state.closeAll() + this.renderState() + } + } + + private handleTopLevelMenuButtonClick (_e: Event, index: number): void { + this.state.expandTopLevelMenu(index) + this.renderState() + } + + private handleDocumentClick (e: Event): void { + const target = e.target as HTMLElement + if (!target.closest(this.MenuItemStyle)) { + this.state.closeAll() + this.renderState() + } + } } diff --git a/desktop/start-dev.bat b/desktop/start-dev.bat new file mode 100644 index 0000000000..87c6930448 --- /dev/null +++ b/desktop/start-dev.bat @@ -0,0 +1,5 @@ +@echo off +for /f %%i in ('node ../common/scripts/show_version.js') do set MODEL_VERSION=%%i +for /f %%i in ('node ../common/scripts/show_tag.js') do set VERSION=%%i +set NODE_ENV=development +electron --no-sandbox --trace-warnings ./ \ No newline at end of file diff --git a/desktop/start-dev.ps1 b/desktop/start-dev.ps1 new file mode 100644 index 0000000000..94bbd625f5 --- /dev/null +++ b/desktop/start-dev.ps1 @@ -0,0 +1,11 @@ +$MODEL_VERSION = node ../common/scripts/show_version.js | Out-String +$MODEL_VERSION = $MODEL_VERSION.Trim() + +$VERSION = node ../common/scripts/show_tag.js | Out-String +$VERSION = $VERSION.Trim() + +# Set environment variable +$env:NODE_ENV = "development" + +# Run Electron +electron --no-sandbox ./ \ No newline at end of file diff --git a/packages/presentation/jest.config.js b/packages/presentation/jest.config.js index 3b91601ef3..4a14a8ef9a 100644 --- a/packages/presentation/jest.config.js +++ b/packages/presentation/jest.config.js @@ -1,6 +1,8 @@ +const SVELTE_MOCKS_PATH = '/src/__mocks__' + module.exports = { projects: [ - // Default configuration for most tests (node environment) + // default configuration for most tests (node environment) { displayName: 'node', preset: 'ts-jest', @@ -8,12 +10,26 @@ module.exports = { testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], testPathIgnorePatterns: ['.*drawing\\.test\\.ts$'] }, - // Configuration for drawing tests (jsdom environment) + // configuration for drawing tests (jsdom environment) { displayName: 'jsdom', preset: 'ts-jest', testEnvironment: 'jsdom', - testMatch: ['**/drawing.test.ts'] + testMatch: ['**/drawing.test.ts'], + moduleNameMapper: { + '^@hcengineering/platform-rig/profiles/ui/svelte$': `${SVELTE_MOCKS_PATH}/svelte-runtime.ts`, + '^svelte/store$': `${SVELTE_MOCKS_PATH}/svelte-store.ts`, + '^svelte/transition$': `${SVELTE_MOCKS_PATH}/svelte-transition.ts`, + '^svelte/animate$': `${SVELTE_MOCKS_PATH}/svelte-animate.ts`, + '^svelte$': `${SVELTE_MOCKS_PATH}/svelte.ts`, + '\\.svelte$': `${SVELTE_MOCKS_PATH}/svelte-component.ts` + }, + // setup files to run before tests + setupFilesAfterEnv: [`${SVELTE_MOCKS_PATH}/setup.ts`], + // allow transformation of ES modules in case we encounter them + transformIgnorePatterns: [ + 'node_modules/(?!(svelte|@sveltejs|@testing-library)/)' + ] } ] } diff --git a/packages/presentation/lang/cs.json b/packages/presentation/lang/cs.json index 5dc57a2062..47566f1a0d 100644 --- a/packages/presentation/lang/cs.json +++ b/packages/presentation/lang/cs.json @@ -53,7 +53,6 @@ "EraserTool": "Nástroj guma", "PanTool": "Nástroj posun", "TextTool": "Nástroj text", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Spravovat barevné předvolby" }, "status": { diff --git a/packages/presentation/lang/de.json b/packages/presentation/lang/de.json index c0fd76c7b2..61845272a5 100644 --- a/packages/presentation/lang/de.json +++ b/packages/presentation/lang/de.json @@ -53,7 +53,6 @@ "EraserTool": "Radiergummi-Werkzeug", "PanTool": "Verschieben-Werkzeug", "TextTool": "Text-Werkzeug", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Farbpresets verwalten" }, "status": { diff --git a/packages/presentation/lang/en.json b/packages/presentation/lang/en.json index 738fdf57cd..1089fb3329 100644 --- a/packages/presentation/lang/en.json +++ b/packages/presentation/lang/en.json @@ -53,7 +53,6 @@ "EraserTool": "Eraser tool", "PanTool": "Pan tool", "TextTool": "Text tool", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Manage color presets" }, "status": { diff --git a/packages/presentation/lang/es.json b/packages/presentation/lang/es.json index 679e18495c..c424de8b88 100644 --- a/packages/presentation/lang/es.json +++ b/packages/presentation/lang/es.json @@ -53,7 +53,6 @@ "EraserTool": "Herramienta borrador", "PanTool": "Herramienta mover", "TextTool": "Herramienta texto", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Gestionar preajustes de color" }, "status": { diff --git a/packages/presentation/lang/fr.json b/packages/presentation/lang/fr.json index 976f9a5d8d..506b4eb5ec 100644 --- a/packages/presentation/lang/fr.json +++ b/packages/presentation/lang/fr.json @@ -53,7 +53,6 @@ "EraserTool": "Outil gomme", "PanTool": "Outil déplacement", "TextTool": "Outil texte", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Gérer les préréglages de couleur" }, "status": { diff --git a/packages/presentation/lang/it.json b/packages/presentation/lang/it.json index 15bb5e73bf..2c58cde668 100644 --- a/packages/presentation/lang/it.json +++ b/packages/presentation/lang/it.json @@ -53,7 +53,6 @@ "EraserTool": "Strumento gomma", "PanTool": "Strumento sposta", "TextTool": "Strumento testo", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Gestisci i preset di colore" }, "status": { diff --git a/packages/presentation/lang/ja.json b/packages/presentation/lang/ja.json index 361e580128..a594fa1598 100644 --- a/packages/presentation/lang/ja.json +++ b/packages/presentation/lang/ja.json @@ -53,7 +53,6 @@ "EraserTool": "消しゴムツール", "PanTool": "パンツール", "TextTool": "テキストツール", - "ColorTooltip": "{color}", "PaletteManagementMenu": "カラープリセットを管理" }, "status": { diff --git a/packages/presentation/lang/pt.json b/packages/presentation/lang/pt.json index bfb38e7d47..4274257351 100644 --- a/packages/presentation/lang/pt.json +++ b/packages/presentation/lang/pt.json @@ -53,7 +53,6 @@ "EraserTool": "Ferramenta borracha", "PanTool": "Ferramenta mover", "TextTool": "Ferramenta texto", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Gerenciar predefinições de cor" }, "status": { diff --git a/packages/presentation/lang/ru.json b/packages/presentation/lang/ru.json index 37e7f385aa..669638c81c 100644 --- a/packages/presentation/lang/ru.json +++ b/packages/presentation/lang/ru.json @@ -53,7 +53,6 @@ "EraserTool": "Инструмент ластик", "PanTool": "Инструмент перемещения", "TextTool": "Инструмент текст", - "ColorTooltip": "{color}", "PaletteManagementMenu": "Управление цветовыми пресетами" }, "status": { diff --git a/packages/presentation/lang/zh.json b/packages/presentation/lang/zh.json index 506c50efa3..152ad3f958 100644 --- a/packages/presentation/lang/zh.json +++ b/packages/presentation/lang/zh.json @@ -53,7 +53,6 @@ "EraserTool": "橡皮擦工具", "PanTool": "移动工具", "TextTool": "文字工具", - "ColorTooltip": "{color}", "PaletteManagementMenu": "管理颜色预设" }, "status": { diff --git a/packages/presentation/package.json b/packages/presentation/package.json index c336487228..3cdd6e9789 100644 --- a/packages/presentation/package.json +++ b/packages/presentation/package.json @@ -59,6 +59,7 @@ "@hcengineering/uploader": "^0.6.0", "@hcengineering/view": "^0.6.13", "@hcengineering/emoji": "^0.6.0", + "@hcengineering/theme": "^0.6.5", "fast-equals": "^5.2.2", "png-chunks-extract": "^1.0.0", "svelte": "^4.2.19", diff --git a/packages/presentation/src/___tests___/drawing.test.ts b/packages/presentation/src/___tests___/drawing.test.ts index 7d77d0057a..703cfe5fb9 100644 --- a/packages/presentation/src/___tests___/drawing.test.ts +++ b/packages/presentation/src/___tests___/drawing.test.ts @@ -15,9 +15,10 @@ import '@testing-library/jest-dom' -import { makeCommandUid, drawing } from '../drawing' -import type { DrawTextCmd, CommandUid, DrawingTool, DrawingProps, DrawingCmd } from '../drawing' -import { makeCanvasPoint } from '../drawingUtils' +import { makeCommandUid, type CommandUid, type DrawTextCmd, type DrawingCmd } from '../drawingCommand' +import { ThemeAwareColor, type ColorsList } from '../drawingColors' +import { drawing, type DrawingTool, type DrawingProps } from '../drawing' +import { type ColorMetaNameOrHex, makeCanvasPoint } from '../drawingUtils' const fakeCanvasContext = { clearRect: jest.fn(), @@ -120,7 +121,10 @@ describe('drawing module tests', () => { it('create a drawing board', () => { const drawingBoard = drawing(drawingPlugInPoint, { + colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]], readonly: false, + getCurrentTheme: () => 'theme-light', + subscribeOnThemeChange: () => {}, imageWidth: 40, imageHeight: 40, commands: [] @@ -130,7 +134,7 @@ describe('drawing module tests', () => { }) describe('text editing', () => { - const DefaultPenColor: string = 'red' + const DefaultPenColor: ColorMetaNameOrHex = 'red' as ColorMetaNameOrHex const DefaultTool: DrawingTool = 'pen' const EmptyCommandUid = '' as CommandUid const DefaultDrawingBoardWidth = 200 @@ -147,7 +151,7 @@ describe('drawing module tests', () => { pos: makeCanvasPoint(10, 10), fontSize: 12, fontFace: '"IBM Plex Sans"', - color: 'green', + color: 'green' as ColorMetaNameOrHex, ...overrides } return { textCommandUid, textCommand } @@ -159,7 +163,11 @@ describe('drawing module tests', () => { ): { drawingBoard: ReturnType, initialState: DrawingProps } => { const commands = existingTextCommand === undefined ? [] : [existingTextCommand] - const initialState = { + const colorsList: ColorsList = [['alpha', new ThemeAwareColor('red', 'yellow')]] + const initialState: any = { + colorsList, + getCurrentTheme: () => 'theme-light', + subscribeOnThemeChange: () => {}, readonly: false, imageWidth: DefaultDrawingBoardWidth, imageHeight: DefaultDrawingBoardHeight, @@ -222,7 +230,7 @@ describe('drawing module tests', () => { const { drawingBoard, initialState: boardState } = createDrawingBoard(undefined, { cmdAdded: commandAddedSpy }) { - const colorToSet = 'blue' + const colorToSet = 'blue' as ColorMetaNameOrHex // simulating: user selected text tool drawingBoard.update?.({ ...boardState, tool: 'text' }) // simulating: user selected a color @@ -235,12 +243,12 @@ describe('drawing module tests', () => { // change color to something else { - const colorToSet = 'blue' + const colorToSet = 'blue' as ColorMetaNameOrHex drawingBoard.update?.({ ...boardState, tool: 'text', penColor: colorToSet, changingCmdId: EmptyCommandUid }) expect(getTextEditorColor(drawingPlugInPoint)).toBe(colorToSet) } - const lastSetColor = 'red' + const lastSetColor = 'red' as ColorMetaNameOrHex drawingBoard.update?.({ ...boardState, tool: 'text', penColor: lastSetColor, changingCmdId: EmptyCommandUid }) expect(getTextEditorColor(drawingPlugInPoint)).toBe(lastSetColor) @@ -250,7 +258,12 @@ describe('drawing module tests', () => { // 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 }) + drawingBoard.update?.({ + ...boardState, + tool: 'pen', + penColor: 'magenta' as ColorMetaNameOrHex, + changingCmdId: undefined + }) setTextEditorText(drawingPlugInPoint, setTextContent + setTextContent) @@ -342,7 +355,7 @@ describe('drawing module tests', () => { const newText = 'New Text' setTextEditorText(drawingPlugInPoint, newText) - const newColor = 'yellow' + const newColor = 'yellow' as ColorMetaNameOrHex drawingBoard.update?.({ ...boardState, tool: 'text', changingCmdId: textCommandUid, penColor: newColor }) // commit text editing @@ -453,7 +466,10 @@ describe('drawing module tests', () => { const pointerMovedSpy = jest.fn() drawing(drawingPlugInPoint, { + colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]], readonly: false, + getCurrentTheme: () => 'theme-light', + subscribeOnThemeChange: () => {}, imageWidth: 400, imageHeight: 300, commands: [], @@ -485,13 +501,16 @@ describe('drawing module tests', () => { const backgroundImageWidth = 400 const backgroundImageHeight = 300 drawing(drawingPlugInPoint, { + colorsList: [['alpha', new ThemeAwareColor('red', 'yellow')]], readonly: false, + getCurrentTheme: () => 'theme-light', + subscribeOnThemeChange: () => {}, autoSize: false, imageWidth: backgroundImageWidth, imageHeight: backgroundImageHeight, commands: [], tool: 'pen', - penColor: 'red', + penColor: 'red' as ColorMetaNameOrHex, cmdAdded: commandAddedSpy, pointerMoved: pointerMovedSpy }) diff --git a/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts b/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts index 3b971ce96b..1c659ac7f9 100644 --- a/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts +++ b/packages/presentation/src/___tests___/drawingCommandsProcessor.test.ts @@ -14,9 +14,9 @@ // import { DrawingCommandsProcessor, UndoRedoAvailability } from '../drawingCommandsProcessor' -import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawing' +import { makeCommandUid, type DrawingCmd, type DrawTextCmd, type DrawLineCmd } from '../drawingCommand' import { type Array as YArray, Doc as YDoc } from 'yjs' -import { makeCanvasPoint } from '../drawingUtils' +import { type ColorMetaNameOrHex, makeCanvasPoint } from '../drawingUtils' const makeTextCommand = (overrides: Partial = {}): DrawTextCmd => ({ id: makeCommandUid(), @@ -25,7 +25,7 @@ const makeTextCommand = (overrides: Partial = {}): DrawTextCmd => ( pos: makeCanvasPoint(13, 17), fontSize: 11, fontFace: 'Arial', - color: 'red', + color: 'red' as ColorMetaNameOrHex, ...overrides }) @@ -34,7 +34,7 @@ const makeLineCommand = (overrides: Partial = {}): DrawLineCmd => ( type: 'line', lineWidth: 3, erasing: false, - penColor: 'blue', + penColor: 'blue' as ColorMetaNameOrHex, points: [makeCanvasPoint(1, 3), makeCanvasPoint(11, 13)], ...overrides }) @@ -163,13 +163,13 @@ describe('DrawingCommandsProcessor Tests', () => { it('existing command change', () => { const originalCommand = makeTextCommand({ text: 'Original', - color: 'red' + color: 'red' as ColorMetaNameOrHex }) const changedCommand: DrawTextCmd = { ...originalCommand, text: 'Changed', - color: 'blue' + color: 'blue' as ColorMetaNameOrHex } systemUnderTest.addCommand(originalCommand) @@ -189,19 +189,19 @@ describe('DrawingCommandsProcessor Tests', () => { it('order preservation', () => { const first = makeTextCommand({ text: 'First', - color: 'red' + color: 'red' as ColorMetaNameOrHex }) const second = makeTextCommand({ text: 'Second', pos: makeCanvasPoint(20, 20), - color: 'blue' + color: 'blue' as ColorMetaNameOrHex }) const third = makeTextCommand({ text: 'Third', pos: makeCanvasPoint(30, 30), - color: 'green' + color: 'green' as ColorMetaNameOrHex }) systemUnderTest.addCommand(first) @@ -391,7 +391,7 @@ describe('DrawingCommandsProcessor Tests', () => { const changedTextCommand: DrawTextCmd = { ...textCommand, text: 'Changed Text', - color: 'green' + color: 'green' as ColorMetaNameOrHex } systemUnderTest.changeCommand(changedTextCommand) expect(systemUnderTest.snapshot()).toEqual([changedTextCommand, lineCommand]) @@ -419,7 +419,7 @@ describe('DrawingCommandsProcessor Tests', () => { const cmd = makeTextCommand({ text: `Command ${i}`, pos: makeCanvasPoint(i * 10, i * 10), - color: 'black' + color: 'black' as ColorMetaNameOrHex }) commands.push(cmd) systemUnderTest.addCommand(cmd) diff --git a/packages/presentation/src/___tests___/drawingUtils.test.ts b/packages/presentation/src/___tests___/drawingUtils.test.ts index a2daee8983..b195a5fdc4 100644 --- a/packages/presentation/src/___tests___/drawingUtils.test.ts +++ b/packages/presentation/src/___tests___/drawingUtils.test.ts @@ -13,7 +13,83 @@ // limitations under the License. // -import { rescaleToFitAspectRatio, scalePoint, offsetPoint, offsetInParent, type Point } from '../drawingUtils' +import { ThemeAwareColor, type ColorsList, DrawingBoardColoringSetup, metaColorNameToHex } from '../drawingColors' +import { + rescaleToFitAspectRatio, + scalePoint, + offsetPoint, + offsetInParent, + type Point, + type ColorMetaName, + type ColorMetaNameOrHex +} from '../drawingUtils' +import { ThemeVariant, type ThemeVariantType } from '@hcengineering/theme' + +jest.mock('@hcengineering/theme', () => ({ + ThemeVariant: { + Dark: 'dark', + Light: 'light' + } +})) + +const StubPlatformColors: Record = { + Firework: { + light: { + name: 'Firework', + color: '#D15045', + title: '#C03B2F', + icon: '#D15045', + number: '#C03B2F', + background: '#C03B2F' + }, + dark: { + name: 'Firework', + color: '#D15045', + title: '#FFFFFF', + icon: '#D15045', + number: '#FFFFFF', + background: '#C03B2F' + } + }, + Sky: { + light: { + name: 'Sky', + color: '#4CA6EE', + title: '#1F90EA', + icon: '#4CA6EE', + number: '#1F90EA', + background: '#1F90EA' + }, + dark: { name: 'Sky', color: '#4CA6EE', title: '#FFFFFF', icon: '#4CA6EE', number: '#FFFFFF', background: '#1F90EA' } + }, + Grass: { + light: { + name: 'Grass', + color: '#83AF12', + title: '#60810E', + icon: '#83AF12', + number: '#60810E', + background: '#60810E' + }, + dark: { + name: 'Grass', + color: '#83AF12', + title: '#FFFFFF', + icon: '#83AF12', + number: '#FFFFFF', + background: '#83AF12' + } + } +} +jest.mock('@hcengineering/ui', () => ({ + getPlatformColorByName: jest.fn().mockImplementation((name: string, darkTheme: boolean) => { + const colorDefinition = StubPlatformColors[name] + if (colorDefinition == null) { + return undefined + } + return darkTheme ? colorDefinition.dark : colorDefinition.light + }) +})) describe('drawingUtils module tests', () => { describe('scalePoint', () => { @@ -210,4 +286,128 @@ describe('drawingUtils module tests', () => { expect(result).toEqual(expected) }) }) + + describe('ThemeAwareColor', () => { + interface ThemeTestCase { + theme: ThemeVariantType + expected: string + } + const themeUnknownColorsTestCases: ThemeTestCase[] = [ + { theme: ThemeVariant.Dark, expected: 'DarkColor' }, + { theme: ThemeVariant.Light, expected: 'LightColor' } + ] + it.each(themeUnknownColorsTestCases)("materialize unknown color for '$theme' theme", ({ theme, expected }) => { + const systemUnderTest = new ThemeAwareColor('DarkColor', 'LightColor') + const result = systemUnderTest.materialize(theme) + expect(result).toBe(expected) + }) + const themeKnownColorsTestCases: ThemeTestCase[] = [ + { theme: ThemeVariant.Dark, expected: StubPlatformColors.Firework.dark.color }, + { theme: ThemeVariant.Light, expected: StubPlatformColors.Firework.light.color } + ] + it.each(themeKnownColorsTestCases)("materialize known color for '$theme' theme", ({ theme, expected }) => { + const systemUnderTest = new ThemeAwareColor( + StubPlatformColors.Firework.dark.name, + StubPlatformColors.Firework.light.name + ) + const result = systemUnderTest.materialize(theme) + expect(result).toBe(expected) + }) + + it('materialize hex colors', () => { + const expectedDarkColor = '#000000' + const expectedLightColor = '#FFFFFF' + const systemUnderTest = new ThemeAwareColor(expectedDarkColor, expectedLightColor) + + expect(systemUnderTest.materialize(ThemeVariant.Dark)).toBe(expectedDarkColor) + expect(systemUnderTest.materialize(ThemeVariant.Light)).toBe(expectedLightColor) + }) + }) + + describe('DrawingBoardColoringSetup', () => { + const stubColorsList: ColorsList = [ + ['alpha', new ThemeAwareColor('#000000', '#FFFFFF')], + ['beta', new ThemeAwareColor('#FF0000', '#00FF00')], + ['gamma', new ThemeAwareColor('#0000FF', '#FFFF00')] + ] + + it('construction', () => { + const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList) + expect(systemUnderTest.allColors).toBe(stubColorsList) + }) + + it('colorByName, known color', () => { + const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList) + + const actualColor = systemUnderTest.colorByName('alpha') + + const expectedColor = stubColorsList[0][1] + expect(actualColor).toBe(expectedColor) + }) + + it('colorByName, unknown color', () => { + const systemUnderTest = new DrawingBoardColoringSetup(stubColorsList) + + const actualColor = systemUnderTest.colorByName('unknown' as ColorMetaName) + + expect(actualColor).toBeUndefined() + }) + + it('construction with empty list', () => { + const systemUnderTest = new DrawingBoardColoringSetup([]) + + expect(systemUnderTest.allColors).toEqual([]) + expect(systemUnderTest.colorByName('alpha')).toBeUndefined() + }) + }) + + describe('metaColorNameToHex', () => { + const AlphaDarkColor = '#000000' + const AlphaLightColor = '#FFFFFF' + const testColorsList: ColorsList = [ + ['alpha', new ThemeAwareColor(AlphaDarkColor, AlphaLightColor)], + ['beta', new ThemeAwareColor('Firework', 'Sky')], + ['gamma', new ThemeAwareColor('Grass', 'Grass')] + ] + const colorsSetupStub = new DrawingBoardColoringSetup(testColorsList) + + it('hex color, 7 characters', () => { + const hexColor = '#FF5733' as ColorMetaNameOrHex + const actualColor = metaColorNameToHex(hexColor, ThemeVariant.Dark, colorsSetupStub) + expect(actualColor).toBe(hexColor) + }) + + it('hex color, 4 characters', () => { + const hexColor = '#F53' as ColorMetaNameOrHex + const actualColor = metaColorNameToHex(hexColor, ThemeVariant.Dark, colorsSetupStub) + expect(actualColor).toBe(hexColor) + }) + + it('meta color name, dark theme', () => { + const colorName = 'alpha' as ColorMetaNameOrHex + const actualColor = metaColorNameToHex(colorName, ThemeVariant.Dark, colorsSetupStub) + expect(actualColor).toBe(AlphaDarkColor) + }) + + it('meta color name, light theme', () => { + const colorName = 'alpha' as ColorMetaNameOrHex + const actualColor = metaColorNameToHex(colorName, ThemeVariant.Light, colorsSetupStub) + expect(actualColor).toBe(AlphaLightColor) + }) + + it('unknown color name', () => { + const unknownColor = 'unknown' as ColorMetaNameOrHex + const actualColor = metaColorNameToHex(unknownColor, ThemeVariant.Dark, colorsSetupStub) + expect(actualColor).toBe(unknownColor) + }) + + it('should handle platform colors with same name for both themes', () => { + const gammaColor = 'gamma' as ColorMetaNameOrHex + const darkResult = metaColorNameToHex(gammaColor, ThemeVariant.Dark, colorsSetupStub) + const lightResult = metaColorNameToHex(gammaColor, ThemeVariant.Light, colorsSetupStub) + + expect(darkResult).toBe('#83AF12') + expect(lightResult).toBe('#83AF12') + }) + }) }) diff --git a/packages/presentation/src/__mocks__/README.md b/packages/presentation/src/__mocks__/README.md new file mode 100644 index 0000000000..5e6bf62711 --- /dev/null +++ b/packages/presentation/src/__mocks__/README.md @@ -0,0 +1,119 @@ +# Svelte Testing Strategy + +This project uses an enhanced mocking approach for Svelte components and stores in Jest tests, inspired by Svelte Testing Library patterns but adapted for our current Jest setup. + +## Current Approach: Enhanced Svelte Mocking + +### What We Have +- **Robust Svelte Store Mocks**: Our `svelte-store.js` mock provides functional implementations of `writable`, `derived`, and `readable` stores with testing helpers +- **Complete Svelte Ecosystem Coverage**: Mocks for `svelte`, `svelte/transition`, `svelte/animate`, and `.svelte` components +- **Jest Integration**: Works seamlessly with our existing Jest configuration without requiring ESM mode + +### Benefits +- ✅ **Simple Setup**: No complex ESM configuration required +- ✅ **Fast Tests**: Lightweight mocks don't slow down test execution +- ✅ **Focused Testing**: Tests logic rather than UI components +- ✅ **Backward Compatible**: Works with existing Jest setup + +### When This Approach Works Best +- Testing business logic that uses Svelte stores +- Testing utility functions that depend on Svelte ecosystem +- Integration tests that need to mock Svelte dependencies +- When you don't need to test actual Svelte component rendering + +## Future: Migration to Svelte Testing Library + +### When to Consider Migration +Consider migrating to full Svelte Testing Library when you need to: +- **Test Svelte Components**: Render and interact with actual `.svelte` components +- **Test User Interactions**: Simulate clicks, form inputs, etc. on Svelte components +- **Test Component Props**: Verify how components respond to different prop values +- **Test Component Events**: Verify custom event dispatching + +### Migration Steps (Future) +If you need full Svelte Testing Library support: + +1. **Install Dependencies**: + ```bash + npm install --save-dev @testing-library/svelte svelte-jester jest-environment-jsdom + ``` + +2. **Update Jest Configuration** to ESM mode: + ```javascript + export default { + transform: { + '^.+\\.svelte(\\.(js|ts))?$': 'svelte-jester', + }, + transformIgnorePatterns: [ + '/node_modules/(?!@testing-library/svelte/)', + ], + moduleFileExtensions: ['js', 'svelte', 'ts'], + extensionsToTreatAsEsm: ['.svelte'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest-setup.js'], + } + ``` + +3. **Update Package.json** scripts: + ```json + { + "scripts": { + "test": "npx --node-options=\"--experimental-vm-modules\" jest src" + } + } + ``` + +### Example Test with Svelte Testing Library +```javascript +import { render, screen, fireEvent } from '@testing-library/svelte' +import MyComponent from './MyComponent.svelte' + +test('renders and handles click', async () => { + render(MyComponent, { props: { title: 'Hello' } }) + + const button = screen.getByRole('button') + await fireEvent.click(button) + + expect(screen.getByText('Clicked!')).toBeInTheDocument() +}) +``` + +## Current Mock Files + +### `__mocks__/svelte-store.js` +- Functional implementations of Svelte stores +- Testing helpers like `_getValue()` and `get()` +- Proper subscriber management + +### `__mocks__/svelte.js` +- Core Svelte lifecycle functions (`onMount`, `onDestroy`) +- Utility functions (`tick`, `createEventDispatcher`) + +### `__mocks__/svelte-component.js` +- Mock for `.svelte` files +- Returns simple DOM element for component imports + +### `__mocks__/svelte-transition.js` & `__mocks__/svelte-animate.js` +- Animation and transition function mocks +- Return basic CSS transformation objects + +## Best Practices + +1. **Use Store Testing Helpers**: Leverage `_getValue()` for easy store value assertions +2. **Focus on Logic**: Test business logic, not UI rendering details +3. **Mock Appropriately**: Only mock what you need to avoid test complexity +4. **Consider Migration**: Plan for Svelte Testing Library when component testing becomes necessary + +## Example Usage + +```javascript +import { get } from '../__mocks__/svelte-store.js' + +test('store behavior', () => { + const store = writable(0) + store.set(5) + + expect(get(store)).toBe(5) + expect(store._getSubscriberCount()).toBe(0) +}) +``` diff --git a/packages/presentation/src/__mocks__/setup.ts b/packages/presentation/src/__mocks__/setup.ts new file mode 100644 index 0000000000..0fd6b2fe73 --- /dev/null +++ b/packages/presentation/src/__mocks__/setup.ts @@ -0,0 +1,81 @@ +// +// 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. +// + +// TypeScript setup for Jest test environment + +declare global { + interface Window { + getComputedStyle: (element: Element, pseudoElt?: string | null) => CSSStyleDeclaration + } +} + +// Mock getComputedStyle +Object.defineProperty(window, 'getComputedStyle', { + value: (): Partial => ({ + getPropertyValue: (): string => '' + }) +}) + +// Canvas API mock interface +interface MockCanvasRenderingContext2D { + clearRect: jest.MockedFunction<() => void> + beginPath: jest.MockedFunction<() => void> + moveTo: jest.MockedFunction<() => void> + lineTo: jest.MockedFunction<() => void> + stroke: jest.MockedFunction<() => void> + fill: jest.MockedFunction<() => void> + strokeText: jest.MockedFunction<() => void> + fillText: jest.MockedFunction<() => void> + arc: jest.MockedFunction<() => void> + save: jest.MockedFunction<() => void> + restore: jest.MockedFunction<() => void> + translate: jest.MockedFunction<() => void> + scale: jest.MockedFunction<() => void> + rotate: jest.MockedFunction<() => void> + setTransform: jest.MockedFunction<() => void> + drawImage: jest.MockedFunction<() => void> + createImageData: jest.MockedFunction<() => void> + getImageData: jest.MockedFunction<() => void> + putImageData: jest.MockedFunction<() => void> + measureText: jest.MockedFunction<() => { width: number }> +} + +// Canvas API mock if needed +Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + value: (): MockCanvasRenderingContext2D => ({ + clearRect: jest.fn(), + beginPath: jest.fn(), + moveTo: jest.fn(), + lineTo: jest.fn(), + stroke: jest.fn(), + fill: jest.fn(), + strokeText: jest.fn(), + fillText: jest.fn(), + arc: jest.fn(), + save: jest.fn(), + restore: jest.fn(), + translate: jest.fn(), + scale: jest.fn(), + rotate: jest.fn(), + setTransform: jest.fn(), + drawImage: jest.fn(), + createImageData: jest.fn(), + getImageData: jest.fn(), + putImageData: jest.fn(), + measureText: jest.fn().mockReturnValue({ width: 0 }) + }) +}) + +export {} diff --git a/packages/presentation/src/__mocks__/svelte-animate.ts b/packages/presentation/src/__mocks__/svelte-animate.ts new file mode 100644 index 0000000000..ce21a7a5e7 --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte-animate.ts @@ -0,0 +1,39 @@ +// +// 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. +// + +// TypeScript mock for svelte/animate + +export type EasingFunction = (t: number) => number + +export interface AnimationConfig { + delay?: number + duration?: number | ((len: number) => number) + easing?: EasingFunction + css?: (t: number, u: number) => string + tick?: (t: number, u: number) => void +} + +export interface FlipParams { + delay?: number + duration?: number | ((len: number) => number) + easing?: EasingFunction +} + +export const flip = (params: FlipParams = {}): AnimationConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? ((d: number) => Math.sqrt(d) * 120), + easing: params.easing ?? ((t: number) => t), + css: (t: number, u: number) => `transform: translate(${u * t}px, ${u * t}px)` +}) diff --git a/packages/presentation/src/__mocks__/svelte-component.ts b/packages/presentation/src/__mocks__/svelte-component.ts new file mode 100644 index 0000000000..bf8a6012ee --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte-component.ts @@ -0,0 +1,18 @@ +// +// 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. +// + +// TypeScript mock for .svelte components + +export default 'div' // mock as a simple div element diff --git a/packages/presentation/src/__mocks__/svelte-runtime.ts b/packages/presentation/src/__mocks__/svelte-runtime.ts new file mode 100644 index 0000000000..7af07453f4 --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte-runtime.ts @@ -0,0 +1,22 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +// TypeScript mock for @hcengineering/platform-rig/profiles/ui/svelte +// This is used in Jest tests to avoid loading the actual Svelte runtime +// which uses ES modules that Jest doesn't handle well by default + +// This file is intentionally empty as the import is just for TypeScript definitions +// and doesn't need any runtime behavior in tests +export default {} diff --git a/packages/presentation/src/__mocks__/svelte-store.ts b/packages/presentation/src/__mocks__/svelte-store.ts new file mode 100644 index 0000000000..e00680b945 --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte-store.ts @@ -0,0 +1,137 @@ +// +// 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. +// + +// Enhanced TypeScript mock for svelte/store inspired by Svelte Testing Library patterns +// This provides more accurate Jest-compatible implementations of Svelte stores + +export type Subscriber = (value: T) => void +export type Unsubscriber = () => void +export type Updater = (value: T) => T +export type StartStopNotifier = (set: (value: T) => void) => Unsubscriber | undefined + +export interface Readable { + subscribe: (run: Subscriber, invalidate?: any) => Unsubscriber + _getValue?: () => T +} + +export interface Writable extends Readable { + set: (value: T) => void + update: (updater: Updater) => void + _getSubscriberCount?: () => number +} + +export interface Derived extends Readable { + _getValue?: () => T +} + +export const writable = (initialValue: T): Writable => { + let value = initialValue + const subscribers = new Set>() + + const store: Writable = { + subscribe: (callback: Subscriber) => { + subscribers.add(callback) + callback(value) + return () => subscribers.delete(callback) + }, + set: (newValue: T) => { + value = newValue + subscribers.forEach((callback) => { + callback(value) + }) + }, + update: (updater: Updater) => { + value = updater(value) + subscribers.forEach((callback) => { + callback(value) + }) + }, + _getValue: () => value, + _getSubscriberCount: () => subscribers.size + } + + return store +} + +export const derived = ( + stores: Readable | Array>, + fn: (values: any) => T, + initialValue?: T +): Derived => { + const storeArray = Array.isArray(stores) ? stores : [stores] + + let computedValue: T + try { + // provide reasonable defaults for testing + const values = storeArray.map((store) => { + if (store != null && typeof store.subscribe === 'function') { + return store._getValue != null ? store._getValue() : [] + } + return [] + }) + + computedValue = Array.isArray(stores) ? fn(values) : fn(values[0]) + } catch (e) { + computedValue = initialValue ?? ([] as any) + } + + const store: Derived = { + subscribe: (callback: Subscriber) => { + callback(computedValue) + return () => {} + }, + _getValue: () => computedValue + } + + return store +} + +export const readable = (initialValue: T, startStopNotifier?: StartStopNotifier): Readable => { + const store: Readable = { + subscribe: (callback: Subscriber) => { + callback(initialValue) + + if (typeof startStopNotifier === 'function') { + const stop = startStopNotifier((newValue: T) => { + callback(newValue) + }) + + return () => { + if (typeof stop === 'function') { + stop() + } + } + } + + return () => {} + }, + _getValue: () => initialValue + } + + return store +} + +export const get = (store: Readable): T => { + if (store?._getValue != null) { + return store._getValue() + } + + let value: T | undefined + const unsubscribe = store.subscribe((v: T) => { + value = v + }) + unsubscribe() + return value as T +} diff --git a/packages/presentation/src/__mocks__/svelte-transition.ts b/packages/presentation/src/__mocks__/svelte-transition.ts new file mode 100644 index 0000000000..49cae1aee5 --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte-transition.ts @@ -0,0 +1,102 @@ +// +// 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. +// + +// TypeScript mock for svelte/transition + +export type EasingFunction = (t: number) => number + +export interface TransitionConfig { + delay?: number + duration?: number | ((from: number, to: number) => number) + easing?: EasingFunction + css?: (t: number, u: number) => string + tick?: (t: number, u: number) => void +} + +export interface FadeParams { + delay?: number + duration?: number + easing?: EasingFunction +} + +export interface FlyParams extends FadeParams { + x?: number + y?: number + opacity?: number +} + +export interface SlideParams extends FadeParams { + axis?: 'x' | 'y' +} + +export interface ScaleParams extends FadeParams { + start?: number + opacity?: number +} + +export interface DrawParams extends FadeParams { + speed?: number +} + +export const fade = (params: FadeParams = {}): TransitionConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? 400, + easing: params.easing ?? ((t: number) => t), + css: (t: number, u: number) => `opacity: ${t}` +}) + +export const fly = (params: FlyParams = {}): TransitionConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? 400, + easing: params.easing ?? ((t: number) => t), + css: (t: number, u: number) => + `transform: translate(${u * (params.x ?? 20)}px, ${u * (params.y ?? 0)}px); opacity: ${t * (params.opacity ?? 1)}` +}) + +export const slide = (params: SlideParams = {}): TransitionConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? 400, + easing: params.easing ?? ((t: number) => t), + css: (t: number, u: number) => { + const property = params.axis === 'x' ? 'width' : 'height' + return `${property}: ${t * 100}%` + } +}) + +export const scale = (params: ScaleParams = {}): TransitionConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? 400, + easing: params.easing ?? ((t: number) => t), + css: (t: number, u: number) => `transform: scale(${t * (params.start ?? 1)}); opacity: ${t * (params.opacity ?? 1)}` +}) + +export const draw = (params: DrawParams = {}): TransitionConfig => ({ + delay: params.delay ?? 0, + duration: params.duration ?? 800, + easing: params.easing ?? ((t: number) => t), + css: (t: number) => `stroke-dasharray: ${t * (params.speed ?? 100)}` +}) + +export const crossfade = (): { + fallback: typeof fade + send: typeof fade + receive: typeof fade +} => { + return { + fallback: fade, + send: fade, + receive: fade + } +} diff --git a/packages/presentation/src/__mocks__/svelte.ts b/packages/presentation/src/__mocks__/svelte.ts new file mode 100644 index 0000000000..836e28d3d6 --- /dev/null +++ b/packages/presentation/src/__mocks__/svelte.ts @@ -0,0 +1,43 @@ +// +// 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. +// + +// TypeScript mock for svelte + +export type MountCallback = () => undefined | (() => void) +export type DestroyCallback = () => void +export type EventDispatcher = any> = ( + type: K, + detail?: T[K] +) => boolean + +export const onMount = (fn: MountCallback): undefined | (() => void) => { + if (typeof fn === 'function') { + return fn() + } +} + +export const onDestroy = (fn: DestroyCallback): DestroyCallback => { + return fn +} + +export const tick = async (): Promise => { + await Promise.resolve() +} + +export const createEventDispatcher = = any>(): EventDispatcher => { + return (eventType: K, detail?: T[K]): boolean => { + return true + } +} diff --git a/packages/presentation/src/components/DrawingBoard.svelte b/packages/presentation/src/components/DrawingBoard.svelte index 3d8cc7cf42..2f426d2fc3 100644 --- a/packages/presentation/src/components/DrawingBoard.svelte +++ b/packages/presentation/src/components/DrawingBoard.svelte @@ -16,17 +16,14 @@ import { Analytics } from '@hcengineering/analytics' import { resizeObserver } from '@hcengineering/ui' import { onMount, onDestroy } from 'svelte' - import { - CommandUid, - drawing, - type DrawingCmd, - type DrawingData, - type DrawingTool, - type DrawTextCmd - } from '../drawing' + import { drawing, type DrawingData, type DrawingTool } from '../drawing' import DrawingBoardToolbar from './DrawingBoardToolbar.svelte' import { DrawingCommandsProcessor } from '../drawingCommandsProcessor' import { Doc as YDoc } from 'yjs' + import { ColorMetaNameOrHex } from '../drawingUtils' + import { themeStore } from '@hcengineering/theme' + import { ColorsList, ThemeAwareColor } from '../drawingColors' + import { DrawingCmd, CommandUid, DrawTextCmd } from '../drawingCommand' export let active = false export let readonly = true @@ -36,7 +33,7 @@ export let createDrawing: (data: any) => Promise let tool: DrawingTool - let penColor: string + let penColor: ColorMetaNameOrHex let penWidth: number let eraserWidth: number let fontSize: number @@ -52,6 +49,21 @@ let disableUndo: boolean = false let disableRedo: boolean = false + const themeChangeUnsubscribe: Array<() => void> = [] + + const DrawingColorPalette: ColorsList = [ + ['alpha', new ThemeAwareColor('#000', '#000')], + ['beta', new ThemeAwareColor('#FFF', '#FFF')], + ['gamma', new ThemeAwareColor('Fuchsia', 'Fuchsia')], + ['delta', new ThemeAwareColor('Houseplant', 'Houseplant')], + ['epsilon', new ThemeAwareColor('Sky', 'Sky')], + ['zeta', new ThemeAwareColor('Turquoise', 'Turquoise')], + ['eta', new ThemeAwareColor('Pink', 'Pink')], + ['theta', new ThemeAwareColor('Cloud', 'Cloud')], + ['iota', new ThemeAwareColor('#FFC114', '#FFC114')], + ['kappa', new ThemeAwareColor('Mauve', 'Mauve')] + ] + const document: YDoc = new YDoc() const undoableCommands = document.getArray('drawing-commands') const commandProcessor = new DrawingCommandsProcessor(document, undoableCommands) @@ -185,7 +197,13 @@ }) onDestroy(() => { + themeChangeUnsubscribe.forEach((unsubscribe) => { + unsubscribe() + }) + themeChangeUnsubscribe.length = 0 + undoableCommands.unobserve(onSavedCommandsChanged) + saveDrawing() }) @@ -200,6 +218,11 @@ }} use:drawing={{ autoSize: imageWidth === undefined || imageHeight === undefined, + colorsList: DrawingColorPalette, + getCurrentTheme: () => $themeStore.variant, + subscribeOnThemeChange: (callback) => { + themeChangeUnsubscribe.push(themeStore.subscribe(callback)) + }, readonly, imageWidth, imageHeight, @@ -225,6 +248,7 @@ {#if !readonly} + + + +
+ + diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte index 61b2d0c303..b4532cee65 100644 --- a/packages/presentation/src/components/DrawingBoardToolbar.svelte +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -27,6 +27,15 @@ showPopup } from '@hcengineering/ui' import { createEventDispatcher, onMount } from 'svelte' + 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' + import { ColorMetaName, ColorMetaNameOrHex } from '../drawingUtils' + import DrawingBoardToolbarColorIcon from './DrawingBoardToolbarColorIcon.svelte' + import DrawingBoardColorSelectorIcon from './DrawingBoardColorSelectorIcon.svelte' + import { ColorsList, DrawingBoardColoringSetup } from '../drawingColors' interface DrawingBoardToolbarEvents { undo: undefined @@ -34,17 +43,11 @@ 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 maxColors = 8 const minColors = 0 - const defaultColor = '#0000ff' - const defaultColors = ['#ff0000', '#00ff00', '#0000ff', '#ffffff', '#000000'] + const defaultColor: ColorMetaName = 'alpha' + const defaultColors: Array = ['alpha', 'gamma', 'delta', 'epsilon'] const storageKey = { color: 'drawingBoard.color', colors: 'drawingBoard.colors', @@ -54,7 +57,7 @@ } export let tool: DrawingTool = 'pen' - export let penColor: string + export let penColor: ColorMetaNameOrHex export let penWidth: number export let eraserWidth: number export let fontSize: number @@ -64,22 +67,23 @@ export let cmdEditor: HTMLDivElement | undefined export let disableUndo: boolean = false export let disableRedo: boolean = false + export let colorsList: ColorsList - let colorSelector: HTMLInputElement - let colorsPalette: string[] = defaultColors + const availableColors = new DrawingBoardColoringSetup(colorsList) + let userSelectedPalette: ColorMetaNameOrHex[] = defaultColors type PaletteCommandId = 'add-color' | 'remove-color' | 'reset-colors' function showPaletteManagementMenu (ev: MouseEvent): void { const items: Array & { id: PaletteCommandId }> = [] - if (colorsPalette.length < maxColors) { + if (userSelectedPalette.length < maxColors) { items.push({ id: 'add-color', label: presentation.string.ColorAdd, icon: IconAdd }) } - if (colorsPalette.length > minColors) { + if (userSelectedPalette.length > minColors) { items.push({ id: 'remove-color', label: presentation.string.ColorRemove, @@ -91,26 +95,34 @@ label: presentation.string.ColorReset, icon: IconRedo }) + showPopup(SelectPopup, { value: items }, eventToHTMLElement(ev), (id: PaletteCommandId | undefined) => { switch (id) { case 'add-color': { - if (colorSelector !== undefined) { - colorSelector.value = penColor - colorSelector.showPicker() - } + const colorsRange: Array = colorsList.map((color, index) => ({ + id: index, + icon: DrawingBoardColorSelectorIcon, + iconProps: { color: color[0], palette: availableColors } + })) + showPopup(SelectPopup, { value: colorsRange }, eventToHTMLElement(ev), (id) => { + if (id != null) { + penColor = colorsList[id][0] + addColorPreset() + } + }) break } case 'remove-color': { - colorsPalette = colorsPalette.filter((c: string) => c !== penColor) - localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette)) - selectColor(colorsPalette[0]) + userSelectedPalette = userSelectedPalette.filter((c: string) => c !== penColor) + localStorage.setItem(storageKey.colors, JSON.stringify(userSelectedPalette)) + selectColor(userSelectedPalette[0]) focusEditor() break } case 'reset-colors': { - colorsPalette = defaultColors + userSelectedPalette = defaultColors localStorage.removeItem(storageKey.colors) - selectColor(colorsPalette[0]) + selectColor(userSelectedPalette[0]) focusEditor() break } @@ -127,15 +139,14 @@ } function addColorPreset (): void { - penColor = penColor.toLowerCase() - if (!colorsPalette.includes(penColor)) { - colorsPalette = [...colorsPalette, penColor] - localStorage.setItem(storageKey.colors, JSON.stringify(colorsPalette)) + if (!userSelectedPalette.includes(penColor)) { + userSelectedPalette = [...userSelectedPalette, penColor] + localStorage.setItem(storageKey.colors, JSON.stringify(userSelectedPalette)) } focusEditor() } - function selectColor (color: string): void { + function selectColor (color: ColorMetaNameOrHex): void { penColor = color ?? defaultColor localStorage.setItem(storageKey.color, penColor) } @@ -143,13 +154,13 @@ onMount(() => { try { const savedColors = localStorage.getItem(storageKey.colors) - colorsPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors + userSelectedPalette = savedColors !== null ? JSON.parse(savedColors.toLowerCase()) : defaultColors } catch { - colorsPalette = defaultColors + userSelectedPalette = defaultColors } - penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor).toLowerCase() - if (!colorsPalette.includes(penColor)) { - penColor = colorsPalette[0] ?? defaultColor + penColor = (localStorage.getItem(storageKey.color) ?? penColor ?? defaultColor) as ColorMetaNameOrHex + if (!userSelectedPalette.includes(penColor)) { + penColor = userSelectedPalette[0] ?? defaultColor } penWidth = parseInt(localStorage.getItem(storageKey.penWidth) ?? '4') eraserWidth = parseInt(localStorage.getItem(storageKey.eraserWidth) ?? '50') @@ -288,12 +299,11 @@ />
{/if} - {#each colorsPalette as color} + {#each userSelectedPalette as color} {/each}
-