mirror of
https://github.com/dream-num/univer.git
synced 2026-10-25 10:27:03 +08:00
refactor(ui)!: migrate ConfigContext to LocaleService and remove global-zone services (#6930)
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user