refactor(ui)!: migrate ConfigContext to LocaleService and remove global-zone services (#6930)

This commit is contained in:
白熱
2026-05-21 20:34:58 +08:00
committed by GitHub
parent d1e62ba800
commit 0839f630b8
14 changed files with 409 additions and 360 deletions
@@ -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 <Messager theme={darkMode ? 'dark' : 'light'} dir={direction} />;
}
@@ -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 <Toaster theme={darkMode ? 'dark' : 'light'} dir={direction} />;
}
-3
View File
@@ -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';
-3
View File
@@ -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 }],
-3
View File
@@ -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 }],
@@ -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> = {}): 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);
});
});
@@ -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<boolean>();
readonly componentKey$ = new Subject<string>();
private _componentKey = '';
constructor(
@Inject(ComponentManager)
private readonly _componentManager: ComponentManager
) {
// empty
}
get componentKey() {
return this._componentKey;
}
set(key: string, component: ForwardRefExoticComponent<any>): 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);
}
}
@@ -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<IGlobalZoneService>('univer.global-zone-service');
export interface IGlobalZoneService {
readonly visible$: Subject<boolean>;
readonly componentKey$: Subject<string>;
get componentKey(): string;
set(key: string, component: any): IDisposable;
open(): void;
close(): void;
}
@@ -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<boolean>(menuItem.disabled$, false);
const activated = useObservable<boolean>(menuItem.activated$, false);
@@ -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;
@@ -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 (
<section
dir={direction}
className={clsx(
'univer-absolute univer-bg-gray-100',
{
'univer-hidden': !visible,
'univer-inset-0 univer-z-[100] univer-block univer-size-full': visible,
}
)}
>
{Component && <Component />}
</section>
);
}
@@ -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 = (
<>
@@ -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) {
<ComponentContainer key="footer" components={footerComponents} />
</footer>
)}
<GlobalZone />
<ZenZone />
</section>
</div>
@@ -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) {
</footer>
)}
<ZenZone />
</section>
</div>
<div dir={direction}>
<ComponentContainer key="global" components={globalComponents} />
<GlobalZone />
{contextMenu && <DesktopContextMenu />}
<FloatingContainer />
<div id={popupRootId} />