diff --git a/packages/ui/src/components/message/MessageContainer.tsx b/packages/ui/src/components/message/MessageContainer.tsx index fad8fafff1..c48db38fa6 100644 --- a/packages/ui/src/components/message/MessageContainer.tsx +++ b/packages/ui/src/components/message/MessageContainer.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ -import { ThemeService } from '@univerjs/core'; -import { ConfigContext, Messager } from '@univerjs/design'; -import { useContext } from 'react'; +import { LocaleService, ThemeService } from '@univerjs/core'; +import { Messager } from '@univerjs/design'; import { useDependency, useObservable } from '../../utils/di'; export function MessageContainer() { + const localeService = useDependency(LocaleService); const themeService = useDependency(ThemeService); const darkMode = useObservable(themeService.darkMode$); - const { direction } = useContext(ConfigContext); + const direction = useObservable(localeService.direction$); return ; } diff --git a/packages/ui/src/components/notification/Notification.tsx b/packages/ui/src/components/notification/Notification.tsx index adf7ab1922..044a68106c 100644 --- a/packages/ui/src/components/notification/Notification.tsx +++ b/packages/ui/src/components/notification/Notification.tsx @@ -15,16 +15,16 @@ */ import type { IToasterProps } from '@univerjs/design'; -import { ThemeService } from '@univerjs/core'; -import { ConfigContext, toast, Toaster } from '@univerjs/design'; -import { useContext } from 'react'; +import { LocaleService, ThemeService } from '@univerjs/core'; +import { toast, Toaster } from '@univerjs/design'; import { useDependency, useObservable } from '../../utils/di'; export function Notification() { + const localeService = useDependency(LocaleService); const themeService = useDependency(ThemeService); const darkMode = useObservable(themeService.darkMode$); - const { direction } = useContext(ConfigContext); + const direction = useObservable(localeService.direction$); return ; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 450a166e17..aea5193084 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -72,8 +72,6 @@ export { CanvasFloatDomService, type IFloatDom, type IFloatDomLayout } from './s export { type IFontConfig, IFontService } from './services/font.service'; export { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; export { IGalleryService } from './services/gallery/gallery.service'; -export { DesktopGlobalZoneService } from './services/global-zone/desktop-global-zone.service'; -export { IGlobalZoneService } from './services/global-zone/global-zone.service'; export { DesktopLayoutService, ILayoutService } from './services/layout/layout.service'; export { DesktopLocalFileService } from './services/local-file/desktop-local-file.service'; export { ILocalFileService, type IOpenFileOptions } from './services/local-file/local-file.service'; @@ -134,7 +132,6 @@ export { type IDialogPartMethodOptions } from './views/components/dialog-part/in export { FloatDomSingle } from './views/components/dom/FloatDom'; export { FloatDom } from './views/components/dom/FloatDom'; export { PrintFloatDomSingle } from './views/components/dom/Print'; -export { GlobalZone } from './views/components/global-zone/GlobalZone'; export { CanvasPopup, SingleCanvasPopup } from './views/components/popup/CanvasPopup'; export { RectPopup, type RectPopupDirection } from './views/components/popup/RectPopup'; export { useToolbarItemStatus } from './views/components/ribbon/hook'; diff --git a/packages/ui/src/mobile-plugin.ts b/packages/ui/src/mobile-plugin.ts index 9f708c064c..4ff08668ed 100644 --- a/packages/ui/src/mobile-plugin.ts +++ b/packages/ui/src/mobile-plugin.ts @@ -37,8 +37,6 @@ import { CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; import { FontService, IFontService } from './services/font.service'; import { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; import { IGalleryService } from './services/gallery/gallery.service'; -import { DesktopGlobalZoneService } from './services/global-zone/desktop-global-zone.service'; -import { IGlobalZoneService } from './services/global-zone/global-zone.service'; import { DesktopLayoutService, ILayoutService } from './services/layout/layout.service'; import { DesktopLocalFileService } from './services/local-file/desktop-local-file.service'; import { ILocalFileService } from './services/local-file/local-file.service'; @@ -118,7 +116,6 @@ export class UniverMobileUIPlugin extends Plugin { [IConfirmService, { useClass: DesktopConfirmService, lazy: true }], [ISidebarService, { useClass: DesktopSidebarService, lazy: true }], [IZenZoneService, { useClass: DesktopZenZoneService, lazy: true }], - [IGlobalZoneService, { useClass: DesktopGlobalZoneService, lazy: true }], [IMessageService, { useClass: DesktopMessageService, lazy: true }], [ILocalStorageService, { useClass: DesktopLocalStorageService, lazy: true }], [IBeforeCloseService, { useClass: DesktopBeforeCloseService }], diff --git a/packages/ui/src/plugin.ts b/packages/ui/src/plugin.ts index acb7357ba2..57c420e81a 100644 --- a/packages/ui/src/plugin.ts +++ b/packages/ui/src/plugin.ts @@ -37,8 +37,6 @@ import { CanvasFloatDomService } from './services/dom/canvas-dom-layer.service'; import { FontService, IFontService } from './services/font.service'; import { DesktopGalleryService } from './services/gallery/desktop-gallery.service'; import { IGalleryService } from './services/gallery/gallery.service'; -import { DesktopGlobalZoneService } from './services/global-zone/desktop-global-zone.service'; -import { IGlobalZoneService } from './services/global-zone/global-zone.service'; import { DesktopLayoutService, ILayoutService } from './services/layout/layout.service'; import { DesktopLocalFileService } from './services/local-file/desktop-local-file.service'; import { ILocalFileService } from './services/local-file/local-file.service'; @@ -118,7 +116,6 @@ export class UniverUIPlugin extends Plugin { [IConfirmService, { useClass: DesktopConfirmService, lazy: true }], [ISidebarService, { useClass: DesktopSidebarService, lazy: true }], [IZenZoneService, { useClass: DesktopZenZoneService, lazy: true }], - [IGlobalZoneService, { useClass: DesktopGlobalZoneService, lazy: true }], [IMessageService, { useClass: DesktopMessageService, lazy: true }], [ILocalStorageService, { useClass: DesktopLocalStorageService, lazy: true }], [IBeforeCloseService, { useClass: DesktopBeforeCloseService }], diff --git a/packages/ui/src/services/__tests__/misc-services.spec.ts b/packages/ui/src/services/__tests__/misc-services.spec.ts index 509ca6aa2b..831e66455b 100644 --- a/packages/ui/src/services/__tests__/misc-services.spec.ts +++ b/packages/ui/src/services/__tests__/misc-services.spec.ts @@ -19,64 +19,54 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { supportClipboardAPI } from '../clipboard/clipboard-utils'; import { ContextMenuHostService } from '../contextmenu/contextmenu-host.service'; import { ContextMenuService } from '../contextmenu/contextmenu.service'; -import { IDialogService } from '../dialog/dialog.service'; import { CanvasFloatDomService } from '../dom/canvas-dom-layer.service'; -import { IGalleryService } from '../gallery/gallery.service'; -import { DesktopGlobalZoneService } from '../global-zone/desktop-global-zone.service'; -import { IGlobalZoneService } from '../global-zone/global-zone.service'; -import { ILocalFileService } from '../local-file/local-file.service'; import { isMenuButtonSelectorItem, isMenuSelectorItem, MenuItemType } from '../menu/menu'; -import { IMessageService } from '../message/message.service'; -import { INotificationService } from '../notification/notification.service'; import { CanvasPopupService } from '../popup/canvas-popup.service'; import { DesktopSidebarService } from '../sidebar/desktop-sidebar.service'; -import { ILeftSidebarService, ISidebarService } from '../sidebar/sidebar.service'; import { ThemeSwitcherService } from '../theme-switcher/theme-switcher.service'; import { DesktopZenZoneService } from '../zen-zone/desktop-zen-zone.service'; -import { IZenZoneService } from '../zen-zone/zen-zone.service'; -describe('simple exported identifiers and helpers', () => { +describe('clipboard capability detection', () => { afterEach(() => { vi.restoreAllMocks(); }); - it('should expose identifier tokens', () => { - expect(IGalleryService).toBeDefined(); - expect(IGlobalZoneService).toBeDefined(); - expect(IDialogService).toBeDefined(); - expect(ILocalFileService).toBeDefined(); - expect(IMessageService).toBeDefined(); - expect(INotificationService).toBeDefined(); - expect(ISidebarService).toBeDefined(); - expect(ILeftSidebarService).toBeDefined(); - expect(IZenZoneService).toBeDefined(); - }); - - it('should detect clipboard api support', () => { + it('should report clipboard as unsupported when navigator.clipboard is missing', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); expect(supportClipboardAPI()).toBe(false); + }); + it('should report clipboard as supported when navigator.clipboard.readText is available', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, - value: { - readText: vi.fn(), - }, + value: { readText: vi.fn() }, }); expect(supportClipboardAPI()).toBe(true); }); +}); - it('should detect menu selector item types', () => { +describe('menu item type guards', () => { + it('should recognize selector and subitems as menu selector items', () => { expect(isMenuSelectorItem({ type: MenuItemType.SELECTOR } as any)).toBe(true); expect(isMenuSelectorItem({ type: MenuItemType.SUBITEMS } as any)).toBe(true); - expect(isMenuSelectorItem({ type: MenuItemType.BUTTON_SELECTOR } as any)).toBe(false); + }); + it('should not recognize button or button-selector as menu selector items', () => { + expect(isMenuSelectorItem({ type: MenuItemType.BUTTON } as any)).toBe(false); + expect(isMenuSelectorItem({ type: MenuItemType.BUTTON_SELECTOR } as any)).toBe(false); + }); + + it('should recognize button-selector as menu button-selector item', () => { expect(isMenuButtonSelectorItem({ type: MenuItemType.BUTTON_SELECTOR } as any)).toBe(true); + }); + + it('should not recognize plain button as menu button-selector item', () => { expect(isMenuButtonSelectorItem({ type: MenuItemType.BUTTON } as any)).toBe(false); }); }); -describe('context menu services', () => { - it('should delegate context menu events to current handler', () => { +describe('context menu service', () => { + it('should forward context menu events to the registered handler', () => { const service = new ContextMenuService(); const handler = { visible: true, @@ -84,112 +74,261 @@ describe('context menu services', () => { hideContextMenu: vi.fn(), }; - const dis = service.registerContextMenuHandler(handler); - expect(() => service.registerContextMenuHandler(handler)).toThrow(); + service.registerContextMenuHandler(handler); - const event = { - stopPropagation: vi.fn(), - } as any; + const event = { stopPropagation: vi.fn() } as any; service.triggerContextMenu(event, 'main'); - expect(event.stopPropagation).toHaveBeenCalledTimes(1); - expect(handler.handleContextMenu).toHaveBeenCalledWith(event, 'main'); + expect(service.visible).toBe(true); - - service.disable(); - service.triggerContextMenu(event, 'disabled'); - expect(handler.handleContextMenu).toHaveBeenCalledTimes(1); - - service.enable(); - service.hideContextMenu(); - expect(handler.hideContextMenu).toHaveBeenCalledTimes(1); - - dis.dispose(); - expect(service.visible).toBe(false); + expect(handler.handleContextMenu).toHaveBeenCalledWith(event, 'main'); }); - it('should manage active host menu', () => { + it('should suppress context menu events while disabled', () => { + const service = new ContextMenuService(); + const handler = { + visible: true, + handleContextMenu: vi.fn(), + hideContextMenu: vi.fn(), + }; + + service.registerContextMenuHandler(handler); + service.disable(); + + const event = { stopPropagation: vi.fn() } as any; + service.triggerContextMenu(event, 'main'); + + expect(service.disabled).toBe(true); + expect(handler.handleContextMenu).not.toHaveBeenCalled(); + }); + + it('should resume forwarding after re-enabling', () => { + const service = new ContextMenuService(); + const handler = { + visible: true, + handleContextMenu: vi.fn(), + hideContextMenu: vi.fn(), + }; + + service.registerContextMenuHandler(handler); + service.disable(); + service.enable(); + + const event = { stopPropagation: vi.fn() } as any; + service.triggerContextMenu(event, 'main'); + + expect(handler.handleContextMenu).toHaveBeenCalledTimes(1); + }); + + it('should hide the context menu through the handler', () => { + const service = new ContextMenuService(); + const handler = { + visible: true, + handleContextMenu: vi.fn(), + hideContextMenu: vi.fn(), + }; + + service.registerContextMenuHandler(handler); + service.hideContextMenu(); + expect(handler.hideContextMenu).toHaveBeenCalledTimes(1); + }); + + it('should throw when registering a handler twice', () => { + const service = new ContextMenuService(); + const handler = { visible: true, handleContextMenu: vi.fn(), hideContextMenu: vi.fn() }; + + service.registerContextMenuHandler(handler); + expect(() => service.registerContextMenuHandler(handler)).toThrow(); + }); + + it('should clean up visibility when the handler is disposed', () => { + const service = new ContextMenuService(); + const handler = { + visible: true, + handleContextMenu: vi.fn(), + hideContextMenu: vi.fn(), + }; + + const disposable = service.registerContextMenuHandler(handler); + service.triggerContextMenu({ stopPropagation: vi.fn() } as any, 'main'); + expect(service.visible).toBe(true); + + disposable.dispose(); + expect(service.visible).toBe(false); + }); +}); + +describe('context menu host service', () => { + it('should activate a registered menu and track it as the active menu', () => { + const service = new ContextMenuHostService(); + const hide = vi.fn(); + + service.registerMenu('menu-a', hide); + service.activateMenu('menu-a'); + + expect(service.activeMenuId).toBe('menu-a'); + }); + + it('should hide the previous menu when activating a different one', () => { const service = new ContextMenuHostService(); const hideA = vi.fn(); const hideB = vi.fn(); - const disA = service.registerMenu('a', hideA); - const disB = service.registerMenu('b', hideB); + service.registerMenu('menu-a', hideA); + service.registerMenu('menu-b', hideB); - service.activateMenu('a'); - expect(service.activeMenuId).toBe('a'); + service.activateMenu('menu-a'); + service.activateMenu('menu-b'); - service.activateMenu('b'); expect(hideA).toHaveBeenCalledTimes(1); - expect(service.activeMenuId).toBe('b'); + expect(service.activeMenuId).toBe('menu-b'); + }); - service.hideActiveMenu('b'); - expect(hideB).toHaveBeenCalledTimes(0); + it('should hide the active menu when requested', () => { + const service = new ContextMenuHostService(); + const hide = vi.fn(); + service.registerMenu('menu-a', hide); + service.activateMenu('menu-a'); service.hideActiveMenu(); - expect(hideB).toHaveBeenCalledTimes(1); - expect(service.activeMenuId).toBeNull(); - service.deactivateMenu('b'); - disA.dispose(); - disB.dispose(); + expect(hide).toHaveBeenCalledTimes(1); expect(service.activeMenuId).toBeNull(); }); + + it('should skip hiding when the active menu matches the excepted id', () => { + const service = new ContextMenuHostService(); + const hide = vi.fn(); + + service.registerMenu('menu-a', hide); + service.activateMenu('menu-a'); + service.hideActiveMenu('menu-a'); + + expect(hide).not.toHaveBeenCalled(); + expect(service.activeMenuId).toBe('menu-a'); + }); + + it('should clear active state when a menu is deactivated', () => { + const service = new ContextMenuHostService(); + + service.registerMenu('menu-a', vi.fn()); + service.activateMenu('menu-a'); + service.deactivateMenu('menu-a'); + + expect(service.activeMenuId).toBeNull(); + }); + + it('should allow re-activating a menu after it was deactivated', () => { + const service = new ContextMenuHostService(); + + service.registerMenu('menu-a', vi.fn()); + service.activateMenu('menu-a'); + service.deactivateMenu('menu-a'); + service.activateMenu('menu-a'); + + expect(service.activeMenuId).toBe('menu-a'); + }); }); -describe('CanvasFloatDomService', () => { - it('should add, update, remove and clear float dom items', () => { - const service = new CanvasFloatDomService(); - const events: any[] = []; - service.domLayers$.subscribe((value) => events.push(value)); - - service.addFloatDom({ - id: 'dom-1', - componentKey: 'comp', +describe('canvas float DOM service', () => { + function createFloatDom(id: string): any { + return { + id, + componentKey: 'test-component', + unitId: 'unit-1', onPointerMove: vi.fn(), onPointerDown: vi.fn(), onPointerUp: vi.fn(), onWheel: vi.fn(), - unitId: 'u1', position$: new BehaviorSubject({ left: 0, top: 0, rotate: 0, - width: 10, - height: 10, + width: 100, + height: 100, absolute: { left: true, top: true }, }), - } as any); + }; + } - expect(service.domLayers.length).toBe(1); + it('should reflect newly added float DOM in the domLayers list', () => { + const service = new CanvasFloatDomService(); + service.addFloatDom(createFloatDom('dom-1')); - service.updateFloatDom('dom-1', { componentKey: 'comp2' } as any); - expect(service.domLayers[0][1].componentKey).toBe('comp2'); + expect(service.domLayers).toHaveLength(1); + expect(service.domLayers[0][0]).toBe('dom-1'); + }); - service.updateFloatDom('not-found', { componentKey: 'x' } as any); + it('should update an existing float DOM by id', () => { + const service = new CanvasFloatDomService(); + service.addFloatDom(createFloatDom('dom-1')); + service.updateFloatDom('dom-1', { componentKey: 'updated-component' } as any); + expect(service.domLayers[0][1].componentKey).toBe('updated-component'); + }); + + it('should silently ignore updates to non-existent float DOM ids', () => { + const service = new CanvasFloatDomService(); + service.addFloatDom(createFloatDom('dom-1')); + + expect(() => service.updateFloatDom('missing', { componentKey: 'x' } as any)).not.toThrow(); + expect(service.domLayers).toHaveLength(1); + }); + + it('should remove a float DOM by id', () => { + const service = new CanvasFloatDomService(); + service.addFloatDom(createFloatDom('dom-1')); + service.addFloatDom(createFloatDom('dom-2')); service.removeFloatDom('dom-1'); - expect(service.domLayers.length).toBe(0); - service.removeFloatDom('dom-1'); + expect(service.domLayers).toHaveLength(1); + expect(service.domLayers[0][0]).toBe('dom-2'); + }); + + it('should clear all float DOMs when removeAll is called', () => { + const service = new CanvasFloatDomService(); + service.addFloatDom(createFloatDom('dom-1')); + service.addFloatDom(createFloatDom('dom-2')); service.removeAll(); - expect(events.length).toBeGreaterThanOrEqual(4); + + expect(service.domLayers).toHaveLength(0); + }); + + it('should emit domLayers changes through the observable', () => { + const service = new CanvasFloatDomService(); + const emissions: any[] = []; + service.domLayers$.subscribe((layers) => emissions.push(layers.length)); + + service.addFloatDom(createFloatDom('dom-1')); + service.addFloatDom(createFloatDom('dom-2')); + service.removeFloatDom('dom-1'); + + expect(emissions.at(-1)).toBe(1); }); }); -describe('CanvasPopupService', () => { - it('should add/remove popups and track active popup id', () => { - const service = new CanvasPopupService(); - - const id = service.addPopup({ - componentKey: 'popup', - unitId: 'u', - subUnitId: 's', +describe('canvas popup service', () => { + function createPopup(overrides: Partial = {}): any { + return { + componentKey: 'test-popup', + unitId: 'unit-1', + subUnitId: 'sheet-1', canvasElement: document.createElement('canvas'), - anchorRect$: new BehaviorSubject({} as any), - } as any); + anchorRect$: new BehaviorSubject({}), + ...overrides, + }; + } + + it('should assign a unique id when adding a popup', () => { + const service = new CanvasPopupService(); + const id = service.addPopup(createPopup()); expect(id).toBeTruthy(); - expect(service.popups.length).toBe(1); + expect(service.popups).toHaveLength(1); + }); + + it('should track the active popup id when onActiveChange is triggered', () => { + const service = new CanvasPopupService(); + const id = service.addPopup(createPopup()); const popup = service.popups[0][1]; popup.onActiveChange?.(true); @@ -197,137 +336,215 @@ describe('CanvasPopupService', () => { popup.onActiveChange?.(false); expect(service.activePopupId).toBeNull(); + }); + + it('should remove a popup by id and clear the activePopupId if it was active', () => { + const service = new CanvasPopupService(); + const id = service.addPopup(createPopup()); + + service.popups[0][1].onActiveChange?.(true); + expect(service.activePopupId).toBe(id); - service.removePopup('not-found'); service.removePopup(id); - expect(service.popups.length).toBe(0); + expect(service.popups).toHaveLength(0); + }); - service.addPopup({ - componentKey: 'popup-2', - unitId: 'u2', - subUnitId: 's2', - canvasElement: document.createElement('canvas'), - anchorRect$: new BehaviorSubject({} as any), - } as any); + it('should ignore removal of non-existent popup ids', () => { + const service = new CanvasPopupService(); + service.addPopup(createPopup()); + expect(() => service.removePopup('non-existent')).not.toThrow(); + expect(service.popups).toHaveLength(1); + }); + + it('should remove all popups and reset state', () => { + const service = new CanvasPopupService(); + service.addPopup(createPopup()); + service.addPopup(createPopup()); service.removeAll(); - expect(service.popups).toEqual([]); + expect(service.popups).toHaveLength(0); + expect(service.activePopupId).toBeNull(); + }); + + it('should clear all popups on dispose', () => { + const service = new CanvasPopupService(); + service.addPopup(createPopup()); service.dispose(); - expect(service.popups).toEqual([]); + + expect(service.popups).toHaveLength(0); }); }); -describe('DesktopSidebarService', () => { - it('should open and close sidebar with id checks and container', () => { +describe('sidebar service', () => { + it('should become visible after opening a sidebar panel', () => { + const service = new DesktopSidebarService(); + service.open({ id: 'panel-1' }); + + expect(service.visible).toBe(true); + expect(service.options.id).toBe('panel-1'); + }); + + it('should ignore close requests with a mismatched panel id', () => { + const service = new DesktopSidebarService(); + service.open({ id: 'panel-1' }); + service.close('panel-2'); + + expect(service.visible).toBe(true); + }); + + it('should hide the sidebar and invoke onClose when the correct id is closed', () => { const service = new DesktopSidebarService(); const onClose = vi.fn(); - const emitted: any[] = []; - service.sidebarOptions$.subscribe((v) => emitted.push(v)); + service.open({ id: 'panel-1', onClose }); + service.close('panel-1'); - const dis = service.open({ id: 'side-1', title: 'A', onClose } as any); - expect(service.visible).toBe(true); - expect(service.options.id).toBe('side-1'); - - service.close('other-id'); - expect(service.visible).toBe(true); - - service.close('side-1'); expect(service.visible).toBe(false); expect(onClose).toHaveBeenCalledTimes(1); + }); + it('should invoke onClose when the open disposable is disposed', () => { + const service = new DesktopSidebarService(); + const onClose = vi.fn(); + const disposable = service.open({ id: 'panel-1', onClose }); + + disposable.dispose(); + expect(onClose).toHaveBeenCalledTimes(1); + expect(service.visible).toBe(false); + }); + + it('should replace the current panel when opening a different one', () => { + const service = new DesktopSidebarService(); + service.open({ id: 'panel-1' }); + service.open({ id: 'panel-2' }); + + expect(service.options.id).toBe('panel-2'); + expect(service.visible).toBe(true); + }); + + it('should allow setting and retrieving the sidebar container element', () => { + const service = new DesktopSidebarService(); const container = document.createElement('div'); + service.setContainer(container); expect(service.getContainer()).toBe(container); + }); - dis.dispose(); - expect(service.visible).toBe(false); - expect(emitted.length).toBeGreaterThanOrEqual(2); + it('should notify subscribers when the sidebar state changes', () => { + const service = new DesktopSidebarService(); + const states: any[] = []; + service.sidebarOptions$.subscribe((opts) => states.push(opts)); + + service.open({ id: 'panel-1' }); + service.close('panel-1'); + + expect(states.length).toBeGreaterThanOrEqual(2); }); }); -describe('ThemeSwitcherService', () => { - it('should inject and replace theme css variables in document head', () => { +describe('theme switcher service', () => { + afterEach(() => { + const existing = document.getElementById('univer-theme-css-variables'); + if (existing) { + existing.remove(); + } + }); + + it('should inject theme CSS variables into the document head', () => { const service = new ThemeSwitcherService(); service.injectThemeToHead({ - color: { - primary: '#fff', - }, + color: { primary: '#ffffff' }, spacing: 8, } as any); const style = document.getElementById('univer-theme-css-variables'); expect(style).toBeTruthy(); - expect(style?.innerHTML).toContain('--univer-color-primary'); + expect(style?.textContent).toContain('--univer-color-primary: #ffffff'); + expect(style?.textContent).toContain('--univer-spacing: 8'); + }); - service.injectThemeToHead({ - color: { - primary: '#000', - }, - } as any); + it('should replace the existing style element instead of creating duplicates', () => { + const service = new ThemeSwitcherService(); + service.injectThemeToHead({ color: { primary: '#fff' } } as any); + service.injectThemeToHead({ color: { primary: '#000' } } as any); const styles = document.querySelectorAll('#univer-theme-css-variables'); expect(styles.length).toBe(1); - - service.dispose(); + expect(styles[0]?.textContent).toContain('#000'); }); }); -describe('zone services', () => { - it('should handle DesktopGlobalZoneService lifecycle', () => { - const manager = { +describe('zen zone service', () => { + function createComponentManager() { + return { register: vi.fn(), delete: vi.fn(), }; - const service = new DesktopGlobalZoneService(manager as any); + } - const visibleValues: boolean[] = []; - const keyValues: string[] = []; - service.visible$.subscribe((v) => visibleValues.push(v)); - service.componentKey$.subscribe((v) => keyValues.push(v)); - - const dis = service.set('zone-key', (() => null) as any); - expect(manager.register).toHaveBeenCalledWith('zone-key', expect.any(Function)); - expect(service.componentKey).toBe('zone-key'); - expect(keyValues).toEqual(['zone-key']); - - service.open(); - service.close(); - expect(visibleValues).toEqual([true, false]); - - dis.dispose(); - expect(manager.delete).toHaveBeenCalledWith('zone-key'); - }); - - it('should handle DesktopZenZoneService lifecycle', () => { - const manager = { - register: vi.fn(), - delete: vi.fn(), - }; + it('should register the component and emit its key through componentKey$', () => { + const manager = createComponentManager(); const service = new DesktopZenZoneService(manager as any); - const visibleValues: boolean[] = []; - const hiddenValues: boolean[] = []; - service.visible$.subscribe((v) => visibleValues.push(v)); - service.temporaryHidden$.subscribe((v) => hiddenValues.push(v)); + const keys: string[] = []; + service.componentKey$.subscribe((k) => keys.push(k)); - const dis = service.set('zen-key', (() => null) as any); - expect(manager.register).toHaveBeenCalledWith('zen-key', expect.any(Function)); + const dummyComponent = () => null; + service.set('zen-editor', dummyComponent); - service.hide(); - service.show(); - expect(service.temporaryHidden).toBe(false); + expect(manager.register).toHaveBeenCalledWith('zen-editor', dummyComponent); + expect(keys).toContain('zen-editor'); + }); + + it('should transition visible state when opening and closing', () => { + const manager = createComponentManager(); + const service = new DesktopZenZoneService(manager as any); + service.set('zen-editor', () => null); + + expect(service.visible).toBe(false); service.open(); expect(service.visible).toBe(true); + service.close(); expect(service.visible).toBe(false); + }); - dis.dispose(); - expect(manager.delete).toHaveBeenCalledWith('zen-key'); + it('should temporarily hide without affecting the underlying visible state', () => { + const manager = createComponentManager(); + const service = new DesktopZenZoneService(manager as any); + service.set('zen-editor', () => null); + service.open(); + service.hide(); + expect(service.temporaryHidden).toBe(true); + expect(service.visible).toBe(true); + + service.show(); + expect(service.temporaryHidden).toBe(false); + }); + + it('should unregister the component when the set disposable is disposed', () => { + const manager = createComponentManager(); + const service = new DesktopZenZoneService(manager as any); + + const disposable = service.set('zen-editor', () => null); + disposable.dispose(); + + expect(manager.delete).toHaveBeenCalledWith('zen-editor'); + }); + + it('should emit false through visible$ on dispose', () => { + const manager = createComponentManager(); + const service = new DesktopZenZoneService(manager as any); + service.set('zen-editor', () => null); + + const visibleValues: boolean[] = []; + service.visible$.subscribe((v) => visibleValues.push(v)); + + service.open(); service.dispose(); + expect(visibleValues.at(-1)).toBe(false); - expect(hiddenValues).toContain(true); }); }); diff --git a/packages/ui/src/services/global-zone/desktop-global-zone.service.ts b/packages/ui/src/services/global-zone/desktop-global-zone.service.ts deleted file mode 100644 index deade5b144..0000000000 --- a/packages/ui/src/services/global-zone/desktop-global-zone.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright 2023-present DreamNum Co., Ltd. - * - * Licensed under the Apache 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 - * - * http://www.apache.org/licenses/LICENSE-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 type { IDisposable } from '@univerjs/core'; -import type { ForwardRefExoticComponent } from 'react'; -import type { IGlobalZoneService } from './global-zone.service'; -import { Inject, toDisposable } from '@univerjs/core'; -import { Subject } from 'rxjs'; -import { ComponentManager } from '../../common/component-manager'; - -export class DesktopGlobalZoneService implements IGlobalZoneService { - readonly visible$ = new Subject(); - readonly componentKey$ = new Subject(); - private _componentKey = ''; - - constructor( - @Inject(ComponentManager) - private readonly _componentManager: ComponentManager - ) { - // empty - } - - get componentKey() { - return this._componentKey; - } - - set(key: string, component: ForwardRefExoticComponent): IDisposable { - this._componentManager.register(key, component); - this.componentKey$.next(key); - this._componentKey = key; - - return toDisposable(() => { - this._componentManager.delete(key); - this.visible$.complete(); - this.componentKey$.complete(); - }); - } - - open(): void { - this.visible$.next(true); - } - - close() { - this.visible$.next(false); - } -} diff --git a/packages/ui/src/services/global-zone/global-zone.service.ts b/packages/ui/src/services/global-zone/global-zone.service.ts deleted file mode 100644 index 41f155e818..0000000000 --- a/packages/ui/src/services/global-zone/global-zone.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright 2023-present DreamNum Co., Ltd. - * - * Licensed under the Apache 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 - * - * http://www.apache.org/licenses/LICENSE-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 type { IDisposable } from '@univerjs/core'; -import type { Subject } from 'rxjs'; -import { createIdentifier } from '@univerjs/core'; - -export const IGlobalZoneService = createIdentifier('univer.global-zone-service'); - -export interface IGlobalZoneService { - readonly visible$: Subject; - readonly componentKey$: Subject; - - get componentKey(): string; - - set(key: string, component: any): IDisposable; - - open(): void; - - close(): void; -} diff --git a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx index 21d26f0692..bd77317262 100644 --- a/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx +++ b/packages/ui/src/views/components/context-menu/ContextMenuPanel.tsx @@ -24,9 +24,9 @@ import type { } from '../../../services/menu/menu'; import type { IMenuSchema } from '../../../services/menu/menu-manager.service'; import { isRealNum, LocaleService } from '@univerjs/core'; -import { borderBottomClassName, borderClassName, clsx, ConfigContext, scrollbarClassName } from '@univerjs/design'; +import { borderBottomClassName, borderClassName, clsx, scrollbarClassName } from '@univerjs/design'; import { CheckMarkIcon, MoreIcon } from '@univerjs/icons'; -import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { combineLatest, isObservable, of, scan, startWith } from 'rxjs'; import { CustomLabel } from '../../../components/custom-label/CustomLabel'; @@ -272,7 +272,8 @@ function ContextMenuMenu(props: IContextMenuMenuProps) { function ContextMenuMenuItem(props: IContextMenuMenuItemProps) { const { menuKey, menuItem, menuSessionVersion, submenuPortalContainer, onOptionSelect, maxMenuHeight } = props; - const { direction } = useContext(ConfigContext); + const localeService = useDependency(LocaleService); + const direction = useObservable(localeService.direction$); const menuManagerService = useDependency(IMenuManagerService); const disabled = useObservable(menuItem.disabled$, false); const activated = useObservable(menuItem.activated$, false); diff --git a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx index fcc0c24e28..5ed28dc721 100644 --- a/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx +++ b/packages/ui/src/views/components/context-menu/MobileContextMenu.tsx @@ -25,7 +25,7 @@ import { IContextMenuHostService } from '../../../services/contextmenu/contextme import { IContextMenuService } from '../../../services/contextmenu/contextmenu.service'; import { ILayoutService } from '../../../services/layout/layout.service'; import { ContextMenuPosition } from '../../../services/menu/types'; -import { useDependency } from '../../../utils/di'; +import { useDependency, useObservable } from '../../../utils/di'; const MOBILE_CONTEXT_MENU_HOST_ID = 'mobile-context-menu'; @@ -38,7 +38,8 @@ export function MobileContextMenu() { const commandService = useDependency(ICommandService); const layoutService = useDependency(ILayoutService); const localeService = useDependency(LocaleService); - const { mountContainer, direction } = useContext(ConfigContext); + const direction = useObservable(localeService.direction$); + const { mountContainer } = useContext(ConfigContext); visibleRef.current = visible; diff --git a/packages/ui/src/views/components/global-zone/GlobalZone.tsx b/packages/ui/src/views/components/global-zone/GlobalZone.tsx deleted file mode 100644 index 171997d222..0000000000 --- a/packages/ui/src/views/components/global-zone/GlobalZone.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright 2023-present DreamNum Co., Ltd. - * - * Licensed under the Apache 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 - * - * http://www.apache.org/licenses/LICENSE-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 { clsx, ConfigContext } from '@univerjs/design'; -import { useContext, useEffect, useMemo, useState } from 'react'; -import { ComponentManager } from '../../../common/component-manager'; -import { IGlobalZoneService } from '../../../services/global-zone/global-zone.service'; -import { useDependency, useObservable } from '../../../utils/di'; - -export function GlobalZone() { - const globalZoneService = useDependency(IGlobalZoneService); - const [visible, setVisible] = useState(false); - const componentKey = useObservable(globalZoneService.componentKey$, globalZoneService.componentKey); - const componentManager = useDependency(ComponentManager); - const { direction } = useContext(ConfigContext); - - const Component = useMemo(() => { - const Component = componentManager.get(componentKey ?? ''); - if (Component) { - return Component; - } - }, [componentKey, componentManager]); - - useEffect(() => { - const subscription = globalZoneService.visible$.subscribe((val) => { - setVisible(val); - }); - - return () => { - subscription.unsubscribe(); - }; - }, [globalZoneService.visible$]); - - if (!visible) { - return null; - } - - return ( -
- {Component && } -
- ); -} diff --git a/packages/ui/src/views/components/popup/RectPopup.tsx b/packages/ui/src/views/components/popup/RectPopup.tsx index 4da280f7ee..785c54684f 100644 --- a/packages/ui/src/views/components/popup/RectPopup.tsx +++ b/packages/ui/src/views/components/popup/RectPopup.tsx @@ -18,13 +18,13 @@ import type { Nullable } from '@univerjs/core'; import type { ReactNode, RefObject } from 'react'; import type { Observable } from 'rxjs'; import type { IUniverUIConfig } from '../../../config/config'; -import { IConfigService } from '@univerjs/core'; -import { clsx, ConfigContext } from '@univerjs/design'; +import { IConfigService, LocaleService } from '@univerjs/core'; +import { clsx } from '@univerjs/design'; import { createContext, useContext, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useEvent } from '../../../components/hooks/event'; import { UI_PLUGIN_CONFIG_KEY } from '../../../config/config'; -import { useDependency } from '../../../utils/di'; +import { useDependency, useObservable } from '../../../utils/di'; interface IAbsolutePosition { left: number; @@ -311,7 +311,8 @@ function RectPopup(props: IRectPopupProps) { }; }, [contextMenuFn]); - const { direction: dir } = useContext(ConfigContext); + const localeService = useDependency(LocaleService); + const dir = useObservable(localeService.direction$); const ele = ( <> diff --git a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx index f827c0fead..f997222c73 100644 --- a/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx +++ b/packages/ui/src/views/mobile-workbench/MobileWorkbench.tsx @@ -23,7 +23,6 @@ import { ThemeSwitcherService } from '../../services/theme-switcher/theme-switch import { useDependency } from '../../utils/di'; import { ComponentContainer, useComponentsOfPart } from '../components/ComponentContainer'; import { MobileContextMenu } from '../components/context-menu/MobileContextMenu'; -import { GlobalZone } from '../components/global-zone/GlobalZone'; import { Sidebar } from '../components/sidebar/Sidebar'; import { ZenZone } from '../components/zen-zone/ZenZone'; @@ -199,7 +198,6 @@ export function MobileWorkbench(props: IUniverAppProps) { )} - diff --git a/packages/ui/src/views/workbench/Workbench.tsx b/packages/ui/src/views/workbench/Workbench.tsx index 95d2226655..ddb210c475 100644 --- a/packages/ui/src/views/workbench/Workbench.tsx +++ b/packages/ui/src/views/workbench/Workbench.tsx @@ -27,7 +27,6 @@ import { ThemeSwitcherService } from '../../services/theme-switcher/theme-switch import { useDependency } from '../../utils/di'; import { ComponentContainer, useComponentsOfPart } from '../components/ComponentContainer'; import { DesktopContextMenu } from '../components/context-menu/ContextMenu'; -import { GlobalZone } from '../components/global-zone/GlobalZone'; import { Sidebar } from '../components/sidebar/Sidebar'; import { ZenZone } from '../components/zen-zone/ZenZone'; @@ -232,12 +231,11 @@ export function DesktopWorkbenchContent(props: IUniverWorkbenchProps) { )} - +
- {contextMenu && }