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 (
-
- );
-}
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 && }