snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
/**
* 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 { IUniverUIConfig } from '../../config/config';
import { ConfigService, IConfigService, Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UI_PLUGIN_CONFIG_KEY } from '../../config/config';
import { FontService, IFontService } from '../font.service';
class CustomFontConfigService extends ConfigService {
override getConfig<T>(key: string): T {
if (key === UI_PLUGIN_CONFIG_KEY) {
return {
customFontFamily: {
override: true,
list: [{ value: 'OnlyCustom', label: 'Only Custom', category: 'display' }],
},
} as IUniverUIConfig as T;
}
return super.getConfig<T>(key);
}
}
function createService(configService: new (...args: any[]) => IConfigService = ConfigService): IFontService {
const injector = new Injector();
injector.add([IConfigService, { useClass: configService }]);
injector.add([IFontService, { useClass: FontService }]);
return injector.get(IFontService);
}
describe('FontService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('maintains the font catalog used by font family pickers', () => {
const service = createService();
service.addFont({ value: 'Inter', label: 'Inter', category: 'sans-serif' });
service.updateFont('Inter', { label: 'Inter UI' });
expect(service.getFontByValue('Inter')).toMatchObject({ label: 'Inter UI' });
expect(service.removeFont('Inter')).toBe(true);
expect(service.getFontByValue('Inter')).toBeUndefined();
});
it('uses the filtered reference catalog as the default font list', () => {
const values = createService().getFonts().map((font) => font.value);
expect(values).toHaveLength(197);
expect(values.slice(0, 8)).toEqual([
'宋体',
'Arial',
'Baskerville',
'Comic Sans MS',
'Courier New',
'Helvetica',
'Times New Roman',
'Verdana',
]);
expect(values).toContain('Zapfino');
expect(values).not.toContain('Microsoft YaHei');
expect(values).not.toContain('正文字体');
expect(values.some((value) => value.startsWith('MiSans'))).toBe(false);
expect(values.some((value) => value.toUpperCase().includes('WPS'))).toBe(false);
});
it('rejects duplicate font values to keep font selection stable', () => {
const service = createService();
const arial = service.getFontByValue('Arial')!;
expect(() => service.addFont(arial)).toThrow('[FontService]: Font with value "Arial" already exists.');
});
it('loads override font configuration and rejects updates to missing fonts', () => {
const service = createService(CustomFontConfigService);
expect(service.getFonts()).toEqual([{ value: 'OnlyCustom', label: 'Only Custom', category: 'display' }]);
expect(() => service.updateFont('Arial', { label: 'Missing' })).toThrow('Font with value "Arial" not found.');
expect(service.removeFont('Arial')).toBe(false);
});
it('checks font support by comparing measured text widths', () => {
const measured: string[] = [];
vi.stubGlobal('document', {
createElement: () => ({
getContext: () => ({
font: '',
measureText() {
measured.push(this.font);
return { width: this.font.includes('"CustomFont"') ? 120 : 100 };
},
}),
}),
});
const service = createService();
expect(service.isFontSupported('CustomFont')).toBe(true);
expect(measured).toContain('72px "CustomFont", monospace');
});
it('returns false when the canvas context is unavailable', () => {
vi.stubGlobal('document', {
createElement: () => ({
getContext: () => null,
}),
});
const service = createService();
expect(service.isFontSupported('MissingFont')).toBe(false);
});
});

View File

@@ -0,0 +1,99 @@
/**
* 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 { INotificationOptions } from '../../../views/notification/Notification';
import { Injector, LocaleService, LocaleType } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopNotificationService } from '../../notification/desktop-notification.service';
import { INotificationService } from '../../notification/notification.service';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopBeforeCloseService, IBeforeCloseService } from '../before-close.service';
class FakeNotificationService extends DesktopNotificationService {
static notifications: INotificationOptions[] = [];
override show(params: INotificationOptions): IDisposable {
FakeNotificationService.notifications.push(params);
return { dispose: () => {} };
}
}
function createService(): IBeforeCloseService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([LocaleService, { useClass: LocaleService }]);
injector.add([INotificationService, { useClass: FakeNotificationService }]);
injector.add([IBeforeCloseService, { useClass: DesktopBeforeCloseService }]);
const localeService = injector.get(LocaleService);
localeService.load({
[LocaleType.EN_US]: {},
});
return injector.get(IBeforeCloseService);
}
describe('DesktopBeforeCloseService', () => {
afterEach(() => {
vi.unstubAllGlobals();
FakeNotificationService.notifications = [];
});
it('blocks unload with combined messages and clears handlers on dispose', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const service = createService();
service.registerBeforeClose(() => 'Unsaved sheet');
const disposable = service.registerBeforeClose(() => 'Pending upload');
const event = new Event('beforeunload') as BeforeUnloadEvent;
windowTarget.dispatchEvent(event);
expect(event.returnValue).toBe('Unsaved sheet\nPending upload');
disposable.dispose();
(service as DesktopBeforeCloseService).dispose();
const nextEvent = new Event('beforeunload') as BeforeUnloadEvent;
windowTarget.dispatchEvent(nextEvent);
expect(nextEvent.returnValue).toBeUndefined();
});
it('runs close callbacks when the page unloads and stops after disposal', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const service = createService();
const closed: string[] = [];
const disposable = service.registerOnClose(() => closed.push('closed'));
windowTarget.dispatchEvent(new Event('unload'));
disposable.dispose();
windowTarget.dispatchEvent(new Event('unload'));
expect(closed).toEqual(['closed']);
});
it('registers the notification UI part used for blocked close messages', () => {
const windowTarget = new EventTarget();
vi.stubGlobal('window', windowTarget);
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([LocaleService, { useClass: LocaleService }]);
injector.add([INotificationService, { useClass: FakeNotificationService }]);
injector.add([IBeforeCloseService, { useClass: DesktopBeforeCloseService }]);
injector.get(IBeforeCloseService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,114 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { createIdentifier, Disposable, Inject, LocaleService } from '@univerjs/core';
import { INotificationService } from '../notification/notification.service';
export interface IBeforeCloseService {
/**
* Provide a callback to check if the web page could be closed safely.
*
* @param callback The callback to check if the web page could be closed safely.
* It should return a string to show a message to the user. If the return value is undefined,
* the web page could be closed safely.
*/
registerBeforeClose(callback: () => string | undefined): IDisposable;
/**
* Provide a callback to be called when the web page is closed.
*
* @param callback The callback to be called when the web page is closed.
*/
registerOnClose(callback: () => void): IDisposable;
}
export const IBeforeCloseService = createIdentifier<IBeforeCloseService>('univer.ui.before-close-service');
export class DesktopBeforeCloseService extends Disposable implements IBeforeCloseService {
private _beforeUnloadCallbacks: Array<() => string | undefined> = [];
private _onCloseCallbacks: Array<() => void> = [];
private readonly _beforeUnloadHandler: (event: BeforeUnloadEvent) => string | undefined;
private readonly _unloadHandler: () => void;
constructor(
@INotificationService private readonly _notificationService: INotificationService,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this._beforeUnloadHandler = (_event: BeforeUnloadEvent) => {
let event = _event;
const message = this._beforeUnloadCallbacks
.map((callback) => callback())
.filter((m) => !!m)
.join('\n');
if (message) {
this._notificationService.show({
type: 'error',
title: this._localeService.t<LocaleKey>('ui.beforeClose.title'),
content: message,
});
if (typeof event === 'undefined') {
event = window.event as BeforeUnloadEvent;
}
event.returnValue = message;
return message;
}
};
this._unloadHandler = () => {
this._onCloseCallbacks.forEach((callback) => callback());
};
this._init();
}
override dispose(): void {
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
window.removeEventListener('unload', this._unloadHandler);
this._beforeUnloadCallbacks = [];
this._onCloseCallbacks = [];
super.dispose();
}
registerBeforeClose(callback: () => string | undefined): IDisposable {
this._beforeUnloadCallbacks.push(callback);
return {
dispose: () => {
this._beforeUnloadCallbacks = this._beforeUnloadCallbacks.filter((cb) => cb !== callback);
},
};
}
registerOnClose(callback: () => void): IDisposable {
this._onCloseCallbacks.push(callback);
return {
dispose: () => {
this._onCloseCallbacks = this._onCloseCallbacks.filter((cb) => cb !== callback);
},
};
}
private _init(): void {
window.addEventListener('beforeunload', this._beforeUnloadHandler);
window.addEventListener('unload', this._unloadHandler);
}
}

View File

@@ -0,0 +1,241 @@
/**
* 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 { ILogService, LocaleService } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BrowserClipboardService } from '../clipboard-interface.service';
import { supportClipboardAPI } from '../clipboard-utils';
vi.mock('../clipboard-utils', () => ({
supportClipboardAPI: vi.fn(),
}));
class MockClipboardItem {
constructor(public readonly items: Record<string, Blob>) {}
}
function createService() {
const localeService = {
t: vi.fn((key: string) => key),
} as unknown as LocaleService;
const logService = {
error: vi.fn(),
} as unknown as ILogService;
const notificationService = {
show: vi.fn(),
};
const service = new BrowserClipboardService(localeService, logService, notificationService as any);
return { service, localeService, logService, notificationService };
}
describe('BrowserClipboardService', () => {
beforeEach(() => {
vi.mocked(supportClipboardAPI).mockReturnValue(true);
vi.stubGlobal('ClipboardItem', MockClipboardItem);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
write: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(undefined),
read: vi.fn().mockResolvedValue([]),
readText: vi.fn().mockResolvedValue('clipboard-text'),
},
});
(document as any).execCommand = vi.fn(() => true);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should expose clipboard support from utility', () => {
vi.mocked(supportClipboardAPI).mockReturnValue(true);
const { service } = createService();
expect(service.supportClipboard).toBe(true);
});
it('should write plain text and html with modern clipboard api', async () => {
const { service } = createService();
await service.write('plain', '<b>plain</b>');
expect(navigator.clipboard.write).toHaveBeenCalledTimes(1);
});
it('should write text with modern clipboard api', async () => {
const { service } = createService();
await service.writeText('hello');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello');
});
it('should fallback to legacy copy when clipboard api is unsupported', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
await service.writeText('legacy-text');
await service.write('legacy', '<i>legacy</i>');
expect(document.execCommand).toHaveBeenCalledWith('copy');
});
it('should fallback to legacy copy when ClipboardItem is unavailable', async () => {
vi.stubGlobal('ClipboardItem', undefined);
const { service } = createService();
await service.write('legacy', '<i>legacy</i>');
expect(document.execCommand).toHaveBeenCalledWith('copy');
expect(navigator.clipboard.write).not.toHaveBeenCalled();
});
it('should write raw sanitized html during legacy copy event', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const clipboardData = {
setData: vi.fn(),
};
vi.mocked(document.execCommand).mockImplementation(() => {
const event = new Event('copy', { cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: clipboardData,
});
document.dispatchEvent(event);
return true;
});
await service.write('plain', '<div onclick="alert(1)" style="color:red">rich</div>');
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'plain');
expect(clipboardData.setData).toHaveBeenCalledWith('text/html', '<div style="color: red">rich</div>');
expect(document.body.lastElementChild?.textContent).not.toBe('rich');
});
it('should preserve Univer Excel metadata while sanitizing legacy html', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
const clipboardData = {
setData: vi.fn(),
};
vi.mocked(document.execCommand).mockImplementation(() => {
const event = new Event('copy', { cancelable: true }) as ClipboardEvent;
Object.defineProperty(event, 'clipboardData', {
value: clipboardData,
});
document.dispatchEvent(event);
return true;
});
await service.write(
'merged',
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"><script>alert(1)</script></head><body><!--StartFragment--><table data-copy-id="copy-id"><tbody><tr><td rowspan="3" onclick="alert(1)">merged</td></tr></tbody></table><!--EndFragment--></body></html>'
);
const copiedHtml = clipboardData.setData.mock.calls.find(([type]) => type === 'text/html')?.[1];
expect(copiedHtml).toContain('xmlns:x="urn:schemas-microsoft-com:office:excel"');
expect(copiedHtml).toContain('<meta name="ProgId" content="Excel.Sheet">');
expect(copiedHtml).toContain('<!--StartFragment--><table data-copy-id="copy-id">');
expect(copiedHtml).toContain('<td rowspan="3">merged</td>');
expect(copiedHtml).toContain('</table><!--EndFragment-->');
expect(copiedHtml).not.toContain('<script');
expect(copiedHtml).not.toContain('onclick');
});
it('should sanitize html before legacy copy', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
let copiedHtml = '';
vi.mocked(document.execCommand).mockImplementation(() => {
copiedHtml = document.body.lastElementChild?.innerHTML ?? '';
return true;
});
await service.write(
'legacy',
'<div data-copy-id="copy-id" onclick="alert(1)"><a href="javascript:alert(1)">unsafe</a><img src="data:image/png;base64,ZmFrZQ==" onerror="alert(1)" /><span style="color: red; background-image: url(javascript:alert(1))">safe</span><script>alert(1)</script></div>'
);
const copiedDocument = new DOMParser().parseFromString(copiedHtml, 'text/html');
const wrapper = copiedDocument.body.firstElementChild as HTMLDivElement | null;
const link = copiedDocument.querySelector('a');
const image = copiedDocument.querySelector('img');
const span = copiedDocument.querySelector('span');
expect(wrapper?.getAttribute('data-copy-id')).toBe('copy-id');
expect(wrapper?.hasAttribute('onclick')).toBe(false);
expect(copiedDocument.querySelector('script')).toBeNull();
expect(link?.hasAttribute('href')).toBe(false);
expect(image?.getAttribute('src')).toBe('data:image/png;base64,ZmFrZQ==');
expect(image?.hasAttribute('onerror')).toBe(false);
expect(span?.getAttribute('style')).toContain('color: red');
expect(span?.getAttribute('style')).not.toContain('url(');
});
it('should show notification when write/writeText fails', async () => {
const { service, logService, notificationService } = createService();
vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('writeText failed'));
vi.mocked(navigator.clipboard.write).mockRejectedValueOnce(new Error('write failed'));
await service.writeText('failed');
await service.write('failed', '<b>failed</b>');
expect(logService.error).toHaveBeenCalledTimes(2);
expect(notificationService.show).toHaveBeenCalledWith({
type: 'warning',
title: 'ui.clipboard.authentication.title',
content: 'ui.clipboard.authentication.content',
});
});
it('should throw for read/readText when clipboard api is unsupported', async () => {
vi.mocked(supportClipboardAPI).mockReturnValue(false);
const { service } = createService();
await expect(service.read()).rejects.toThrow('[BrowserClipboardService] read() is not supported on this platform.');
await expect(service.readText()).rejects.toThrow('[BrowserClipboardService] read() is not supported on this platform.');
});
it('should read clipboard data when supported', async () => {
const { service } = createService();
await expect(service.read()).resolves.toEqual([]);
await expect(service.readText()).resolves.toBe('clipboard-text');
});
it('should return fallback values and notify when read/readText fails', async () => {
const { service, logService, notificationService } = createService();
vi.mocked(navigator.clipboard.read).mockImplementationOnce(() => {
throw new Error('read failed');
});
vi.mocked(navigator.clipboard.readText).mockRejectedValueOnce(new Error('readText failed'));
await expect(service.read()).resolves.toEqual([]);
await expect(service.readText()).resolves.toBe('');
expect(logService.error).toHaveBeenCalledTimes(2);
expect(notificationService.show).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,32 @@
/**
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
import { supportClipboardAPI } from '../clipboard-utils';
describe('clipboard-utils', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('detects whether text clipboard reads are available in the browser', () => {
vi.stubGlobal('navigator', { clipboard: { readText: () => Promise.resolve('copied') } });
expect(supportClipboardAPI()).toBe(true);
vi.stubGlobal('navigator', { clipboard: {} });
expect(supportClipboardAPI()).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
/**
* 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 { IAccessor, IContextService } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { CopyCommand, CutCommand, PasteCommand, SheetPasteShortKeyCommandName } from '../clipboard.command';
describe('clipboard commands', () => {
it('should define copy/cut/paste command metadata', () => {
expect(CopyCommand.id).toBe('univer.command.copy');
expect(CutCommand.id).toBe('univer.command.cut');
expect(PasteCommand.id).toBe('univer.command.paste');
expect(CopyCommand.type).toBe(CommandType.COMMAND);
expect(CutCommand.type).toBe(CommandType.COMMAND);
expect(PasteCommand.type).toBe(CommandType.COMMAND);
expect(CopyCommand.multi).toBe(true);
expect(CutCommand.multi).toBe(true);
expect(PasteCommand.multi).toBe(true);
});
it('should return fixed command behavior and short key name', async () => {
const contextService = {} as IContextService;
const accessor = {} as IAccessor;
expect(CopyCommand.preconditions!(contextService)).toBe(false);
expect(CutCommand.preconditions!(contextService)).toBe(false);
expect(PasteCommand.preconditions!(contextService)).toBe(false);
expect(CopyCommand.handler(accessor)).toBe(true);
await expect(CutCommand.handler(accessor)).resolves.toBe(true);
expect(PasteCommand.handler(accessor)).toBe(true);
expect(SheetPasteShortKeyCommandName).toBe('sheet.command.paste-by-short-key');
});
});

View File

@@ -0,0 +1,556 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { createIdentifier, Disposable, ILogService, Inject, LocaleService, Optional } from '@univerjs/core';
import { sanitizeParsedHtml } from '../../utils/html';
import { INotificationService } from '../notification/notification.service';
import { supportClipboardAPI } from './clipboard-utils';
export const PLAIN_TEXT_CLIPBOARD_MIME_TYPE = 'text/plain';
export const HTML_CLIPBOARD_MIME_TYPE = 'text/html';
export const FILE_PNG_CLIPBOARD_MIME_TYPE = 'image/png';
export const FILE__JPEG_CLIPBOARD_MIME_TYPE = 'image/jpeg';
export const FILE__BMP_CLIPBOARD_MIME_TYPE = 'image/bmp';
export const FILE__WEBP_CLIPBOARD_MIME_TYPE = 'image/webp';
export const FILE_SVG_XML_CLIPBOARD_MIME_TYPE = 'image/svg+xml';
export const imageMimeTypeSet = new Set([
FILE__BMP_CLIPBOARD_MIME_TYPE,
FILE__JPEG_CLIPBOARD_MIME_TYPE,
FILE__WEBP_CLIPBOARD_MIME_TYPE,
FILE_PNG_CLIPBOARD_MIME_TYPE,
]);
const DROP_CONTENT_TAGS = new Set([
'base',
'button',
'embed',
'form',
'frame',
'frameset',
'head',
'iframe',
'input',
'link',
'meta',
'object',
'script',
'select',
'style',
'template',
'textarea',
]);
const ALLOWED_HTML_TAGS = new Set([
'a',
'b',
'blockquote',
'br',
'caption',
'code',
'col',
'colgroup',
'dd',
'div',
'dl',
'dt',
'em',
'font',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'span',
'strike',
'strong',
'sub',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'tr',
'u',
'ul',
]);
const ALLOWED_HTML_ATTRIBUTES = new Set([
'align',
'alt',
'aria-colindex',
'aria-colspan',
'aria-label',
'aria-labelledby',
'aria-rowindex',
'aria-rowspan',
'bgcolor',
'border',
'cellpadding',
'cellspacing',
'class',
'colspan',
'color',
'dir',
'face',
'headers',
'height',
'href',
'lang',
'rel',
'role',
'rowspan',
'size',
'src',
'style',
'target',
'title',
'valign',
'width',
]);
/**
* This interface provides an interface to access system's clipboard.
*/
export interface IClipboardInterfaceService {
/**
* Write plain text into clipboard. Use write() to write both plain text and html.
* @param text
*/
writeText(text: string): Promise<void>;
/**
* Write both plain text and html into clipboard.
* @param text
* @param html
*/
write(text: string, html: string, customData?: Record<string, string>): Promise<void>;
/**
* Read plain text from clipboard. Use read() to read both plain text and html.
* @returns plain text
*/
readText(): Promise<string>;
/**
* Read `ClipboardItem[]` from clipboard.
*/
read(): Promise<ClipboardItem[]>;
/**
* This property tells if the platform supports reading data directly from the clipboard.
*/
readonly supportClipboard: boolean;
}
export const IClipboardInterfaceService = createIdentifier<IClipboardInterfaceService>(
'univer.clipboard-interface-service'
);
export class BrowserClipboardService extends Disposable implements IClipboardInterfaceService {
get supportClipboard(): boolean {
return supportClipboardAPI();
}
constructor(
@Inject(LocaleService) private readonly _localeService: LocaleService,
@ILogService private readonly _logService: ILogService,
@Optional(INotificationService) private readonly _notificationService?: INotificationService
) {
super();
}
async write(text: string, html: string, customData?: Record<string, string>): Promise<void> {
if (!this.supportClipboard || typeof ClipboardItem === 'undefined' || typeof navigator.clipboard.write !== 'function') {
return this._legacyCopyHtml(text, html);
}
try {
// write both pure text content and html content to the clipboard
return await navigator.clipboard.write([
new ClipboardItem({
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
...Object.fromEntries(Object.entries(customData ?? {}).map(([type, value]) => [type, new Blob([value], { type })])),
}),
]);
} catch (error) {
if (customData && Object.keys(customData).length) {
try {
return await navigator.clipboard.write([
new ClipboardItem({
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE]: new Blob([text], { type: PLAIN_TEXT_CLIPBOARD_MIME_TYPE }),
[HTML_CLIPBOARD_MIME_TYPE]: new Blob([html], { type: HTML_CLIPBOARD_MIME_TYPE }),
}),
]);
} catch (fallbackError) {
this._logService.error('[BrowserClipboardService]', fallbackError);
this._showClipboardAuthenticationNotification();
return;
}
}
this._logService.error('[BrowserClipboardService]', error);
this._showClipboardAuthenticationNotification();
}
}
async writeText(text: string): Promise<void> {
if (!this.supportClipboard) {
return this._legacyCopyText(text);
}
// use new Clipboard API first
try {
return await navigator.clipboard.writeText(text);
} catch (error) {
this._logService.error('[BrowserClipboardService]', error);
this._showClipboardAuthenticationNotification();
}
}
async read(): Promise<ClipboardItem[]> {
if (!this.supportClipboard) {
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
}
try {
return navigator.clipboard.read();
} catch (e) {
this._logService.error('[BrowserClipboardService]', e);
this._showClipboardAuthenticationNotification();
return [];
}
}
async readText(): Promise<string> {
if (!this.supportClipboard) {
throw new Error('[BrowserClipboardService] read() is not supported on this platform.');
}
try {
return await navigator.clipboard.readText();
} catch (e) {
this._logService.error('[BrowserClipboardService]', e);
this._showClipboardAuthenticationNotification();
return '';
}
}
private _legacyCopyHtml(text: string, html: string): void {
const activeElement = document.activeElement;
const sanitizedHtml = serializeSanitizedHtmlForClipboard(html);
let handledByClipboardEvent = false;
const onCopy = (event: ClipboardEvent) => {
if (!event.clipboardData) {
return;
}
event.preventDefault();
event.clipboardData.setData(PLAIN_TEXT_CLIPBOARD_MIME_TYPE, text);
event.clipboardData.setData(HTML_CLIPBOARD_MIME_TYPE, sanitizedHtml);
handledByClipboardEvent = true;
};
document.addEventListener('copy', onCopy);
try {
document.execCommand('copy');
if (!handledByClipboardEvent) {
const container = createCopyHtmlContainer();
document.body.appendChild(container);
container.innerHTML = sanitizedHtml;
try {
select(container);
document.execCommand('copy');
} finally {
document.body.removeChild(container);
}
}
} finally {
document.removeEventListener('copy', onCopy);
if (activeElement instanceof HTMLElement) {
activeElement.focus();
}
}
}
private _legacyCopyText(text: string): void {
const activeElement = document.activeElement;
const container = createCopyTextContainer();
document.body.appendChild(container);
container.value = text;
try {
select(container);
document.execCommand('copy');
} finally {
// reset previous elements focus state
if (activeElement instanceof HTMLElement) {
activeElement.focus();
}
document.body.removeChild(container);
}
}
private _showClipboardAuthenticationNotification(): void {
this._notificationService?.show({
type: 'warning',
title: this._localeService.t<LocaleKey>('ui.clipboard.authentication.title'),
content: this._localeService.t<LocaleKey>('ui.clipboard.authentication.content'),
});
}
}
function createCopyTextContainer() {
const textArea = document.createElement('textarea');
textArea.style.position = 'absolute';
textArea.style.height = '1px';
textArea.style.width = '1px';
textArea.style.opacity = '0';
textArea.readOnly = true;
return textArea;
}
function createCopyHtmlContainer() {
const div = document.createElement('div');
div.contentEditable = 'true';
div.style.position = 'absolute';
div.style.opacity = '0';
div.style.height = '1px';
div.style.width = '1px';
return div;
}
function sanitizeHtmlForClipboard(html: string): DocumentFragment {
const parser = new DOMParser();
const doc = parser.parseFromString(html, HTML_CLIPBOARD_MIME_TYPE);
sanitizeParsedHtml(doc, {
strippedSelector: 'script, iframe, object, embed',
});
const fragment = document.createDocumentFragment();
Array.from(doc.body.childNodes).forEach((node) => {
const sanitizedNode = sanitizeHtmlNode(node);
if (sanitizedNode) {
fragment.appendChild(sanitizedNode);
}
});
return fragment;
}
function serializeSanitizedHtmlForClipboard(html: string): string {
const container = document.createElement('div');
container.appendChild(sanitizeHtmlForClipboard(html));
const sanitizedHtml = container.innerHTML;
const isUniverExcelHtml = html.includes('xmlns:x="urn:schemas-microsoft-com:office:excel"')
&& html.includes('<meta name="ProgId" content="Excel.Sheet">')
&& html.includes('<meta name="Generator" content="Univer">');
if (!isUniverExcelHtml) {
return sanitizedHtml;
}
return `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="ProgId" content="Excel.Sheet"><meta name="Generator" content="Univer"></head><body><!--StartFragment-->${sanitizedHtml}<!--EndFragment--></body></html>`;
}
function sanitizeHtmlNode(node: Node): Node | null {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent ?? '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return null;
}
const sourceElement = node as HTMLElement;
const tagName = sourceElement.tagName.toLowerCase();
if (DROP_CONTENT_TAGS.has(tagName)) {
return null;
}
if (!ALLOWED_HTML_TAGS.has(tagName)) {
const fragment = document.createDocumentFragment();
Array.from(sourceElement.childNodes).forEach((childNode) => {
const sanitizedChildNode = sanitizeHtmlNode(childNode);
if (sanitizedChildNode) {
fragment.appendChild(sanitizedChildNode);
}
});
return fragment;
}
const sanitizedElement = document.createElement(tagName);
copySanitizedAttributes(sourceElement, sanitizedElement);
Array.from(sourceElement.childNodes).forEach((childNode) => {
const sanitizedChildNode = sanitizeHtmlNode(childNode);
if (sanitizedChildNode) {
sanitizedElement.appendChild(sanitizedChildNode);
}
});
return sanitizedElement;
}
function copySanitizedAttributes(sourceElement: HTMLElement, targetElement: HTMLElement) {
Array.from(sourceElement.attributes).forEach((attribute) => {
const attributeName = attribute.name.toLowerCase();
if (attributeName.startsWith('on')) {
return;
}
if (attributeName.startsWith('data-') || attributeName.startsWith('aria-')) {
targetElement.setAttribute(attribute.name, attribute.value);
return;
}
if (!ALLOWED_HTML_ATTRIBUTES.has(attributeName)) {
return;
}
if (attributeName === 'href' || attributeName === 'src') {
const sanitizedUrl = sanitizeHtmlUrl(attribute.value, attributeName === 'src');
if (sanitizedUrl) {
targetElement.setAttribute(attribute.name, sanitizedUrl);
}
return;
}
if (attributeName === 'style') {
const sanitizedStyle = sanitizeInlineStyle(attribute.value);
if (sanitizedStyle) {
targetElement.setAttribute(attribute.name, sanitizedStyle);
}
return;
}
targetElement.setAttribute(attribute.name, attribute.value);
});
}
function sanitizeHtmlUrl(value: string, allowDataImage: boolean): string | null {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
if (trimmedValue.startsWith('#')) {
return trimmedValue;
}
if (/^(?:https?:|mailto:|tel:)/i.test(trimmedValue)) {
return trimmedValue;
}
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmedValue)) {
return trimmedValue;
}
if (/^(?:\/|\.\/|\.\.\/)/.test(trimmedValue)) {
return trimmedValue;
}
return null;
}
function sanitizeInlineStyle(styleText: string): string {
const probe = document.createElement('div');
probe.style.cssText = styleText;
const sanitizedDeclarations: string[] = [];
Array.from(probe.style).forEach((propertyName) => {
const propertyValue = probe.style.getPropertyValue(propertyName).trim();
if (!propertyValue || isUnsafeStyleValue(propertyValue)) {
return;
}
sanitizedDeclarations.push(`${propertyName}: ${propertyValue}`);
});
return sanitizedDeclarations.join('; ');
}
function isUnsafeStyleValue(styleValue: string): boolean {
return /(expression\s*\(|url\s*\(|javascript:|vbscript:|mocha:|-moz-binding|behavior\s*:|@import)/i.test(styleValue);
}
function select(element: HTMLTextAreaElement | HTMLDivElement): string {
if (element instanceof HTMLTextAreaElement) {
const isReadOnly = element.hasAttribute('readonly');
if (!isReadOnly) {
element.setAttribute('readonly', '');
}
element.select();
element.setSelectionRange(0, element.value.length);
if (!isReadOnly) {
element.removeAttribute('readonly');
}
return element.value;
}
if (element.hasAttribute('contenteditable')) {
element.focus();
}
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
if (!selection) {
throw new Error();
}
selection.removeAllRanges();
selection.addRange(range);
return selection.toString();
}

View File

@@ -0,0 +1,26 @@
/**
* 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.
*/
/**
* This function is used to check if the system supports full clipboard API,
* especially for the clipboard.readText() API.
*
* @returns if the system supports clipboard API
*/
export function supportClipboardAPI(): boolean {
// In unsecure context, navigator.clipboard does not exist.
return typeof navigator.clipboard !== 'undefined' && typeof navigator.clipboard.readText !== 'undefined';
}

View File

@@ -0,0 +1,53 @@
/**
* 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 { IMultiCommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
const CopyCommandName = 'univer.command.copy';
export const CopyCommand: IMultiCommand = {
id: CopyCommandName,
name: CopyCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: () => true,
};
const CutCommandName = 'univer.command.cut';
export const CutCommand: IMultiCommand = {
id: CutCommandName,
name: CutCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: async () => true,
};
const PasteCommandName = 'univer.command.paste';
export const PasteCommand: IMultiCommand = {
id: PasteCommandName,
name: PasteCommandName,
multi: true,
priority: 0,
type: CommandType.COMMAND,
preconditions: () => false,
handler: () => true,
};
export const SheetPasteShortKeyCommandName = 'sheet.command.paste-by-short-key';

View File

@@ -0,0 +1,73 @@
/**
* 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 { IConfirmPartMethodOptions } from '../../../views/components/confirm-part/interface';
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopConfirmService } from '../desktop-confirm.service';
function createService(): DesktopConfirmService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([DesktopConfirmService]);
return injector.get(DesktopConfirmService);
}
describe('DesktopConfirmService', () => {
it('opens a confirm request and resolves it through the confirm callback', async () => {
const service = createService();
const opened: IConfirmPartMethodOptions[][] = [];
const sub = service.confirmOptions$.subscribe((options) => opened.push(options));
const promise = service.confirm({ id: 'save-confirm', children: 'Save changes?' });
opened.at(-1)?.[0].onConfirm?.();
await expect(promise).resolves.toBe(true);
expect(opened.at(-1)).toEqual([]);
sub.unsubscribe();
});
it('updates duplicate confirm ids and resolves close callbacks as cancellation', async () => {
const service = createService();
const snapshots: IConfirmPartMethodOptions[][] = [];
const sub = service.confirmOptions$.subscribe((options) => snapshots.push(options));
const disposable = service.open({ id: 'save-confirm', children: 'Save changes?' });
service.open({ id: 'save-confirm', children: 'Discard changes?' });
service.close('save-confirm');
expect(snapshots.at(-2)?.[0]).toMatchObject({ id: 'save-confirm', children: 'Discard changes?', visible: true });
expect(snapshots.at(-1)?.[0]).toMatchObject({ id: 'save-confirm', visible: false });
const promise = service.confirm({ id: 'cancel-confirm', children: 'Cancel?' });
snapshots.at(-1)?.find((item) => item.id === 'cancel-confirm')?.onClose?.();
await expect(promise).resolves.toBe(false);
disposable.dispose();
expect(snapshots.at(-1)).toEqual([]);
sub.unsubscribe();
});
it('registers the global confirm UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([DesktopConfirmService]);
injector.get(DesktopConfirmService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,87 @@
/**
* 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 { IConfirmService, IDisposable } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '../../views/components/confirm-part/interface';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { ConfirmPart } from '../../views/components/confirm-part/ConfirmPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopConfirmService extends Disposable implements IConfirmService<IConfirmPartMethodOptions> {
private _confirmOptions: IConfirmPartMethodOptions[] = [];
readonly confirmOptions$ = new BehaviorSubject<IConfirmPartMethodOptions[]>([]);
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
open(option: IConfirmPartMethodOptions): IDisposable {
if (this._confirmOptions.find((item) => item.id === option.id)) {
this._confirmOptions = this._confirmOptions.map((item) => ({
...(item.id === option.id ? option : item),
visible: item.id === option.id ? true : item.visible,
}));
} else {
this._confirmOptions.push({
...option,
visible: true,
});
}
this.confirmOptions$.next(this._confirmOptions);
return toDisposable(() => {
this._confirmOptions = [];
this.confirmOptions$.next([]);
});
}
confirm(params: IConfirmPartMethodOptions): Promise<boolean> {
return new Promise((resolve) => {
const disposeHandler = this.open({
...params,
onConfirm: () => {
disposeHandler.dispose();
resolve(true);
},
onClose: () => {
disposeHandler.dispose();
resolve(false);
},
});
});
}
close(id: string): void {
this._confirmOptions = this._confirmOptions.map((item) => ({
...item,
visible: item.id === id ? false : item.visible,
}));
this.confirmOptions$.next([...this._confirmOptions]);
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(ConfirmPart, this._injector))
);
}
}

View File

@@ -0,0 +1,89 @@
/**
* 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 { IConfirmService, IDisposable } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '../../views/components/confirm-part/interface';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { MobileConfirmPart } from '../../views/components/confirm-part/MobileConfirmPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class MobileConfirmService extends Disposable implements IConfirmService<IConfirmPartMethodOptions> {
private _confirmOptions: IConfirmPartMethodOptions[] = [];
readonly confirmOptions$ = new BehaviorSubject<IConfirmPartMethodOptions[]>([]);
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IUIPartsService uiPartsService: IUIPartsService
) {
super();
this.disposeWithMe(
uiPartsService.registerComponent(
BuiltInUIPart.GLOBAL,
() => connectInjector(MobileConfirmPart, this._injector)
)
);
}
override dispose(): void {
super.dispose();
this._confirmOptions = [];
this.confirmOptions$.complete();
}
open(option: IConfirmPartMethodOptions): IDisposable {
const existing = this._confirmOptions.some((item) => item.id === option.id);
if (existing) {
this._confirmOptions = this._confirmOptions.map((item) => item.id === option.id
? { ...option, visible: true }
: item);
} else {
this._confirmOptions.push({ ...option, visible: true });
}
this.confirmOptions$.next(this._confirmOptions);
return toDisposable(() => {
this._confirmOptions = [];
this.confirmOptions$.next([]);
});
}
confirm(params: IConfirmPartMethodOptions): Promise<boolean> {
return new Promise((resolve) => {
const disposable = this.open({
...params,
onConfirm: () => {
disposable.dispose();
resolve(true);
},
onClose: () => {
disposable.dispose();
resolve(false);
},
});
});
}
close(id: string): void {
this._confirmOptions = this._confirmOptions.map((item) => ({
...item,
visible: item.id === id ? false : item.visible,
}));
this.confirmOptions$.next([...this._confirmOptions]);
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ContextMenuHostService, IContextMenuHostService } from '../contextmenu-host.service';
describe('ContextMenuHostService', () => {
let service: IContextMenuHostService;
beforeEach(() => {
const injector = new Injector();
injector.add([IContextMenuHostService, { useClass: ContextMenuHostService }]);
service = injector.get(IContextMenuHostService);
});
it('hides the previously active menu when another menu becomes active', () => {
const hideMainMenu = vi.fn();
const hideFooterMenu = vi.fn();
service.registerMenu('main', hideMainMenu);
service.registerMenu('footer', hideFooterMenu);
service.activateMenu('main');
service.activateMenu('footer');
expect(hideMainMenu).toHaveBeenCalledTimes(1);
expect(hideFooterMenu).not.toHaveBeenCalled();
expect(service.activeMenuId).toBe('footer');
});
it('clears active menu state when a registered menu is disposed', () => {
const disposable = service.registerMenu('main', vi.fn());
service.activateMenu('main');
disposable.dispose();
expect(service.activeMenuId).toBeNull();
});
it('publishes active menu changes', () => {
const activeMenuIds: Array<string | null> = [];
service.registerMenu('main', vi.fn());
const subscription = service.activeMenuId$.subscribe((menuId) => activeMenuIds.push(menuId));
service.activateMenu('main');
service.deactivateMenu('main');
expect(activeMenuIds).toEqual([null, 'main', null]);
subscription.unsubscribe();
});
});

View File

@@ -0,0 +1,74 @@
/**
* 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 { Injector } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { ContextMenuService, IContextMenuService } from '../contextmenu.service';
function createService(): IContextMenuService {
const injector = new Injector();
injector.add([IContextMenuService, { useClass: ContextMenuService }]);
return injector.get(IContextMenuService);
}
describe('ContextMenuService', () => {
it('routes context menu requests to the registered handler while enabled', () => {
const service = createService();
const handled: string[] = [];
let visible = false;
const event = { stopPropagation: vi.fn() };
service.registerContextMenuHandler({
get visible() {
return visible;
},
handleContextMenu: (_event, menuType) => {
visible = true;
handled.push(menuType);
},
hideContextMenu: () => {
visible = false;
},
});
service.triggerContextMenu(event as never, 'cell');
expect(handled).toEqual(['cell']);
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
expect(service.visible).toBe(true);
service.hideContextMenu();
expect(service.visible).toBe(false);
});
it('suppresses handler execution while disabled and resumes after enable', () => {
const service = createService();
const handled: string[] = [];
const event = { stopPropagation: vi.fn() };
service.registerContextMenuHandler({
visible: false,
handleContextMenu: (_event, menuType) => handled.push(menuType),
hideContextMenu: () => {},
});
service.disable();
service.triggerContextMenu(event as never, 'row');
service.enable();
service.triggerContextMenu(event as never, 'column');
expect(handled).toEqual(['column']);
});
});

View File

@@ -0,0 +1,79 @@
/**
* 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 { Observable } from 'rxjs';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface IContextMenuHostService {
readonly activeMenuId: string | null;
readonly activeMenuId$: Observable<string | null>;
registerMenu(menuId: string, hide: () => void): IDisposable;
activateMenu(menuId: string): void;
deactivateMenu(menuId: string): void;
hideActiveMenu(exceptMenuId?: string): void;
}
export const IContextMenuHostService = createIdentifier<IContextMenuHostService>('ui.contextmenu.host.service');
export class ContextMenuHostService extends Disposable implements IContextMenuHostService {
private readonly _menuMap = new Map<string, () => void>();
private readonly _activeMenuId$ = new BehaviorSubject<string | null>(null);
readonly activeMenuId$ = this._activeMenuId$.asObservable();
get activeMenuId(): string | null {
return this._activeMenuId$.value;
}
registerMenu(menuId: string, hide: () => void): IDisposable {
this._menuMap.set(menuId, hide);
return toDisposable(() => {
this._menuMap.delete(menuId);
if (this.activeMenuId === menuId) {
this._activeMenuId$.next(null);
}
});
}
activateMenu(menuId: string): void {
this.hideActiveMenu(menuId);
this._activeMenuId$.next(menuId);
}
deactivateMenu(menuId: string): void {
if (this.activeMenuId === menuId) {
this._activeMenuId$.next(null);
}
}
hideActiveMenu(exceptMenuId?: string): void {
const activeMenuId = this.activeMenuId;
if (!activeMenuId || activeMenuId === exceptMenuId) {
return;
}
const hide = this._menuMap.get(activeMenuId);
this._activeMenuId$.next(null);
hide?.();
}
override dispose(): void {
this._activeMenuId$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,86 @@
/**
* 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 { IMouseEvent } from '@univerjs/engine-render';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
export interface IContextMenuTriggerContext {
autoFocus?: boolean;
caretAnchor?: boolean;
unitId?: string;
subUnitId?: string;
}
export type ContextMenuEvent = Pick<IMouseEvent, 'clientX' | 'clientY' | 'stopPropagation'>;
export interface IContextMenuHandler {
/** A callback to open context menu with given position and menu type. */
handleContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void;
hideContextMenu(): void;
get visible(): boolean;
}
export interface IContextMenuService {
disabled: boolean;
get visible(): boolean;
enable(): void;
disable(): void;
triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void;
hideContextMenu(): void;
registerContextMenuHandler(handler: IContextMenuHandler): IDisposable;
}
export const IContextMenuService = createIdentifier<IContextMenuService>('ui.contextmenu.service');
export class ContextMenuService extends Disposable implements IContextMenuService {
private _currentHandler: IContextMenuHandler | null = null;
disabled: boolean = false;
get visible(): boolean {
return this._currentHandler?.visible ?? false;
}
disable(): void {
this.disabled = true;
}
enable(): void {
this.disabled = false;
}
triggerContextMenu(event: ContextMenuEvent, menuType: string, context?: IContextMenuTriggerContext): void {
event.stopPropagation();
if (this.disabled) return;
this._currentHandler?.handleContextMenu(event, menuType, context);
}
hideContextMenu(): void {
this._currentHandler?.hideContextMenu();
}
registerContextMenuHandler(handler: IContextMenuHandler): IDisposable {
if (this._currentHandler) {
throw new Error('There is already a context menu handler!');
}
this._currentHandler = handler;
return toDisposable(() => this._currentHandler = null);
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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 { IDialogPartMethodOptions } from '../../../views/components/dialog-part/interface';
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopDialogService } from '../desktop-dialog.service';
import { IDialogService } from '../dialog.service';
function createService(): IDialogService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
return injector.get(IDialogService);
}
describe('DesktopDialogService', () => {
it('tracks opened dialogs and closes only the requested dialog', () => {
const service = createService();
const snapshots: IDialogPartMethodOptions[][] = [];
const sub = service.getDialogs$().subscribe((options) => snapshots.push(options));
service.open({ id: 'first' });
service.open({ id: 'second' });
service.close('first');
expect(snapshots.at(-1)).toMatchObject([{ id: 'first', open: false }, { id: 'second', open: true }]);
sub.unsubscribe();
});
it('keeps excepted dialogs open when closing all', () => {
const service = createService();
const snapshots: IDialogPartMethodOptions[][] = [];
const sub = service.getDialogs$().subscribe((options) => snapshots.push(options));
service.open({ id: 'first' });
service.open({ id: 'second' });
service.closeAll(['second']);
expect(snapshots.at(-1)).toMatchObject([{ id: 'first', open: false }, { id: 'second', open: true }]);
sub.unsubscribe();
});
it('registers the global dialog UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: DesktopDialogService }]);
injector.get(IDialogService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,94 @@
/**
* 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 { IDialogPartMethodOptions } from '../../views/components/dialog-part/interface';
import type { IDialogService } from './dialog.service';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { Subject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { DialogPart } from '../../views/components/dialog-part/DialogPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopDialogService extends Disposable implements IDialogService {
protected _dialogOptions: IDialogPartMethodOptions[] = [];
protected readonly _dialogOptions$ = new Subject<IDialogPartMethodOptions[]>();
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
override dispose(): void {
super.dispose();
this._dialogOptions = [];
this._dialogOptions$.complete();
}
open(option: IDialogPartMethodOptions): IDisposable {
if (this._dialogOptions.find((item) => item.id === option.id)) {
this._dialogOptions = this._dialogOptions.map((item) => ({
...(item.id === option.id ? option : item),
open: item.id === option.id ? true : item.open,
}));
} else {
this._dialogOptions.push({
...option,
open: true,
});
}
this._dialogOptions$.next(this._dialogOptions);
return toDisposable(() => {
this._dialogOptions = [];
this._dialogOptions$.next([]);
});
}
close(id: string) {
this._dialogOptions = this._dialogOptions.map((item) => ({
...item,
open: item.id === id ? false : item.open,
}));
this._dialogOptions$.next([...this._dialogOptions]);
}
closeAll(expectIds?: string[]): void {
const expectIdSet = new Set(expectIds);
this._dialogOptions = this._dialogOptions.map((item) => ({
...item,
open: expectIdSet.has(item.id) ? item.open : false,
}));
this._dialogOptions$.next([...this._dialogOptions]);
}
getDialogs$() {
return this._dialogOptions$.asObservable();
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(DialogPart, this._injector))
);
}
}

View File

@@ -0,0 +1,32 @@
/**
* 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 { Observable } from 'rxjs';
import type { IDialogPartMethodOptions } from '../../views/components/dialog-part/interface';
import { createIdentifier } from '@univerjs/core';
export const IDialogService = createIdentifier<IDialogService>('univer.ui.dialog-service');
export interface IDialogService {
open(params: IDialogPartMethodOptions): IDisposable;
close(id: string): void;
/**
* @description close all dialogs except the specified ones
* @param {string[]} [expectIds] The specified dialog ids
*/
closeAll(expectIds?: string[]): void;
getDialogs$(): Observable<IDialogPartMethodOptions[]>;
}

View File

@@ -0,0 +1,42 @@
/**
* 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 { Inject, Injector } from '@univerjs/core';
import { connectInjector } from '../../utils/di';
import { MobileDialogPart } from '../../views/components/dialog-part/MobileDialogPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
import { DesktopDialogService } from './desktop-dialog.service';
export class MobileDialogService extends DesktopDialogService {
constructor(
@Inject(Injector) injector: Injector,
@IUIPartsService uiPartsService: IUIPartsService
) {
super(injector, uiPartsService);
}
override close(id: string): void {
const dialog = this._dialogOptions.find((item) => item.id === id);
if (!dialog || dialog.open === false) return;
super.close(id);
}
protected override _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(MobileDialogPart, this._injector))
);
}
}

View File

@@ -0,0 +1,125 @@
/**
* 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 { IFloatDom, IFloatDomLayout } from '../canvas-dom-layer.service';
import { Injector } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { CanvasFloatDomPreviewService, CanvasFloatDomService } from '../canvas-dom-layer.service';
function createService(): CanvasFloatDomService {
const injector = new Injector();
injector.add([CanvasFloatDomService]);
return injector.get(CanvasFloatDomService);
}
function createFloatDom(id: string): IFloatDom {
return {
id,
componentKey: 'shape',
onPointerDown: () => {},
onPointerMove: () => {},
onPointerUp: () => {},
onWheel: () => {},
position$: new BehaviorSubject<IFloatDomLayout>({
startX: 0,
startY: 0,
endX: 10,
endY: 10,
rotate: 0,
width: 10,
height: 10,
absolute: { left: true, top: true },
}),
unitId: 'unit-1',
};
}
describe('CanvasFloatDomService', () => {
it('publishes floating DOM layer changes for canvas overlays', () => {
const service = createService();
const snapshots: Array<[string, IFloatDom][]> = [];
const sub = service.domLayers$.subscribe((layers) => snapshots.push(layers));
service.addFloatDom(createFloatDom('dom-1'));
service.updateFloatDom('dom-1', { domId: 'runtime-dom-1' });
service.removeFloatDom('dom-1');
expect(snapshots.map((layers) => layers.length)).toEqual([0, 1, 1, 0]);
expect(snapshots[2][0][1].domId).toBe('runtime-dom-1');
sub.unsubscribe();
});
it('ignores missing updates and publishes remove-all changes', () => {
const service = createService();
const sizes: number[] = [];
const sub = service.domLayers$.subscribe((layers) => sizes.push(layers.length));
service.updateFloatDom('missing', { domId: 'ignored' });
service.addFloatDom(createFloatDom('dom-1'));
service.addFloatDom(createFloatDom('dom-2'));
service.removeFloatDom('missing');
service.removeAll();
expect(service.domLayers).toEqual([]);
expect(sizes).toEqual([0, 1, 2, 0]);
sub.unsubscribe();
});
it('clears retained layer callbacks when disposed', () => {
const service = createService();
const sizes: number[] = [];
let completed = false;
service.domLayers$.subscribe({
next: (layers) => sizes.push(layers.length),
complete: () => {
completed = true;
},
});
service.addFloatDom(createFloatDom('dom-1'));
service.dispose();
expect(service.domLayers).toEqual([]);
expect(sizes).toEqual([0, 1, 0]);
expect(completed).toBe(true);
});
it('clears preview state when disposed', () => {
const service = new CanvasFloatDomPreviewService();
let previewCompleted = false;
let requestCompleted = false;
service.previewUpdated$.subscribe({
complete: () => {
previewCompleted = true;
},
});
service.previewRequested$.subscribe({
complete: () => {
requestCompleted = true;
},
});
service.requestPreview({ id: 'dom-1', width: 20, height: 10 });
service.setPreview({ id: 'dom-1', image: 'data:image/png;base64,', updatedAt: 1 });
service.dispose();
expect(service.getPreview('dom-1')).toBeUndefined();
expect(service.getPendingRequests()).toEqual([]);
expect(previewCompleted).toBe(true);
expect(requestCompleted).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
/**
* 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 { IPosition, Serializable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject, Subject } from 'rxjs';
export interface IFloatDomLayout extends IPosition {
rotate: number;
width: number;
height: number;
absolute: {
left: boolean;
top: boolean;
};
opacity?: number;
}
export interface IFloatDomContentBoxConfig {
/** Amount subtracted from the visible wrapper width and height. @default 2 */
wrapperInset?: number;
/** Amount subtracted from the content width and height. @default 4 */
contentInset?: number;
}
export interface IFloatDom {
position$: Observable<IFloatDomLayout>;
id: string;
domId?: string; // Ensure unique id for dom element at runtime
componentKey: string | React.ComponentType;
/**
* Whether pointer and wheel events inside the floating DOM should be
* forwarded back to the host canvas. Existing canvas-owned float DOMs keep
* forwarding by default; interactive embed runtimes can opt out.
*/
eventPassThrough?: boolean;
/**
* Keep rendering this host-owned layer even when focus temporarily moves
* into a child runtime unit. Interactive embed float blocks need this so
* their DOM portal is not filtered out when the child handles focus.
*/
preserveOnFocusChange?: boolean;
/**
* Insets subtracted from FloatDom's wrapper and content dimensions.
* Omitted values preserve the historical wrapper/content insets of 2/4.
*/
contentBox?: IFloatDomContentBoxConfig;
onPointerMove: (evt: PointerEvent | MouseEvent) => void;
onPointerDown: (evt: PointerEvent | MouseEvent) => void;
onPointerUp: (evt: PointerEvent | MouseEvent) => void;
onWheel: (evt: WheelEvent) => void;
props?: Record<string, any>;
data?: Serializable;
unitId: string;
}
export function shouldForwardFloatDomEvents(layer: Pick<IFloatDom, 'eventPassThrough'>): boolean {
return layer.eventPassThrough !== false;
}
export function shouldRenderFloatDomLayer(layer: Pick<IFloatDom, 'unitId' | 'preserveOnFocusChange'>, currentUnitId: string | null | undefined): boolean {
return layer.unitId === currentUnitId || layer.preserveOnFocusChange === true;
}
export class CanvasFloatDomService extends Disposable {
private _domLayerMap = new Map<string, IFloatDom>();
private _scopedRenderRootCount = new Map<string, number>();
private _domLayers$ = new BehaviorSubject<[string, IFloatDom][]>([]);
domLayers$ = this._domLayers$.asObservable();
get domLayers() {
return Array.from(this._domLayerMap.entries());
}
private _notice() {
this._domLayers$.next(Array.from(this._domLayerMap.entries()));
}
updateFloatDom(id: string, item: Partial<IFloatDom>) {
const current = this._domLayerMap.get(id);
if (!current) {
return;
}
this._domLayerMap.set(id, {
...current,
...item,
});
this._notice();
}
addFloatDom(item: IFloatDom) {
this._domLayerMap.set(item.id, item);
this._notice();
}
hasScopedRenderRoot(unitId: string): boolean {
return this._scopedRenderRootCount.has(unitId);
}
registerScopedRenderRoot(unitId: string) {
this._scopedRenderRootCount.set(unitId, (this._scopedRenderRootCount.get(unitId) ?? 0) + 1);
this._notice();
return toDisposable(() => {
const count = this._scopedRenderRootCount.get(unitId);
if (count == null) {
return;
}
if (count === 1) {
this._scopedRenderRootCount.delete(unitId);
} else {
this._scopedRenderRootCount.set(unitId, count - 1);
}
this._notice();
});
}
removeFloatDom(id: string): void {
if (this._domLayerMap.delete(id)) {
this._notice();
}
}
removeAll(): void {
this._domLayerMap.clear();
this._notice();
}
override dispose(): void {
this._domLayerMap.clear();
this._scopedRenderRootCount.clear();
this._domLayers$.next([]);
this._domLayers$.complete();
super.dispose();
}
}
export interface ICanvasFloatDomPreview {
id: string;
image: string;
updatedAt: number;
}
export interface ICanvasFloatDomPreviewRequest {
id: string;
width: number;
height: number;
data?: unknown;
}
export class CanvasFloatDomPreviewService extends Disposable {
readonly previewUpdated$ = new Subject<ICanvasFloatDomPreview>();
readonly previewRequested$ = new Subject<ICanvasFloatDomPreviewRequest>();
private readonly _previewMap = new Map<string, ICanvasFloatDomPreview>();
private readonly _requestMap = new Map<string, ICanvasFloatDomPreviewRequest>();
getPreview(id: string): ICanvasFloatDomPreview | undefined {
return this._previewMap.get(id);
}
getPendingRequests(): ICanvasFloatDomPreviewRequest[] {
return Array.from(this._requestMap.values());
}
setPreview(preview: ICanvasFloatDomPreview): void {
this._previewMap.set(preview.id, preview);
this._requestMap.delete(preview.id);
this.previewUpdated$.next(preview);
}
removePreview(id: string): void {
this._previewMap.delete(id);
this._requestMap.delete(id);
}
requestPreview(request: ICanvasFloatDomPreviewRequest): void {
this._requestMap.set(request.id, request);
this.previewRequested$.next(request);
}
override dispose(): void {
this._previewMap.clear();
this._requestMap.clear();
this.previewUpdated$.complete();
this.previewRequested$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,408 @@
/**
* 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 { IUniverUIConfig } from '../config/config';
import { createIdentifier, IConfigService } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { UI_PLUGIN_CONFIG_KEY } from '../config/config';
export const IFontService = createIdentifier<IFontService>('univer.font-service');
/**
* Font configuration interface
*/
export interface IFontConfig {
/**
* Unique identifier, usually also the preferred value for CSS font-family
* @example 'Microsoft YaHei'
*/
value: string;
/**
* Translation key for i18n
* @example 'font.microsoft_yahei'
*/
label: string;
/**
* Font category for UI grouping (optional)
*/
category?: 'sans-serif' | 'serif' | 'monospace' | 'display' | 'handwriting';
}
const DEFAULT_FONT_NAMES = [
'宋体',
'Arial',
'Baskerville',
'Comic Sans MS',
'Courier New',
'Helvetica',
'Times New Roman',
'Verdana',
'Apple LiGothic',
'Apple LiSung',
'Hei',
'Kai',
'LiHei Pro',
'LiSong Pro',
'STFangsong',
'STHeiti',
'STKaiti',
'STSong',
'报隶-繁',
'报隶-简',
'冬青黑体简体中文',
'黑体-繁',
'黑体-简',
'华文仿宋',
'华文楷体',
'华文宋体',
'楷体-繁',
'楷体-简',
'兰亭黑-繁',
'兰亭黑-简',
'隶变-繁',
'隶变-简',
'凌慧体-繁',
'凌慧体-简',
'翩翩体-繁',
'翩翩体-简',
'苹方-繁',
'苹方-港',
'苹方-简',
'手札体-繁',
'手札体-简',
'宋体-繁',
'宋体-简',
'娃娃体-繁',
'娃娃体-简',
'魏碑-繁',
'魏碑-简',
'行楷-繁',
'行楷-简',
'雅痞-繁',
'雅痞-简',
'圆体-繁',
'圆体-简',
'Arial Black',
'Big Caslon',
'Courier',
'Didot',
'Futura',
'Georgia',
'Helvetica Neue',
'Optima',
'Times',
'Tahoma',
'BIZ UDGothic',
'BIZ UDMincho',
'Hiragino Maru Gothic Pro',
'Hiragino Maru Gothic ProN',
'Hiragino Mincho Pro',
'Hiragino Mincho ProN',
'Hiragino Sans',
'Klee',
'Osaka',
'Toppan Bunkyu Gothic',
'Toppan Bunkyu Midashi Gothic',
'Toppan Bunkyu Midashi Mincho',
'Toppan Bunkyu Mincho',
'Tsukushi A Round Gothic',
'Tsukushi B Round Gothic',
'YuKyokasho',
'YuKyokasho Yoko',
'YuMincho +36p Kana',
'American Typewriter',
'Andale Mono',
'Apple Chancery',
'Apple SD Gothic Neo',
'Apple Symbols',
'AppleGothic',
'AppleMyungjo',
'Arial Unicode MS',
'Athelas',
'Avenir',
'Avenir Next',
'Avenir Next Condensed',
'Ayuthaya',
'Bangla MN',
'Bangla Sangam MN',
'Bauhaus 93',
'Bodoni 72',
'Bodoni 72 Oldstyle',
'Bodoni 72 Smallcaps',
'Bodoni Ornaments',
'Bookshelf Symbol 7',
'Bradley Hand',
'Chalkboard',
'Chalkboard SE',
'Chalkduster',
'Charter',
'Cochin',
'Copperplate',
'Devanagari Sangam MN',
'Din Alternate',
'Euphemia UCAS',
'Galvji',
'Geneva',
'Gill Sans',
'Gujarati Sangam MN',
'GungSeo',
'Gurmukhi MN',
'Gurmukhi Sangam MN',
'HeadLineA',
'Herculanum',
'Hiragino Kaku Gothic Pro',
'Hiragino Kaku Gothic ProN',
'Hiragino Kaku Gothic Std',
'Hiragino Kaku Gothic StdN',
'Hoefler Text',
'Impact',
'InaiMathi',
'Iowan Old Style',
'Kannada MN',
'Kannada Sangam MN',
'Kefa',
'Khmer MN',
'Khmer Sangam MN',
'Kohinoor Bangla',
'Kohinoor Devanagari',
'Kohinoor Telugu',
'Krungthep',
'Lao MN',
'Lao Sangam MN',
'Lucida Grande',
'Luminari',
'Malayalam MN',
'Malayalam Sangam MN',
'Marion',
'Marker Felt',
'Menlo',
'Microsoft Sans Serif',
'Modern No. 20',
'Monaco',
'Mukta Mahee',
'Nanum Brush Script',
'Nanum Gothic',
'Nanum Myeongjo',
'Nanum Pen Script',
'Noteworthy',
'Oriya MN',
'Oriya Sangam MN',
'Palatino',
'Papyrus',
'PCMyungjo',
'Phosphate',
'PilGi',
'Plantagenet Cherokee',
'PT Mono',
'PT Sans',
'PT Sans Caption',
'PT Serif',
'PT Serif Caption',
'Rockwell',
'Sathu',
'Savoye LET',
'Seravek',
'Shree Devanagari 714',
'SignPainter',
'Silom',
'Sinhala MN',
'Sinhala Sangam MN',
'Skia',
'Snell Roundhand',
'STIXGeneral',
'Sukhumvit Set',
'Superclarendon',
'Tamil MN',
'Tamil Sangam MN',
'Telugu MN',
'Telugu Sangam MN',
'Trattatello',
'Trebuchet MS',
'Webdings',
'Wingdings',
'Wingdings 2',
'Wingdings 3',
'YuGothic',
'YuGothic Light',
'YuGothic Medium',
'Zapfino',
] as const;
const DEFAULT_FONT_LIST: IFontConfig[] = DEFAULT_FONT_NAMES.map((value) => ({ value, label: value }));
export interface IFontService {
/**
* The data stream of the font list
* UI components should subscribe to this stream to render dropdown lists
* When the list changes (add, delete, update), a new value is automatically emitted
*/
readonly fonts$: BehaviorSubject<IFontConfig[]>;
/**
* Get a snapshot of the current font list (synchronously)
* Suitable for scenarios where subscribing to the stream is not needed,
* or when obtaining the current state during logic processing
*/
getFonts(): IFontConfig[];
/**
* Get a single font configuration by value
*/
getFontByValue(value: string): IFontConfig | undefined;
/**
* Check if the current browser environment supports the font
* (Based on document.fonts.check or Canvas fallback)
*/
isFontSupported(fontValue: string): boolean;
/**
* Add a new font
* @throws Error if the font value already exists
*/
addFont(font: IFontConfig): void;
/**
* Update an existing font configuration
* Supports partial updates (e.g., only updating stack or label)
* @param value The unique identifier of the font to update
* @param updates The fields to update
*/
updateFont(value: string, updates: Partial<Omit<IFontConfig, 'value'>>): void;
/**
* Remove a font
* @param value The identifier of the font to remove
* @returns boolean Whether the removal was successful (e.g., built-in fonts may not be allowed to be removed)
*/
removeFont(value: string): boolean;
/**
* Reset to the default built-in font list
* (Used for the "Restore Defaults" feature)
*/
resetToDefaults(): void;
}
export class FontService implements IFontService, IDisposable {
readonly fonts$: BehaviorSubject<IFontConfig[]> = new BehaviorSubject<IFontConfig[]>([]);
constructor(@IConfigService protected readonly _configService: IConfigService) {
// Initialize font list from configuration
const config = this._configService.getConfig<IUniverUIConfig>(UI_PLUGIN_CONFIG_KEY);
const { customFontFamily } = config ?? {};
let fonts: IFontConfig[] = [];
if (customFontFamily) {
if (Array.isArray(customFontFamily)) {
fonts = [...DEFAULT_FONT_LIST, ...customFontFamily];
} else if (customFontFamily.override) {
fonts = [...customFontFamily.list];
} else {
fonts = [...DEFAULT_FONT_LIST, ...customFontFamily.list];
}
} else {
fonts = [...DEFAULT_FONT_LIST];
}
this.fonts$.next(fonts);
}
dispose() {
this.resetToDefaults();
this.fonts$.complete();
}
getFonts(): IFontConfig[] {
return this.fonts$.getValue();
}
getFontByValue(value: string): IFontConfig | undefined {
return this.getFonts().find((font) => font.value === value);
}
/**
* Check if the current browser environment supports the font
* @param fontValue
* @returns boolean Whether the font is supported
*/
isFontSupported(fontValue: string): boolean {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) return false;
const text = 'abcdefghijklmnopqrstuvwxyz0123456789';
const size = '72px';
const baseFonts = ['monospace', 'serif', 'sans-serif'];
const defaultWidths: Record<string, number> = {};
baseFonts.forEach((base) => {
context.font = `${size} ${base}`;
defaultWidths[base] = context.measureText(text).width;
});
return baseFonts.some((base) => {
context.font = `${size} "${fontValue}", ${base}`;
const width = context.measureText(text).width;
return width !== defaultWidths[base];
});
}
addFont(font: IFontConfig): void {
const existingFont = this.getFontByValue(font.value);
if (existingFont) {
throw new Error(`[FontService]: Font with value "${font.value}" already exists.`);
}
const updatedFonts = [...this.getFonts(), font].sort();
this.fonts$.next(updatedFonts);
}
updateFont(value: string, updates: Partial<Omit<IFontConfig, 'value'>>): void {
const fonts = this.getFonts();
const fontIndex = fonts.findIndex((font) => font.value === value);
if (fontIndex === -1) {
throw new Error(`Font with value "${value}" not found.`);
}
const updatedFont = { ...fonts[fontIndex], ...updates };
const updatedFonts = [...fonts];
updatedFonts[fontIndex] = updatedFont;
this.fonts$.next(updatedFonts);
}
removeFont(value: string): boolean {
const fonts = this.getFonts();
const fontIndex = fonts.findIndex((font) => font.value === value);
if (fontIndex === -1) {
return false;
}
const updatedFonts = fonts.filter((font) => font.value !== value);
this.fonts$.next(updatedFonts);
return true;
}
resetToDefaults(): void {
this.fonts$.next([...DEFAULT_FONT_LIST]);
}
}

View File

@@ -0,0 +1,77 @@
/**
* 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 { IGalleryProps } from '@univerjs/design';
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopGalleryService } from '../desktop-gallery.service';
import { IGalleryService } from '../gallery.service';
function createService(): IGalleryService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IGalleryService, { useClass: DesktopGalleryService }]);
return injector.get(IGalleryService);
}
describe('DesktopGalleryService', () => {
it('publishes gallery open and close state for image preview workflows', () => {
const service = createService();
const snapshots: IGalleryProps[] = [];
const sub = service.gallery$.subscribe((value) => snapshots.push(value));
service.open({ images: ['a.png', 'b.png'] });
service.close();
expect(snapshots).toEqual([
{ open: true, images: ['a.png', 'b.png'] },
{ open: false, images: [] },
]);
sub.unsubscribe();
});
it('replays the open state when the global gallery UI subscribes after the first open', () => {
const service = createService();
service.open({ images: ['a.png'] });
const snapshots: IGalleryProps[] = [];
const sub = service.gallery$.subscribe((value) => snapshots.push(value));
expect(snapshots).toEqual([{ open: true, images: ['a.png'] }]);
sub.unsubscribe();
});
it('completes gallery state when the open handle is disposed', () => {
const service = createService();
let completed = false;
service.gallery$.subscribe({ complete: () => completed = true });
const disposable = service.open({ images: ['a.png'] });
disposable.dispose();
expect(completed).toBe(true);
});
it('registers the global gallery UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IGalleryService, { useClass: DesktopGalleryService }]);
injector.get(IGalleryService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,65 @@
/**
* 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 { IGalleryProps } from '@univerjs/design';
import type { IGalleryService } from './gallery.service';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { ReplaySubject } from 'rxjs';
import { connectInjector } from '../../utils/di';
import { GalleryPart } from '../../views/components/gallery-part/GalleryPart';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopGalleryService extends Disposable implements IGalleryService {
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
gallery$ = new ReplaySubject<IGalleryProps>(1);
override dispose(): void {
super.dispose();
}
open(option: IGalleryProps): IDisposable {
this.gallery$.next({
open: true,
...option,
});
return toDisposable(() => {
this.gallery$.complete();
});
}
close() {
this.gallery$.next({
open: false,
images: [],
});
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(GalleryPart, this._injector))
);
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { IGalleryProps } from '@univerjs/design';
import type { Subject } from 'rxjs';
import { createIdentifier } from '@univerjs/core';
export const IGalleryService = createIdentifier<IGalleryService>('univer.ui.gallery-service');
export interface IGalleryService {
gallery$: Subject<IGalleryProps>;
open(params: IGalleryProps): IDisposable;
close(): void;
}

View File

@@ -0,0 +1,263 @@
/**
* 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 {
ContextService,
DesktopLogService,
FOCUSING_UNIVER_EDITOR,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
UnitModel,
UniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopLayoutService, FOCUSING_UNIVER, ILayoutService } from '../layout.service';
class TestSlideUnit extends UnitModel {
override type = UniverInstanceType.UNIVER_SLIDE;
private readonly _name$ = new BehaviorSubject('');
override name$ = this._name$.asObservable();
constructor(private readonly _unitId = 'slide-1') {
super();
}
override getUnitId(): string {
return this._unitId;
}
override setName(name: string): void {
this._name$.next(name);
}
override getSnapshot(): object {
return { id: 'slide-1' };
}
override getRev(): number {
return 1;
}
override incrementRev(): void {}
override setRev(): void {}
}
function createElement(name: string, containsSelf = true) {
return {
dataset: { uComp: name },
contains: (target: unknown) => containsSelf && target != null && (target as HTMLElement).dataset?.uComp !== 'outside',
} as unknown as HTMLElement;
}
function dispatchFocusIn(target: HTMLElement): void {
const event = new Event('focusin');
Object.defineProperty(event, 'target', {
configurable: true,
value: target,
});
window.dispatchEvent(event);
}
function createService() {
const listeners = new Map<string, EventListener[]>();
vi.stubGlobal('window', {
addEventListener(type: string, listener: EventListener) {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
removeEventListener(type: string, listener: EventListener) {
listeners.set(type, (listeners.get(type) ?? []).filter((item) => item !== listener));
},
dispatchEvent(event: Event) {
(listeners.get(event.type) ?? []).forEach((listener) => listener(event));
return true;
},
});
vi.stubGlobal('document', { activeElement: null });
const injector = new Injector();
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
const slide = injector.createInstance(TestSlideUnit);
univerInstanceService.__addUnit(slide);
univerInstanceService.focusUnit('slide-1');
return {
injector,
service: injector.get(ILayoutService),
contextService: injector.get(IContextService),
univerInstanceService,
};
}
describe('DesktopLayoutService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('tracks registered containers and delegates focus to the focused Univer unit type', () => {
const { service } = createService();
const root = createElement('app-layout');
const content = createElement('custom-content');
const focused: string[] = [];
const rootDisposable = service.registerRootContainerElement(root);
const contentDisposable = service.registerContentElement(content);
const focusDisposable = service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId));
expect(service.rootContainerElement).toBe(root);
expect(service.getContentElement()).toBe(content);
expect(service.checkElementInCurrentContainers(content)).toBe(true);
service.focus();
expect(focused).toEqual(['slide-1']);
focusDisposable.dispose();
contentDisposable.dispose();
rootDisposable.dispose();
expect(service.rootContainerElement).toBeNull();
});
it('focuses an explicit child without replacing the globally focused host', () => {
const { injector, service, univerInstanceService } = createService();
const child = injector.createInstance(TestSlideUnit, 'slide-child');
univerInstanceService.__addUnit(child);
const current = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SLIDE);
const focused: string[] = [];
const registration = service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId));
try {
service.focus('slide-child');
expect(focused).toEqual(['slide-child']);
expect(univerInstanceService.getFocusedUnit()?.getUnitId()).toBe('slide-1');
expect(univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SLIDE)).toBe(current);
service.focus('missing-child');
expect(focused).toEqual(['slide-child']);
service.focus();
expect(focused).toEqual(['slide-child', 'slide-1']);
} finally {
registration.dispose();
injector.dispose();
}
});
it('rejects duplicate registrations that would make focus ownership ambiguous', () => {
const { service } = createService();
const root = createElement('app-layout');
const content = createElement('custom-content');
const container = createElement('dialog');
service.registerRootContainerElement(root);
service.registerContentElement(content);
service.registerContainerElement(container);
service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, () => {});
expect(() => service.registerRootContainerElement(createElement('other-root'))).toThrow('[DesktopLayoutService]: root container already registered!');
expect(() => service.registerContentElement(content)).toThrow('[DesktopLayoutService]: content container already registered!');
expect(() => service.registerContainerElement(container)).toThrow('[LayoutService]: container already registered!');
expect(() => service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, () => {})).toThrow('[DesktopLayoutService]: handler of type 3 bas been registered!');
});
it('updates focus context from window focus events and editor active state', async () => {
const { service, contextService } = createService();
const focused: string[] = [];
const root = createElement('app-layout');
const content = createElement('custom-content');
const outside = createElement('outside', false);
const editor = createElement('editor');
const button = createElement('button');
service.registerRootContainerElement(root);
service.registerContentElement(content);
service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId));
Object.defineProperty(globalThis.document, 'activeElement', {
configurable: true,
get: () => editor,
});
dispatchFocusIn(outside);
expect(service.isFocused).toBe(false);
expect(contextService.getContextValue(FOCUSING_UNIVER)).toBe(false);
expect(contextService.getContextValue(FOCUSING_UNIVER_EDITOR)).toBe(true);
Object.defineProperty(globalThis.document, 'activeElement', {
configurable: true,
get: () => content,
});
dispatchFocusIn(content);
expect(service.isFocused).toBe(true);
expect(contextService.getContextValue(FOCUSING_UNIVER)).toBe(true);
expect(contextService.getContextValue(FOCUSING_UNIVER_EDITOR)).toBe(false);
contextService.setContextValue(FOCUSING_UNIVER_EDITOR, true);
Object.defineProperty(globalThis.document, 'activeElement', {
configurable: true,
get: () => button,
});
dispatchFocusIn(button);
await Promise.resolve();
expect(focused).toEqual(['slide-1']);
expect(service.isFocused).toBe(true);
expect(contextService.getContextValue(FOCUSING_UNIVER)).toBe(true);
expect(contextService.getContextValue(FOCUSING_UNIVER_EDITOR)).toBe(false);
});
it('does not give focus back to the host unit when an embed-owned render canvas receives focus', async () => {
const { service } = createService();
const focused: string[] = [];
const embedCanvas = {
dataset: { uComp: 'render-canvas' },
closest: vi.fn((selector: string) => selector === '[data-embed-interaction-boundary-owner]' ? {} : null),
} as unknown as HTMLElement;
const root = {
dataset: { uComp: 'app-layout' },
contains: vi.fn((target: unknown) => target === embedCanvas),
} as unknown as HTMLElement;
service.registerRootContainerElement(root);
service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId));
dispatchFocusIn(embedCanvas);
await Promise.resolve();
expect(focused).toEqual([]);
});
it('focuses the unit declared by a render canvas before delegating focus', async () => {
const { injector, service, univerInstanceService } = createService();
const focused: string[] = [];
const root = createElement('app-layout');
const canvas = createElement('render-canvas');
canvas.dataset.uUnitId = 'slide-2';
root.contains = (target: unknown) => target === canvas;
const slide2 = injector.createInstance(TestSlideUnit, 'slide-2');
univerInstanceService.__addUnit(slide2, { makeCurrent: false });
service.registerRootContainerElement(root);
service.registerFocusHandler(UniverInstanceType.UNIVER_SLIDE, (unitId) => focused.push(unitId));
dispatchFocusIn(canvas);
await Promise.resolve();
expect(univerInstanceService.getFocusedUnit()?.getUnitId()).toBe('slide-2');
expect(focused).toEqual(['slide-2']);
});
});

View File

@@ -0,0 +1,223 @@
/**
* 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 { ContextService, IDisposable, Nullable } from '@univerjs/core';
import {
createIdentifier,
Disposable,
DocumentDataModel,
FOCUSING_UNIVER_EDITOR,
IContextService,
IUniverInstanceService,
remove,
toDisposable,
UniverInstanceType,
Workbook,
} from '@univerjs/core';
import { fromEvent } from 'rxjs';
import { isEmbedBoundaryTarget } from '../../utils/embed-boundary';
type FocusHandlerFn = (unitId: string) => void;
export const FOCUSING_UNIVER = 'FOCUSING_UNIVER';
const givingBackFocusElements = [
'app-layout',
// 'univer-toolbar-btn',
// 'univer-menu-item',
'button',
'sheet-bar-append-button',
'render-canvas',
'workbench-layout',
];
export interface ILayoutService {
readonly isFocused: boolean;
get rootContainerElement(): Nullable<HTMLElement>;
/** Re-focus a specific Univer business instance, or the currently focused one when omitted. */
focus(unitId?: string): void;
/** Register a focus handler to focus on certain type of Univer unit. */
registerFocusHandler(type: UniverInstanceType, handler: FocusHandlerFn): IDisposable;
/** Register the root container element. */
registerRootContainerElement(container: HTMLElement): IDisposable;
/** Register a content element. */
registerContentElement(container: HTMLElement): IDisposable;
/** Register an element as a container, especially floating components like Dialogs and Notifications. */
registerContainerElement(container: HTMLElement): IDisposable;
getContentElement(): HTMLElement;
checkElementInCurrentContainers(element: HTMLElement): boolean;
checkContentIsFocused(): boolean;
}
export const ILayoutService = createIdentifier<ILayoutService>('ui.layout-service');
/**
* This service is responsible for storing layout information of the current
* Univer application instance.
*/
export class DesktopLayoutService extends Disposable implements ILayoutService {
private _rootContainerElement: Nullable<HTMLElement> = null;
private _isFocused = false;
get isFocused(): boolean {
return this._isFocused;
}
private readonly _focusHandlers = new Map<UniverInstanceType, FocusHandlerFn>();
// FIXME: this don't need to be plural
private _contentElements: HTMLElement[] = [];
private _allContainers: HTMLElement[] = [];
constructor(
@IContextService private readonly _contextService: ContextService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initUniverFocusListener();
this._initEditorStatus();
}
get rootContainerElement() {
return this._rootContainerElement;
}
focus(unitId?: string): void {
const currentFocused = unitId === undefined
? this._univerInstanceService.getFocusedUnit()
: this._univerInstanceService.getUnit(unitId);
if (!currentFocused) {
return;
}
let handler: Nullable<FocusHandlerFn>;
if (currentFocused instanceof Workbook) {
handler = this._focusHandlers.get(UniverInstanceType.UNIVER_SHEET);
} else if (currentFocused instanceof DocumentDataModel) {
handler = this._focusHandlers.get(UniverInstanceType.UNIVER_DOC);
} else if (currentFocused?.type === UniverInstanceType.UNIVER_SLIDE) {
handler = this._focusHandlers.get(UniverInstanceType.UNIVER_SLIDE);
}
if (handler) {
handler(currentFocused.getUnitId());
}
}
registerFocusHandler(type: UniverInstanceType, handler: FocusHandlerFn): IDisposable {
if (this._focusHandlers.has(type)) {
throw new Error(`[DesktopLayoutService]: handler of type ${type} bas been registered!`);
}
this._focusHandlers.set(type, handler);
return toDisposable(() => this._focusHandlers.delete(type));
}
registerContentElement(container: HTMLElement): IDisposable {
if (this._contentElements.indexOf(container) === -1) {
this._contentElements.push(container);
return toDisposable(() => remove(this._contentElements, container));
}
throw new Error('[DesktopLayoutService]: content container already registered!');
}
getContentElement(): HTMLElement {
return this._contentElements[0];
}
registerRootContainerElement(container: HTMLElement): IDisposable {
if (this._rootContainerElement) {
throw new Error('[DesktopLayoutService]: root container already registered!');
}
this._rootContainerElement = container;
const dis = this.registerContainerElement(container);
return toDisposable(() => {
this._rootContainerElement = null;
dis.dispose();
});
}
registerContainerElement(container: HTMLElement): IDisposable {
if (this._allContainers.indexOf(container) === -1) {
this._allContainers.push(container);
return toDisposable(() => remove(this._allContainers, container));
}
throw new Error('[LayoutService]: container already registered!');
}
checkElementInCurrentContainers(element: HTMLElement): boolean {
return this._allContainers.some((container) => container.contains(element));
}
checkContentIsFocused(): boolean {
return this._contentElements.some((contentEl) => contentEl === document.activeElement || contentEl.contains(document.activeElement));
}
private _initUniverFocusListener(): void {
this.disposeWithMe(
fromEvent(window, 'focusin').subscribe((event) => {
const target = event.target as HTMLElement;
if (
this._rootContainerElement?.contains(target) &&
givingBackFocusElements.some((item) => target.dataset.uComp === item) &&
!isEmbedBoundaryTarget(target)
) {
queueMicrotask(() => {
const targetUnitId = getFocusUnitIdFromElement(target);
if (targetUnitId && this._univerInstanceService.getUnit(targetUnitId)) {
this._univerInstanceService.focusUnit(targetUnitId);
}
this.focus();
this._isFocused = true;
this._contextService.setContextValue(FOCUSING_UNIVER, this._isFocused);
this._contextService.setContextValue(FOCUSING_UNIVER_EDITOR, getFocusingUniverEditorStatus());
});
return;
}
if (target && this.checkElementInCurrentContainers(target as HTMLElement)) {
this._isFocused = true;
} else {
this._isFocused = false;
}
this._contextService.setContextValue(FOCUSING_UNIVER, this._isFocused);
this._contextService.setContextValue(FOCUSING_UNIVER_EDITOR, getFocusingUniverEditorStatus());
})
);
}
private _initEditorStatus(): void {
this._contextService.setContextValue(FOCUSING_UNIVER_EDITOR, getFocusingUniverEditorStatus());
}
}
function getFocusingUniverEditorStatus(): boolean {
return (document.activeElement as HTMLElement)?.dataset.uComp === 'editor';
}
function getFocusUnitIdFromElement(target: HTMLElement): string | undefined {
return target.dataset.uUnitId;
}

View File

@@ -0,0 +1,82 @@
/**
* 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 { Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopLocalFileService } from '../desktop-local-file.service';
import { ILocalFileService } from '../local-file.service';
function createService(): ILocalFileService {
const injector = new Injector();
injector.add([ILocalFileService, { useClass: DesktopLocalFileService }]);
return injector.get(ILocalFileService);
}
describe('DesktopLocalFileService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('creates a file input with the requested picker options', async () => {
const files = [new File(['a'], 'a.csv')];
interface IInputStub {
accept: string;
click: ReturnType<typeof vi.fn>;
multiple: boolean;
onchange: (event: { target: { files: File[] } }) => void;
type: string;
}
let input: IInputStub = {
accept: '',
click: vi.fn(),
multiple: false,
onchange: () => {},
type: '',
};
vi.stubGlobal('document', {
createElement: () => {
input = {
click: vi.fn(() => input.onchange({ target: { files } })),
accept: '',
multiple: false,
onchange: () => {},
type: '',
};
return input;
},
});
const service = createService();
await expect(service.openFile({ accept: '.csv', multiple: true })).resolves.toEqual(files);
expect(input.type).toBe('file');
expect(input.accept).toBe('.csv');
expect(input.multiple).toBe(true);
expect(input.click).toHaveBeenCalledTimes(1);
});
it('creates a download link for exported file data', () => {
const link = { click: vi.fn(), download: '', href: '' };
vi.stubGlobal('document', { createElement: () => link });
vi.stubGlobal('window', { URL: { createObjectURL: () => 'blob:export' } });
const service = createService();
service.downloadFile(new Blob(['data']), 'report.csv');
expect(link.download).toBe('report.csv');
expect(link.href).toBe('blob:export');
expect(link.click).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,43 @@
/**
* 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 { ILocalFileService, IOpenFileOptions } from './local-file.service';
import { Disposable } from '@univerjs/core';
export class DesktopLocalFileService extends Disposable implements ILocalFileService {
openFile(options?: IOpenFileOptions): Promise<File[]> {
return new Promise((resolve) => {
const inputElement = document.createElement('input');
inputElement.type = 'file';
inputElement.accept = options?.accept ?? '';
inputElement.multiple = options?.multiple ?? false;
inputElement.onchange = (event) => {
const fileList = (event.target as HTMLInputElement).files;
if (fileList) {
resolve(Array.from(fileList));
}
};
inputElement.click();
});
}
downloadFile(data: Blob, fileName: string): void {
const a = document.createElement('a');
a.download = fileName;
a.href = window.URL.createObjectURL(data);
a.click();
}
}

View File

@@ -0,0 +1,31 @@
/**
* 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 { createIdentifier } from '@univerjs/core';
export interface IOpenFileOptions {
accept?: string;
multiple?: boolean;
}
/**
* This service is used to upload files.
*/
export const ILocalFileService = createIdentifier<ILocalFileService>('univer-ui.local-file.service');
export interface ILocalFileService {
openFile(options?: IOpenFileOptions): Promise<File[]>;
downloadFile(data: Blob, fileName: string): void;
}

View File

@@ -0,0 +1,72 @@
/**
* 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 { ILocalStorageService } from '@univerjs/core';
import { Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopLocalStorageService } from '../local-storage.service';
function createMemoryLocalStorage() {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
key: (index: number) => Array.from(values.keys())[index] ?? null,
};
}
function createService(): ILocalStorageService {
const injector = new Injector();
injector.add([DesktopLocalStorageService]);
return injector.get(DesktopLocalStorageService);
}
describe('DesktopLocalStorageService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('stores, lists, iterates, and removes persisted UI values', async () => {
vi.stubGlobal('localStorage', createMemoryLocalStorage());
const service = createService();
await service.setItem('sidebar', { width: 320 });
await service.setItem('theme', 'dark');
await expect(service.getItem('sidebar')).resolves.toEqual({ width: 320 });
await expect(service.keys()).resolves.toEqual(['sidebar', 'theme']);
await expect(service.iterate((value, key) => key === 'theme' ? value : undefined)).resolves.toBe('dark');
await service.removeItem('sidebar');
await expect(service.getItem('sidebar')).resolves.toBeNull();
});
it('reads key positions and clears stored values', async () => {
vi.stubGlobal('localStorage', createMemoryLocalStorage());
const service = createService();
await service.setItem('first', 1);
await expect(service.key(0)).resolves.toBe('first');
await service.clear();
await expect(service.keys()).resolves.toEqual([]);
await expect(service.key(0)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
/**
* 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 { ILocalStorageService } from '@univerjs/core';
import { browserStorage } from '../../utils/storage-driver';
export class DesktopLocalStorageService implements ILocalStorageService {
getItem<T>(key: string): Promise<T | null> {
return browserStorage.getItem<T>(key);
}
setItem<T>(key: string, value: T): Promise<T> {
return browserStorage.setItem(key, value);
}
removeItem(key: string): Promise<void> {
return browserStorage.removeItem(key);
}
clear(): Promise<void> {
return browserStorage.clear();
}
key(index: number): Promise<string | null> {
return browserStorage.key(index);
}
keys(): Promise<string[]> {
return browserStorage.keys();
}
iterate<T, U>(iteratee: (value: T, key: string, iterationNumber: number) => U): Promise<U> {
return browserStorage.iterate(iteratee);
}
}

View File

@@ -0,0 +1,84 @@
/**
* 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 { MenuSchemaType } from '../menu-manager.service';
import { ConfigService, IConfigService, Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { isMenuButtonSelectorItem, isMenuSelectorItem, MenuItemType } from '../menu';
import { IMenuManagerService, MenuManagerService } from '../menu-manager.service';
import { FloatingObjectToolbarPosition } from '../types';
function createService(): IMenuManagerService {
const injector = new Injector();
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
return injector.get(IMenuManagerService);
}
describe('MenuManagerService', () => {
it('appends command menus and returns them in display order', () => {
const service = createService();
service.appendRootMenu({
testPosition: {
second: { order: 2, menuItemFactory: () => ({ id: 'second' }) },
first: { order: 1, menuItemFactory: () => ({ id: 'first' }) },
},
} as MenuSchemaType);
expect(service.getMenuByPositionKey('testPosition').map((item) => item.key)).toEqual(['first', 'second']);
expect(service.getFlatMenuByPositionKey('testPosition').map((item) => item.item?.id)).toEqual(['first', 'second']);
});
it('merges menu contributions into existing positions and emits a change event', () => {
const service = createService();
const changes: void[] = [];
const sub = service.menuChanged$.subscribe((value) => changes.push(value));
service.appendRootMenu({ testMerge: { group: { order: 1 } } } as MenuSchemaType);
service.mergeMenu({ group: { command: { order: 1, menuItemFactory: () => ({ id: 'command' }) } } } as MenuSchemaType);
expect(service.getFlatMenuByPositionKey('testMerge').map((item) => item.key)).toEqual(['group', 'command']);
expect(changes.length).toBeGreaterThanOrEqual(2);
sub.unsubscribe();
});
it.each(Object.values(FloatingObjectToolbarPosition))('accepts contributions for %s', (position) => {
const service = createService();
service.mergeMenu({
[position]: {
comment: { order: 1, menuItemFactory: () => ({ id: 'comment' }) },
},
});
expect(service.getFlatMenuByPositionKey(position).map((item) => item.item?.id)).toEqual(['comment']);
});
it('returns an empty array for missing menu positions', () => {
const service = createService();
expect(service.getMenuByPositionKey('missing-position')).toEqual([]);
expect(service.getFlatMenuByPositionKey('missing-position')).toEqual([]);
});
it('identifies selector menu item variants used by menu renderers', () => {
expect(isMenuSelectorItem({ id: 'font-family', type: MenuItemType.SELECTOR })).toBe(true);
expect(isMenuSelectorItem({ id: 'more-actions', type: MenuItemType.SUBITEMS })).toBe(true);
expect(isMenuSelectorItem({ id: 'copy', type: MenuItemType.BUTTON })).toBe(false);
expect(isMenuButtonSelectorItem({ id: 'fill-color', type: MenuItemType.BUTTON_SELECTOR })).toBe(true);
});
});

View File

@@ -0,0 +1,497 @@
/**
* 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 { IAccessor, Nullable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import type { IMenuItem } from './menu';
import { createIdentifier, Disposable, IConfigService, Inject, Injector, merge } from '@univerjs/core';
import { Subject } from 'rxjs';
import { mergeMenuConfigs } from '../../common/menu-merge-configs';
import {
ContextMenuGroup,
ContextMenuPosition,
FloatingObjectToolbarPosition,
MenuManagerPosition,
RibbonDataGroup,
RibbonFormulasGroup,
RibbonInsertGroup,
RibbonOthersGroup,
RibbonPosition,
RibbonStartGroup,
RibbonViewGroup,
} from './types';
export const IMenuManagerService = createIdentifier<IMenuManagerService>('univer.menu-manager-service');
export type ContextMenuQuickLayout = 'icon' | 'tile';
export type ContextMenuQuickLayoutVariant = 'default' | 'compact';
export interface IRibbonGridLayout {
row: number;
column: number;
rowSpan?: number;
columnSpan?: number;
showLabel?: boolean;
width?: number;
iconSize?: number;
}
export interface IMenuSchema {
key: string;
order: number;
title?: string;
contextual?: boolean;
item?: IMenuItem;
headerActionItem?: IMenuItem;
children?: IMenuSchema[];
quickLayout?: ContextMenuQuickLayout;
quickColumns?: number;
quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
gridLayout?: IRibbonGridLayout;
}
export interface IMenuManagerService {
readonly menuChanged$: Observable<void>;
mergeMenu(source: MenuSchemaType, target?: MenuSchemaType): void;
appendRootMenu(source: MenuSchemaType): void;
getMenuByPositionKey(position: string): IMenuSchema[];
getFlatMenuByPositionKey(position: string): IMenuSchema[];
}
export type MenuSchemaType = {
order?: number;
replace?: boolean;
menuItemFactory?: (accessor: IAccessor) => IMenuItem;
headerActionMenuItemFactory?: (accessor: IAccessor) => IMenuItem;
title?: string;
contextual?: boolean;
quickLayout?: ContextMenuQuickLayout;
quickColumns?: number;
quickLayoutVariant?: ContextMenuQuickLayoutVariant;
tiny?: boolean;
gridLayout?: IRibbonGridLayout;
} | {
[key: string]: MenuSchemaType;
};
export class MenuManagerService extends Disposable implements IMenuManagerService {
readonly menuChanged$ = new Subject<void>();
private _menu: MenuSchemaType = {
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
order: 0,
title: 'ui.ribbon.start',
[RibbonStartGroup.HISTORY]: {
order: 0,
},
[RibbonStartGroup.FORMAT]: {
order: 1,
},
[RibbonStartGroup.LAYOUT]: {
order: 2,
},
[RibbonStartGroup.NUMBER]: {
order: 3,
},
[RibbonStartGroup.OTHERS]: {
order: 4,
},
},
[RibbonPosition.INSERT]: {
order: 1,
title: 'ui.ribbon.insert',
[RibbonInsertGroup.EDIT]: {
order: 0,
},
[RibbonInsertGroup.MEDIA]: {
order: 1,
},
[RibbonInsertGroup.CELL]: {
order: 2,
},
[RibbonInsertGroup.OTHERS]: {
order: 3,
},
},
[RibbonPosition.FORMULAS]: {
order: 2,
title: 'ui.ribbon.formulas',
[RibbonFormulasGroup.BASIC]: {
order: 0,
},
[RibbonFormulasGroup.OTHERS]: {
order: 1,
},
},
[RibbonPosition.DATA]: {
order: 3,
title: 'ui.ribbon.data',
[RibbonDataGroup.FORMULAS]: {
order: 0,
},
[RibbonDataGroup.RULES]: {
order: 1,
},
[RibbonDataGroup.ORGANIZATION]: {
order: 2,
},
[RibbonDataGroup.OTHERS]: {
order: 3,
},
},
[RibbonPosition.VIEW]: {
order: 4,
title: 'ui.ribbon.view',
[RibbonViewGroup.DISPLAY]: {
order: 0,
},
[RibbonViewGroup.VISIBILITY]: {
order: 0,
},
[RibbonViewGroup.OTHERS]: {
order: 0,
},
},
[RibbonPosition.OTHERS]: {
order: 5,
title: 'ui.ribbon.others',
[RibbonOthersGroup.OTHERS]: {
order: 0,
},
},
},
[MenuManagerPosition.CONTEXT_MENU]: {
[ContextMenuPosition.MAIN_AREA]: {
order: 0,
[ContextMenuGroup.QUICK]: {
order: -1,
quickLayout: 'icon',
},
[ContextMenuGroup.FORMAT]: {
order: 0,
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.PARAGRAPH]: {
order: 0,
[ContextMenuGroup.QUICK]: {
order: -1,
quickLayout: 'icon',
},
[ContextMenuGroup.FORMAT]: {
order: 0,
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.COL_HEADER]: {
order: 1,
[ContextMenuGroup.QUICK]: {
order: -1,
quickLayout: 'icon',
},
[ContextMenuGroup.FORMAT]: {
order: 0,
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.ROW_HEADER]: {
order: 2,
[ContextMenuGroup.QUICK]: {
order: -1,
quickLayout: 'icon',
},
[ContextMenuGroup.FORMAT]: {
order: 0,
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.FOOTER_TABS]: {
order: 3,
[ContextMenuGroup.FORMAT]: {
order: 0,
},
[ContextMenuGroup.LAYOUT]: {
order: 1,
},
[ContextMenuGroup.DATA]: {
order: 2,
},
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.FOOTER_MENU]: {
order: 4,
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
[ContextMenuPosition.DRAWING]: {
order: 5,
[ContextMenuGroup.OTHERS]: {
order: 3,
},
},
},
[FloatingObjectToolbarPosition.SHEET]: {},
[FloatingObjectToolbarPosition.DOC]: {},
[FloatingObjectToolbarPosition.SLIDE]: {},
[FloatingObjectToolbarPosition.BOARD]: {},
};
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
}
override dispose(): void {
this.menuChanged$.complete();
}
/**
* Merge source menu to target menu recursively
* @param source
* @param target default is root menu
*/
mergeMenu(source: MenuSchemaType, target?: MenuSchemaType): void {
const _target = target ?? this._menu;
for (const [key, value] of Object.entries(_target)) {
if (key in source) {
const targetRecord = _target as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
targetRecord[key] = mergeMenuSchemaNode(targetRecord[key], sourceRecord[key]);
this.menuChanged$.next();
} else if (isMenuSchemaRecord(value)) {
this.mergeMenu(source, value as MenuSchemaType);
}
}
}
appendRootMenu(source: MenuSchemaType): void {
this._menu = merge({}, this._menu, source);
this.menuChanged$.next();
}
createScoped(injector: Injector): IMenuManagerService {
const root = this;
const createScopedBuilder = () => {
const service = new MenuManagerService(injector, root._configService);
service._menu = root._menu;
return service;
};
return {
menuChanged$: root.menuChanged$,
mergeMenu: (source: MenuSchemaType, target?: MenuSchemaType) => root.mergeMenu(source, target),
appendRootMenu: (source: MenuSchemaType) => root.appendRootMenu(source),
getMenuByPositionKey: (position: string) => createScopedBuilder().getMenuByPositionKey(position),
getFlatMenuByPositionKey: (position: string) => createScopedBuilder().getFlatMenuByPositionKey(position),
};
}
private _buildMenuSchema(data: MenuSchemaType): IMenuSchema[] {
const result: IMenuSchema[] = [];
for (const [key, value] of Object.entries(data)) {
if (key === 'replace') {
continue;
}
const menuItem: Partial<IMenuSchema> = {
key,
order: value.order,
title: value.title,
contextual: value.contextual,
quickLayout: value.quickLayout,
quickColumns: value.quickColumns,
quickLayoutVariant: value.quickLayoutVariant,
tiny: value.tiny,
gridLayout: value.gridLayout,
};
if (value.menuItemFactory) {
const item: IMenuItem = this._injector.invoke(value.menuItemFactory);
if (item) {
const menuItemConfig: Nullable<IMenuItem> = this._configService.getConfig<IMenuItem>('menu');
if (menuItemConfig && item.id in menuItemConfig) {
const _key = item.id as keyof IMenuItem;
menuItem.item = mergeMenuConfigs(item, menuItemConfig[_key] as any);
} else {
menuItem.item = item;
}
}
}
if (value.headerActionMenuItemFactory) {
menuItem.headerActionItem = this._injector.invoke(value.headerActionMenuItemFactory);
}
if (typeof value === 'object') {
const children = this._buildMenuSchema(value);
if (children.length > 0) {
menuItem.children = children.sort((a, b) => a.order - b.order);
}
if (menuItem.item || menuItem.children) {
result.push(menuItem as IMenuSchema);
}
}
}
return result.sort((a, b) => normalizeMenuOrder(a.order) - normalizeMenuOrder(b.order));
}
/**
* Get menu schema by position key
* @param key
* @returns Menu schema array or empty array if not found
*/
getMenuByPositionKey(key: string): IMenuSchema[] {
const findKey = (obj: any): any => {
if (key in obj) {
return this._buildMenuSchema(obj[key]);
}
for (const k in obj) {
if (k === key) {
return this._buildMenuSchema(obj[k]);
}
if (typeof obj[k] === 'object') {
const result = findKey(obj[k]);
if (result) {
return result;
}
}
}
};
return findKey(this._menu) ?? [];
}
/**
* Get flat menu schema by position key
* @param key
* @returns Flat menu schema array or empty array if not found
*/
getFlatMenuByPositionKey(key: string): IMenuSchema[] {
const menu = this.getMenuByPositionKey(key);
function flatMenuItems(items: IMenuSchema[]): IMenuSchema[] {
return items.reduce((acc, item) => {
if (item.children) {
return [...acc, item, ...flatMenuItems(item.children)];
}
return [...acc, item];
}, [] as IMenuSchema[]);
}
return flatMenuItems(menu);
}
}
function normalizeMenuOrder(order: number | undefined): number {
return order ?? 0;
}
function isMenuSchemaRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function cloneMenuSchemaNode<T>(source: T, preserveReplace = false): T {
if (!isMenuSchemaRecord(source)) {
return source;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key === 'replace' && !preserveReplace) {
continue;
}
result[key] = cloneMenuSchemaNode(value, preserveReplace);
}
return result as T;
}
function mergeMenuSchemaNode(target: unknown, source: unknown): unknown {
if (!isMenuSchemaRecord(source) || !isMenuSchemaRecord(target)) {
return cloneMenuSchemaNode(source);
}
if (source.replace === true) {
return cloneMenuSchemaNode(source, true);
}
if (target.replace === true) {
return cloneMenuSchemaNode(target, true);
}
const result = merge({}, target) as Record<string, unknown>;
for (const [key, value] of Object.entries(source)) {
if (key === 'replace') {
continue;
}
result[key] = key in result
? mergeMenuSchemaNode(result[key], value)
: cloneMenuSchemaNode(value, true);
}
return result;
}

View File

@@ -0,0 +1,149 @@
/**
* 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 { IAccessor } from '@univerjs/core';
import type { Observable } from 'rxjs';
export type OneOrMany<T> = T | T[];
export enum MenuItemType {
/** Button style menu item. */
BUTTON,
/** Menu item with submenus. Submenus could be other IMenuItem or an ID of a registered component. */
SELECTOR,
/** Button style menu item with a dropdown menu. */
BUTTON_SELECTOR,
/** Submenus have to specific features and do not invoke commands. */
SUBITEMS,
}
type MenuLabel = string | {
name: string;
hoverable?: boolean;
selectable?: boolean;
props?: Record<string, any>;
};
export type IMenuCommandParams = Record<string, unknown>;
interface IMenuItemBase<TLocaleKey extends string, TValue> {
/** ID of the menu item. Normally it should be the same as the ID of the command that it would invoke. */
id: string;
/**
* If two menus reuse the same command (e.g. copy & paste command). They should have the same command
* id and different ids.
*/
commandId?: string;
subId?: string;
title?: TLocaleKey;
description?: string;
icon?: string | Observable<string>;
tooltip?: TLocaleKey;
slot?: boolean;
type: MenuItemType;
/**
* Custom label component id.
*/
label?: MenuLabel; // custom component, send to CustomLabel label property
hidden$?: Observable<boolean>;
disabled$?: Observable<boolean>;
params?: IMenuCommandParams | (() => IMenuCommandParams | undefined);
/** On observable value that should emit the value of the corresponding selection component. */
value$?: Observable<TValue>;
}
export interface IMenuButtonItem<TLocaleKey extends string = string, TValue = undefined> extends IMenuItemBase<TLocaleKey, TValue> {
type: MenuItemType.BUTTON;
activated$?: Observable<boolean>;
}
export interface IValueOption<TLocaleKey extends string = string, TValue = undefined> {
id?: string;
value?: string | number;
value$?: Observable<TValue>;
params?: IMenuCommandParams | ((value?: string | number) => IMenuCommandParams | undefined);
slot?: boolean;
label?: MenuLabel; // custom component, send to CustomLabel label property
icon?: string;
tooltip?: TLocaleKey;
style?: object;
disabled?: boolean;
commandId?: string;
}
export interface ICustomComponentProps<T> {
value: T;
onChange: (v: T) => void;
}
export interface IMenuSelectorItem<
TLocaleKey extends string = string,
TValue = MenuItemDefaultValueType,
TOptionValue = undefined
> extends IMenuItemBase<TLocaleKey, TValue> {
type: MenuItemType.SELECTOR | MenuItemType.BUTTON_SELECTOR | MenuItemType.SUBITEMS;
/**
* If this property is set, changing the value of the selection will trigger the command with this id,
* instead of {@link IMenuItemBase.id} or {@link IMenuItemBase.commandId}. At the same title,
* clicking the button will trigger IMenuItemBase.id or IMenuItemBase.commandId.
*/
selectionsCommandId?: string;
// selections submenu can be of three types
// One is the current menu's options, after selection directly use its value to trigger command
// One is a special component, such as color picker, after selection directly use its value to trigger command
// One is another menu's id, directly rendered as another menu
/** Options or IDs of registered components. */
selections?: Array<IValueOption<TLocaleKey, TOptionValue>> | Observable<Array<IValueOption<TLocaleKey, TOptionValue>>>;
/** If `type` is `MenuItemType.BUTTON_SELECTOR`, this determines if the button is activated. */
activated$?: Observable<boolean>;
}
export type MenuItemDefaultValueType = string | number | undefined;
export type IMenuItem<TLocaleKey extends string = string> =
| IMenuButtonItem<TLocaleKey, MenuItemDefaultValueType>
| IMenuSelectorItem<TLocaleKey, MenuItemDefaultValueType, any>;
export function isMenuSelectorItem<T extends MenuItemDefaultValueType, TLocaleKey extends string = string>(
v: IMenuItem<TLocaleKey>
): v is IMenuSelectorItem<TLocaleKey, T, any> {
return v.type === MenuItemType.SELECTOR || v.type === MenuItemType.SUBITEMS;
}
export function isMenuButtonSelectorItem<T extends MenuItemDefaultValueType, TLocaleKey extends string = string>(
v: IMenuItem<TLocaleKey>
): v is IMenuSelectorItem<TLocaleKey, T, any> {
return v.type === MenuItemType.BUTTON_SELECTOR;
}
export type IDisplayMenuItem<T extends IMenuItem = IMenuItem> = T & {
shortcut?: string;
};
export type MenuItemConfig<TLocaleKey extends string = string> = Partial<Omit<IMenuItem<TLocaleKey>, 'id' | 'subId' | 'value$' | 'hidden$' | 'disabled$' | 'activated$' | 'icon$'> & {
hidden?: boolean;
disabled?: boolean;
activated?: boolean;
}>;
export type MenuConfig<TLocaleKey extends string = string> = Record<string, MenuItemConfig<TLocaleKey>>;
export type IMenuItemFactory<TLocaleKey extends string = string> = (accessor: IAccessor, menuConfig?: MenuConfig<TLocaleKey>) => IMenuItem<TLocaleKey>;

View File

@@ -0,0 +1,100 @@
/**
* 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.
*/
export enum MenuManagerPosition {
RIBBON = 'ribbon',
CONTEXT_MENU = 'contextMenu',
}
export enum FloatingObjectToolbarPosition {
SHEET = 'floatingObjectToolbar.sheet',
DOC = 'floatingObjectToolbar.doc',
SLIDE = 'floatingObjectToolbar.slide',
BOARD = 'floatingObjectToolbar.board',
}
export enum RibbonPosition {
START = 'ribbon.start', // RibbonStartGroup
INSERT = 'ribbon.insert', // RibbonInsertGroup
FORMULAS = 'ribbon.formulas', // RibbonFormulasGroup
DATA = 'ribbon.data', // RibbonDataGroup
VIEW = 'ribbon.view', // RibbonViewGroup
OTHERS = 'ribbon.others', // RibbonOthersGroup
}
export enum RibbonStartGroup {
HISTORY = 'ribbon.start.history',
FORMAT = 'ribbon.start.format',
LAYOUT = 'ribbon.start.layout',
NUMBER = 'ribbon.start.number',
OTHERS = 'ribbon.start.others',
}
export enum RibbonInsertGroup {
EDIT = 'ribbon.insert.edit',
CELL = 'ribbon.insert.cell',
MEDIA = 'ribbon.insert.media',
OTHERS = 'ribbon.insert.others',
}
export enum RibbonFormulasGroup {
BASIC = 'ribbon.formulas.basic',
OTHERS = 'ribbon.formulas.others',
}
export enum RibbonDataGroup {
FORMULAS = 'ribbon.data.formulas',
RULES = 'ribbon.data.rules',
ORGANIZATION = 'ribbon.data.organization',
OTHERS = 'ribbon.data.others',
}
export enum RibbonViewGroup {
DISPLAY = 'ribbon.view.display',
VISIBILITY = 'ribbon.view.Visibility',
OTHERS = 'ribbon.view.others',
}
export enum RibbonOthersGroup {
OTHERS = 'ribbon.others.others',
}
export enum ContextMenuPosition {
MAIN_AREA = 'contextMenu.mainArea',
COL_HEADER = 'contextMenu.colHeader',
ROW_HEADER = 'contextMenu.rowHeader',
FOOTER_TABS = 'contextMenu.footerTabs',
FOOTER_MENU = 'contextMenu.footerMenu',
/**
* paragraph context menu in doc
*/
PARAGRAPH = 'contextMenu.paragraph',
/**
* drawing context menu, now only used in drawing group operation
*/
DRAWING = 'contextMenu.drawing',
}
export enum ContextMenuGroup {
/**
* quick context menu, displayed as icon
*/
QUICK = 'contextMenu.quick',
FORMAT = 'contextMenu.format',
LAYOUT = 'contextMenu.layout',
DATA = 'contextMenu.data',
OTHERS = 'contextMenu.others',
}

View File

@@ -0,0 +1,57 @@
/**
* 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 { Injector } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopMessageService } from '../desktop-message.service';
import { IMessageService } from '../message.service';
function createService(): IMessageService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IMessageService, { useClass: DesktopMessageService }]);
return injector.get(IMessageService);
}
describe('DesktopMessageService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows a message and allows callers to close it through the returned disposable', () => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(1);
return 1;
});
const service = createService();
const disposable = service.show({ id: 'save-message', content: 'Saved', type: MessageType.Success });
expect(() => disposable.dispose()).not.toThrow();
expect(() => service.removeAll()).not.toThrow();
});
it('registers the message container UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IMessageService, { useClass: DesktopMessageService }]);
injector.get(IMessageService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,38 @@
/**
* 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 { Injector } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { describe, expect, it } from 'vitest';
import { IMessageService } from '../message.service';
import { MockMessageService } from './mock-message.service';
function createService(): IMessageService {
const injector = new Injector();
injector.add([IMessageService, { useClass: MockMessageService }]);
return injector.get(IMessageService);
}
describe('MockMessageService', () => {
it('acts as a no-op message channel for tests without a UI container', () => {
const service = createService();
const disposable = service.show({ content: 'Saved', type: MessageType.Success });
expect(() => disposable.dispose()).not.toThrow();
expect(() => service.remove('message-1')).not.toThrow();
expect(() => service.removeAll()).not.toThrow();
});
});

View File

@@ -0,0 +1,47 @@
/**
* 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 { IMessageProps } from '@univerjs/design';
import type { IMessageService } from '../message.service';
import { toDisposable } from '@univerjs/core';
/**
* This is a mocked message service for testing purposes.
*
* @ignore
*/
export class MockMessageService implements IMessageService {
show(_options: IMessageProps): IDisposable {
return toDisposable(() => { /* empty */ });
}
remove(_id: string): void {
// empty
}
removeAll(): void {
// empty
}
setContainer(): void {
// empty
}
getContainer(): HTMLElement | undefined {
return undefined;
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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 { IMessageProps } from '@univerjs/design';
import type { IMessageService } from './message.service';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { message, removeMessage } from '@univerjs/design';
import { connectInjector } from '../../utils/di';
import { MessageContainer } from '../../views/message/MessageContainer';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
let messageId = 0;
export class DesktopMessageService extends Disposable implements IMessageService {
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(MessageContainer, this._injector))
);
}
override dispose(): void {
super.dispose();
removeMessage();
}
show(options: IMessageProps): IDisposable {
const id = options.id ?? `message-${Date.now()}-${messageId}`;
messageId += 1;
const op = Object.assign({}, options, { id });
message(op);
return toDisposable(() => removeMessage(id));
}
remove(id: string): void {
removeMessage(id);
}
removeAll(): void {
removeMessage();
}
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { IMessageProps } from '@univerjs/design';
import { createIdentifier } from '@univerjs/core';
export const IMessageService = createIdentifier<IMessageService>('ui.message.service');
export interface IMessageService {
show(options: IMessageProps): IDisposable;
remove(id: string): void;
removeAll(): void;
}

View File

@@ -0,0 +1,47 @@
/**
* 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 { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../../parts/parts.service';
import { DesktopNotificationService } from '../desktop-notification.service';
import { INotificationService } from '../notification.service';
function createService(): INotificationService {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([INotificationService, { useClass: DesktopNotificationService }]);
return injector.get(INotificationService);
}
describe('DesktopNotificationService', () => {
it('shows a notification and returns a disposable close handle', () => {
const service = createService();
const disposable = service.show({ title: 'Saved', content: 'Workbook saved', type: 'success' });
expect(() => disposable.dispose()).not.toThrow();
});
it('registers the notification UI part when constructed', () => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([INotificationService, { useClass: DesktopNotificationService }]);
injector.get(INotificationService);
expect(injector.get(IUIPartsService).getComponents(BuiltInUIPart.GLOBAL).size).toBe(1);
});
});

View File

@@ -0,0 +1,47 @@
/**
* 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 { INotificationOptions } from '../../views/notification/Notification';
import type { INotificationService } from './notification.service';
import { Disposable, Inject, Injector, toDisposable } from '@univerjs/core';
import { connectInjector } from '../../utils/di';
import { notification, Notification } from '../../views/notification/Notification';
import { BuiltInUIPart, IUIPartsService } from '../parts/parts.service';
export class DesktopNotificationService extends Disposable implements INotificationService {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IUIPartsService private readonly _uiPartsService: IUIPartsService
) {
super();
this._initUIPart();
}
show(params: INotificationOptions): IDisposable {
notification.show(params);
return toDisposable(() => { /* empty */ });
}
protected _initUIPart(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(Notification, this._injector))
);
}
}

View File

@@ -0,0 +1,25 @@
/**
* 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 { INotificationOptions } from '../../views/notification/Notification';
import { createIdentifier } from '@univerjs/core';
export const INotificationService = createIdentifier<INotificationService>('ui.notification.service');
export interface INotificationService {
show(params: INotificationOptions): IDisposable;
}

View File

@@ -0,0 +1,67 @@
/**
* 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 { ComponentType } from '../../../common/component-manager';
import { Injector } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { BuiltInUIPart, IUIPartsService, UIPartsService } from '../parts.service';
describe('UIPartsService', () => {
let service: IUIPartsService;
beforeEach(() => {
const injector = new Injector();
injector.add([IUIPartsService, { useClass: UIPartsService }]);
service = injector.get(IUIPartsService);
});
it('registers UI components by screen region and removes them with the disposable', () => {
const events: string[] = [];
service.componentRegistered$.subscribe((part) => events.push(part));
const Toolbar = (() => null) as ComponentType;
const disposable = service.registerComponent(BuiltInUIPart.TOOLBAR, () => Toolbar);
expect([...service.getComponents(BuiltInUIPart.TOOLBAR)]).toEqual([Toolbar]);
disposable.dispose();
expect([...service.getComponents(BuiltInUIPart.TOOLBAR)]).toEqual([]);
expect(events).toEqual([BuiltInUIPart.TOOLBAR, BuiltInUIPart.TOOLBAR]);
});
it('publishes UI visibility changes for embedders that hide built-in chrome', () => {
const changes: Array<{ ui: string; visible: boolean }> = [];
service.uiVisibleChange$.subscribe((change) => changes.push(change));
service.setUIVisible(BuiltInUIPart.HEADER, false);
expect(service.isUIVisible(BuiltInUIPart.HEADER)).toBe(false);
expect(service.isUIVisible(BuiltInUIPart.FOOTER)).toBe(true);
expect(changes).toEqual([{ ui: BuiltInUIPart.HEADER, visible: false }]);
});
it('disables registered UI parts synchronously and restores them on disposal', () => {
const disabled$ = new BehaviorSubject(false);
const disposable = service.registerDisabledUIParts([BuiltInUIPart.TOOLBAR], disabled$);
disabled$.next(true);
expect(service.isUIVisible(BuiltInUIPart.TOOLBAR)).toBe(false);
disposable.dispose();
expect(service.isUIVisible(BuiltInUIPart.TOOLBAR)).toBe(true);
expect((service as any)._collection._disposables).toHaveLength(0);
});
});

View File

@@ -0,0 +1,149 @@
/**
* 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 { Observable } from 'rxjs';
import type { ComponentType } from '../../common/component-manager';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
import { Subject } from 'rxjs';
export type ComponentRenderer = () => ComponentType;
type ComponentPartKey = BuiltInUIPart | string;
export enum BuiltInUIPart {
GLOBAL = 'global',
HEADER = 'header',
HEADER_MENU = 'header-menu',
CONTENT = 'content',
FOOTER = 'footer',
LEFT_SIDEBAR = 'left-sidebar',
FLOATING = 'floating',
UNIT = 'unit',
CUSTOM_HEADER = 'custom-header',
CUSTOM_LEFT = 'custom-left',
CUSTOM_RIGHT = 'custom-right',
CUSTOM_FOOTER = 'custom-footer',
TOOLBAR = 'toolbar',
}
export interface IUIPartsService {
componentRegistered$: Observable<ComponentPartKey>;
uiVisibleChange$: Observable<{ ui: ComponentPartKey; visible: boolean }>;
registerComponent(part: ComponentPartKey, componentFactory: () => ComponentType): IDisposable;
registerDisabledUIParts(parts: readonly ComponentPartKey[], disabled$: Observable<boolean>): IDisposable;
getComponents(part: ComponentPartKey): Set<ComponentRenderer>;
setUIVisible(part: ComponentPartKey, visible: boolean): void;
isUIVisible(part: ComponentPartKey): boolean;
}
export const IUIPartsService = createIdentifier<IUIPartsService>('ui.parts.service');
export class UIPartsService extends Disposable implements IUIPartsService {
private _componentsByPart: Map<ComponentPartKey, Set<ComponentType>> = new Map();
private _disabledUIParts = new Map<ComponentPartKey, Set<symbol>>();
private readonly _componentRegistered$ = new Subject<ComponentPartKey>();
readonly componentRegistered$ = this._componentRegistered$.asObservable();
private readonly _uiVisible = new Map<ComponentPartKey, boolean>();
private readonly _uiVisibleChange$ = new Subject<{ ui: ComponentPartKey; visible: boolean }>();
readonly uiVisibleChange$ = this._uiVisibleChange$.asObservable();
override dispose(): void {
super.dispose();
this._componentsByPart.clear();
this._disabledUIParts.clear();
this._uiVisible.clear();
this._componentRegistered$.complete();
this._uiVisibleChange$.complete();
}
setUIVisible(part: ComponentPartKey, visible: boolean): void {
const wasVisible = this.isUIVisible(part);
this._uiVisible.set(part, visible);
const isVisible = this.isUIVisible(part);
if (wasVisible !== isVisible) {
this._uiVisibleChange$.next({ ui: part, visible: isVisible });
}
}
isUIVisible(part: ComponentPartKey): boolean {
return (this._uiVisible.get(part) ?? true) && !this._disabledUIParts.get(part)?.size;
}
registerDisabledUIParts(parts: readonly ComponentPartKey[], disabled$: Observable<boolean>): IDisposable {
const uniqueParts = [...new Set(parts)];
const registration = Symbol('disabled-ui-parts');
let disabled = false;
const update = (nextDisabled: boolean) => {
if (disabled === nextDisabled) {
return;
}
disabled = nextDisabled;
uniqueParts.forEach((part) => {
const wasVisible = this.isUIVisible(part);
const registrations = this._disabledUIParts.get(part) ?? new Set<symbol>();
if (disabled) {
registrations.add(registration);
this._disabledUIParts.set(part, registrations);
} else {
registrations.delete(registration);
if (registrations.size === 0) {
this._disabledUIParts.delete(part);
}
}
const isVisible = this.isUIVisible(part);
if (wasVisible !== isVisible) {
this._uiVisibleChange$.next({ ui: part, visible: isVisible });
}
});
};
const subscription = disabled$.subscribe(update);
const disposable = toDisposable(() => {
subscription.unsubscribe();
update(false);
});
return this.disposeWithMe(disposable);
}
registerComponent<T>(part: ComponentPartKey, componentFactory: () => React.ComponentType<T>): IDisposable {
const componentType = componentFactory();
const components = (
this._componentsByPart.get(part)
|| this._componentsByPart.set(part, new Set()).get(part)!
).add(componentType);
this._componentRegistered$.next(part);
return toDisposable(() => {
components.delete(componentType);
if (components.size === 0) {
this._componentsByPart.delete(part);
}
this._componentRegistered$.next(part);
});
}
getComponents(part: ComponentPartKey): Set<ComponentType> {
return new Set([...(this._componentsByPart.get(part) || new Set())]);
}
}

View File

@@ -0,0 +1,59 @@
/**
* 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 { Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { IPlatformService, PlatformService } from '../platform.service';
function createService(): IPlatformService {
const injector = new Injector();
injector.add([IPlatformService, { useClass: PlatformService }]);
return injector.get(IPlatformService);
}
describe('PlatformService', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('should detect Mac platform by appVersion', () => {
vi.stubGlobal('navigator', { appVersion: 'Mac OS X' });
const service = createService();
expect(service.isMac).toBe(true);
expect(service.isWindows).toBe(false);
expect(service.isLinux).toBe(false);
});
it('should detect Windows platform by appVersion', () => {
vi.stubGlobal('navigator', { appVersion: 'Windows NT' });
const service = createService();
expect(service.isMac).toBe(false);
expect(service.isWindows).toBe(true);
expect(service.isLinux).toBe(false);
});
it('should detect Linux platform by appVersion', () => {
vi.stubGlobal('navigator', { appVersion: 'Linux x86_64' });
const service = createService();
expect(service.isMac).toBe(false);
expect(service.isWindows).toBe(false);
expect(service.isLinux).toBe(true);
});
});

View File

@@ -0,0 +1,42 @@
/**
* 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 { createIdentifier } from '@univerjs/core';
/**
* A service to provide context info of the host service.
*/
export interface IPlatformService {
readonly isMac: boolean;
readonly isWindows: boolean;
readonly isLinux: boolean;
}
export const IPlatformService = createIdentifier<IPlatformService>('ui.platform.service');
export class PlatformService implements IPlatformService {
get isMac(): boolean {
return /Mac/.test(navigator.appVersion);
}
get isWindows(): boolean {
return /Windows/.test(navigator.appVersion);
}
get isLinux(): boolean {
return /Linux/.test(navigator.appVersion);
}
}

View File

@@ -0,0 +1,102 @@
/**
* 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 { Injector } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { CanvasPopupService, ICanvasPopupService } from '../canvas-popup.service';
describe('CanvasPopupService', () => {
let service: ICanvasPopupService;
beforeEach(() => {
const injector = new Injector();
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
service = injector.get(ICanvasPopupService);
});
it('tracks canvas popups and active popup ownership', () => {
const popupId = service.addPopup({
unitId: 'book-1',
subUnitId: 'sheet-1',
componentKey: 'cell-comment',
anchorRect$: new BehaviorSubject({ left: 0, top: 0, right: 10, bottom: 10 }),
canvasElement: {} as HTMLCanvasElement,
});
service.popups[0][1].onActiveChange?.(true);
expect(service.activePopupId).toBe(popupId);
service.popups[0][1].onActiveChange?.(false);
expect(service.activePopupId).toBeNull();
});
it('keeps the active popup when another popup is deactivated', () => {
const firstPopupId = service.addPopup({
unitId: 'book-1',
subUnitId: 'sheet-1',
componentKey: 'first-comment',
anchorRect$: new BehaviorSubject({ left: 0, top: 0, right: 10, bottom: 10 }),
canvasElement: {} as HTMLCanvasElement,
});
service.addPopup({
unitId: 'book-1',
subUnitId: 'sheet-1',
componentKey: 'second-comment',
anchorRect$: new BehaviorSubject({ left: 20, top: 20, right: 30, bottom: 30 }),
canvasElement: {} as HTMLCanvasElement,
});
service.popups[0][1].onActiveChange?.(true);
service.popups[1][1].onActiveChange?.(false);
expect(service.activePopupId).toBe(firstPopupId);
});
it('clears all popups for canvas teardown', () => {
const sizes: number[] = [];
const sub = service.popups$.subscribe((popups) => sizes.push(popups.length));
service.addPopup({
unitId: 'book-1',
subUnitId: 'sheet-1',
componentKey: 'cell-comment',
anchorRect$: new BehaviorSubject({ left: 0, top: 0, right: 10, bottom: 10 }),
canvasElement: {} as HTMLCanvasElement,
});
service.removeAll();
expect(service.popups).toEqual([]);
expect(sizes).toEqual([0, 1, 0]);
sub.unsubscribe();
});
it('notifies renderers when popup entries are added or removed', () => {
const sizes: number[] = [];
service.popups$.subscribe((popups) => sizes.push(popups.length));
const popupId = service.addPopup({
unitId: 'book-1',
subUnitId: 'sheet-1',
componentKey: 'cell-comment',
anchorRect$: new BehaviorSubject({ left: 0, top: 0, right: 10, bottom: 10 }),
canvasElement: {} as HTMLCanvasElement,
});
service.removePopup(popupId);
expect(sizes).toEqual([0, 1, 0]);
});
});

View File

@@ -0,0 +1,118 @@
/**
* 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 { Injector, Nullable } from '@univerjs/core';
import type { IBoundRectNoAngle } from '@univerjs/engine-render';
import type { Observable } from 'rxjs';
import type { IRectPopupProps } from '../../views/components/popup/RectPopup';
import { createIdentifier, Disposable, generateRandomId } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface IPopup<T = Record<string, unknown>> extends Omit<IRectPopupProps, 'children' | 'hidden' | 'excludeRects' | 'anchorRect$'> {
anchorRect$: Observable<IBoundRectNoAngle>;
anchorRect?: IBoundRectNoAngle;
excludeRects$?: Observable<IBoundRectNoAngle[]>;
excludeRects?: Nullable<IBoundRectNoAngle[]>;
componentKey: string;
connectorInjector?: Injector;
unitId: string;
subUnitId: string;
offset?: [number, number];
canvasElement: HTMLCanvasElement;
/** Clamp popup placement to the visible canvas boundary instead of the viewport. */
constrainToCanvas?: boolean;
hideOnInvisible?: boolean;
hiddenType?: 'hide' | 'destroy';
hiddenRects$?: Observable<IBoundRectNoAngle[]>;
extraProps?: T;
customActive?: boolean;
onActiveChange?: (active: boolean) => void;
}
export type IPopupWithExtraProps<T> = IPopup<T> & { extraProps: T };
export interface ICanvasPopupService {
addPopup(item: IPopup): string;
removePopup(id: string): void;
removeAll(): void;
popups$: Observable<[string, IPopup][]>;
get popups(): [string, IPopup][];
/**
* which popup is under hovering now
*/
get activePopupId(): Nullable<string>;
}
export const ICanvasPopupService = createIdentifier<ICanvasPopupService>('ui.popup.service');
export class CanvasPopupService extends Disposable implements ICanvasPopupService {
private readonly _popupMap = new Map<string, IPopup>();
private readonly _popups$ = new BehaviorSubject<[string, IPopup][]>([]);
readonly popups$ = this._popups$.asObservable();
get popups() { return Array.from(this._popupMap.entries()); }
private _activePopupId: Nullable<string> = null;
get activePopupId() {
return this._activePopupId;
}
private _update() {
this._popups$.next(Array.from(this._popupMap.entries()));
}
override dispose(): void {
super.dispose();
this._popups$.next([]);
this._popups$.complete();
this._popupMap.clear();
}
addPopup(item: IPopup): string {
const id = generateRandomId();
this._popupMap.set(id, {
...item,
onActiveChange: (active: boolean) => {
if (active) {
this._activePopupId = id;
} else {
if (this._activePopupId === id) {
this._activePopupId = null;
}
}
},
});
this._update();
return id;
}
removePopup(id: string): void {
if (this._popupMap.delete(id)) {
this._update();
}
}
removeAll(): void {
this._popupMap.clear();
this._update();
}
}

View File

@@ -0,0 +1,81 @@
/**
* 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 { IUnitPresenceUIAdapter } from '../unit-presence-ui-adapter.service';
import { Injector, UniverInstanceType } from '@univerjs/core';
import { NEVER, of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import {
IUnitPresenceUIAdapterRegistry,
UnitPresenceUIAdapterRegistry,
} from '../unit-presence-ui-adapter.service';
function createRegistry() {
const injector = new Injector();
injector.add([IUnitPresenceUIAdapterRegistry, { useClass: UnitPresenceUIAdapterRegistry }]);
return {
injector,
registry: injector.get(IUnitPresenceUIAdapterRegistry),
};
}
function createAdapter(unitType: UniverInstanceType): IUnitPresenceUIAdapter {
return {
unitType,
presenceKind: `unit-presence-${unitType}`,
active$: of(false),
localPresence$: NEVER,
activate: () => ({ dispose: () => undefined }),
isActive: () => false,
getLocalPresence: () => null,
getRemotePresences$: () => NEVER,
setRemotePresence: () => undefined,
removeRemotePresence: () => undefined,
clearRemotePresences: () => undefined,
};
}
describe('UnitPresenceUIAdapterRegistry', () => {
it('registers and disposes adapters while publishing immutable snapshots', () => {
const { injector, registry } = createRegistry();
const snapshots: Array<readonly IUnitPresenceUIAdapter[]> = [];
const subscription = registry.adapters$.subscribe((adapters) => snapshots.push(adapters));
const adapter = createAdapter(UniverInstanceType.UNIVER_BOARD);
const disposable = registry.register(adapter);
expect(registry.get(UniverInstanceType.UNIVER_BOARD)).toBe(adapter);
expect(registry.getAll()).toEqual([adapter]);
expect(Object.isFrozen(registry.getAll())).toBe(true);
disposable.dispose();
expect(registry.get(UniverInstanceType.UNIVER_BOARD)).toBeNull();
expect(registry.getAll()).toEqual([]);
expect(snapshots).toHaveLength(3);
subscription.unsubscribe();
injector.dispose();
});
it('rejects duplicate host registrations', () => {
const { injector, registry } = createRegistry();
registry.register(createAdapter(UniverInstanceType.UNIVER_SLIDE));
expect(() => registry.register(createAdapter(UniverInstanceType.UNIVER_SLIDE)))
.toThrow('A unit presence adapter is already registered');
injector.dispose();
});
});

View File

@@ -0,0 +1,111 @@
/**
* 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, UniverInstanceType } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface IUnitPresencePoint {
x: number;
y: number;
}
export interface ILocalUnitPresenceState {
unitId: string;
subUnitId: string;
selectedIds: readonly string[];
focusedId: string | null;
pointer: IUnitPresencePoint | null;
shareInterval: number;
}
export interface IRemoteUnitPresenceState extends Omit<ILocalUnitPresenceState, 'shareInterval'> {
memberId: string;
color: string;
name: string;
}
export interface IUnitPresenceUIAdapter {
readonly unitType: UniverInstanceType;
readonly presenceKind: string;
readonly active$: Observable<boolean>;
readonly localPresence$: Observable<ILocalUnitPresenceState>;
activate(): IDisposable;
isActive(): boolean;
getLocalPresence(unitId: string): ILocalUnitPresenceState | null;
getRemotePresences$(unitId: string): Observable<ReadonlyMap<string, IRemoteUnitPresenceState>>;
setRemotePresence(state: IRemoteUnitPresenceState): void;
removeRemotePresence(unitId: string, memberId: string): void;
clearRemotePresences(unitId: string): void;
readonly statusUIPart?: string;
}
export interface IUnitPresenceUIAdapterRegistry {
readonly adapters$: Observable<readonly IUnitPresenceUIAdapter[]>;
register(adapter: IUnitPresenceUIAdapter): IDisposable;
get(unitType: UniverInstanceType): IUnitPresenceUIAdapter | null;
getAll(): readonly IUnitPresenceUIAdapter[];
}
export const IUnitPresenceUIAdapterRegistry = createIdentifier<IUnitPresenceUIAdapterRegistry>(
'ui.unit-presence-adapter-registry'
);
const EMPTY_ADAPTERS: readonly IUnitPresenceUIAdapter[] = Object.freeze([]);
export class UnitPresenceUIAdapterRegistry extends Disposable implements IUnitPresenceUIAdapterRegistry {
private readonly _adapters = new Map<UniverInstanceType, IUnitPresenceUIAdapter>();
private readonly _adapters$ = new BehaviorSubject<readonly IUnitPresenceUIAdapter[]>(EMPTY_ADAPTERS);
readonly adapters$ = this._adapters$.asObservable();
override dispose(): void {
this._adapters.clear();
this._adapters$.complete();
super.dispose();
}
register(adapter: IUnitPresenceUIAdapter): IDisposable {
if (this._adapters.has(adapter.unitType)) {
throw new Error(`A unit presence adapter is already registered for UniverInstanceType "${adapter.unitType}".`);
}
this._adapters.set(adapter.unitType, adapter);
this._emitAdapters();
return toDisposable(() => {
if (this._adapters.get(adapter.unitType) !== adapter) {
return;
}
this._adapters.delete(adapter.unitType);
this._emitAdapters();
});
}
get(unitType: UniverInstanceType): IUnitPresenceUIAdapter | null {
return this._adapters.get(unitType) ?? null;
}
getAll(): readonly IUnitPresenceUIAdapter[] {
return this._adapters$.getValue();
}
private _emitAdapters(): void {
this._adapters$.next(Object.freeze(Array.from(this._adapters.values())));
}
}

View File

@@ -0,0 +1,298 @@
/**
* 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 { IMenuSchema, MenuSchemaType } from '../../menu/menu-manager.service';
import {
ConfigService,
ContextService,
DesktopLogService,
IConfigService,
IContextService,
ILogService,
Injector,
IUniverInstanceService,
UniverInstanceService,
} from '@univerjs/core';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { MenuItemType } from '../../menu/menu';
import { IMenuManagerService, MenuManagerService } from '../../menu/menu-manager.service';
import { MenuManagerPosition, RibbonPosition } from '../../menu/types';
import { DesktopRibbonService, IRibbonService } from '../ribbon.service';
function createService(univerInstanceService?: { focused$: BehaviorSubject<string | null> }) {
const injector = new Injector();
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add(univerInstanceService
? [IUniverInstanceService, univerInstanceService as never]
: [IUniverInstanceService, { useClass: UniverInstanceService }]);
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
injector.add([IRibbonService, { useClass: DesktopRibbonService }]);
return {
service: injector.get(IRibbonService),
menuManagerService: injector.get(IMenuManagerService),
};
}
describe('DesktopRibbonService', () => {
it('shows contextual ribbon tabs only when requested and restores the last regular tab when hidden', () => {
const { service, menuManagerService } = createService();
const ribbons: string[][] = [];
const activated: string[] = [];
const ribbonSub = service.ribbon$.subscribe((ribbon) => ribbons.push(ribbon.map((item) => item.key)));
const activeSub = service.activatedTab$.subscribe((tab) => activated.push(tab));
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
'chart-tools': {
order: 100,
title: 'chart',
contextual: true,
group: { order: 0, command: { order: 0, menuItemFactory: () => ({ id: 'chart-command' }) } },
},
},
} as MenuSchemaType);
service.setActivatedTab(RibbonPosition.INSERT);
service.showContextualTab('chart-tools', { activate: true });
service.hideContextualTab('chart-tools');
expect(ribbons.at(-2)).toContain('chart-tools');
expect(ribbons.at(-1)).not.toContain('chart-tools');
expect(activated).toContain('chart-tools');
ribbonSub.unsubscribe();
activeSub.unsubscribe();
});
it('publishes collapsed group ids and fake toolbar visibility changes', () => {
const { service } = createService();
const collapsed: string[][] = [];
const fakeToolbarVisible: boolean[] = [];
const collapsedSub = service.collapsedIds$.subscribe((ids) => collapsed.push(ids));
const fakeToolbarSub = service.fakeToolbarVisible$.subscribe((visible) => fakeToolbarVisible.push(visible));
service.setCollapsedIds(['format']);
service.setFakeToolbarVisible(true);
expect(collapsed).toEqual([[], ['format']]);
expect(fakeToolbarVisible).toEqual([false, true]);
collapsedSub.unsubscribe();
fakeToolbarSub.unsubscribe();
});
it('filters hidden ribbon commands and restores activation when contextual tabs disappear', () => {
const { service, menuManagerService } = createService();
const hidden$ = new BehaviorSubject(false);
const ribbons: IMenuSchema[][] = [];
const activated: string[] = [];
const ribbonSub = service.ribbon$.subscribe((ribbon) => ribbons.push(ribbon));
const activeSub = service.activatedTab$.subscribe((tab) => activated.push(tab));
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
order: 0,
[`${RibbonPosition.START}.visible-group`]: {
order: 0,
visibleCommand: {
order: 0,
menuItemFactory: () => ({ id: 'visible-command', type: MenuItemType.BUTTON }),
},
hiddenCommand: {
order: 1,
menuItemFactory: () => ({ id: 'hidden-command', type: MenuItemType.BUTTON, hidden$ }),
},
},
},
'picture-tools': {
order: 99,
contextual: true,
[`${RibbonPosition.START}.picture-group`]: {
order: 0,
command: {
order: 0,
menuItemFactory: () => ({ id: 'picture-command', type: MenuItemType.BUTTON }),
},
},
},
},
} as MenuSchemaType);
service.setActivatedTab(RibbonPosition.START);
service.showContextualTab('picture-tools', { activate: true });
hidden$.next(true);
service.hideAllContextualTabs();
service.hideAllContextualTabs();
service.hideContextualTab('missing-tools');
const startRibbon = ribbons.at(-1)!
.find((group) => group.key === RibbonPosition.START);
const commandKeys = startRibbon?.children?.flatMap((group) => group.children ?? []).map((child) => child.key);
expect(commandKeys).toContain('visibleCommand');
expect(commandKeys).not.toContain('hiddenCommand');
expect(ribbons.at(-1)?.map((item) => item.key)).not.toContain('picture-tools');
expect(activated.at(-1)).toBe(RibbonPosition.START);
ribbonSub.unsubscribe();
activeSub.unsubscribe();
});
it('does not republish the ribbon when hidden states emit unchanged values', () => {
const { service, menuManagerService } = createService();
const hidden$ = new BehaviorSubject(false);
const ribbons: IMenuSchema[][] = [];
const ribbonSub = service.ribbon$.subscribe((ribbon) => ribbons.push(ribbon));
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
order: 0,
[`${RibbonPosition.START}.stable-group`]: {
order: 0,
stableCommand: {
order: 0,
menuItemFactory: () => ({ id: 'stable-command', type: MenuItemType.BUTTON, hidden$ }),
},
},
},
},
} as MenuSchemaType);
const publishCountAfterInitialHiddenState = ribbons.length;
hidden$.next(false);
expect(ribbons.length).toBe(publishCountAfterInitialHiddenState);
hidden$.next(true);
expect(ribbons.length).toBe(publishCountAfterInitialHiddenState + 1);
ribbonSub.unsubscribe();
});
it('filters a hidden command even when another hidden state has not emitted', () => {
const { service, menuManagerService } = createService();
const silentHidden$ = new Subject<boolean>();
const hidden$ = new BehaviorSubject(true);
let latestRibbon: IMenuSchema[] = [];
const ribbonSub = service.ribbon$.subscribe((ribbon) => latestRibbon = ribbon);
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
order: 0,
[`${RibbonPosition.START}.hidden-group`]: {
order: 0,
silentCommand: {
order: 0,
menuItemFactory: () => ({ id: 'silent-command', type: MenuItemType.BUTTON, hidden$: silentHidden$ }),
},
hiddenCommand: {
order: 1,
menuItemFactory: () => ({ id: 'hidden-command', type: MenuItemType.BUTTON, hidden$ }),
},
},
},
},
} as MenuSchemaType);
const commandKeys = latestRibbon
.find((group) => group.key === RibbonPosition.START)
?.children
?.flatMap((group) => group.children ?? [])
.map((child) => child.key);
expect(commandKeys).toContain('silentCommand');
expect(commandKeys).not.toContain('hiddenCommand');
ribbonSub.unsubscribe();
});
it('does not republish the ribbon when focus emits the same unit id', () => {
const focused$ = new BehaviorSubject<string | null>('unit-1');
const { service, menuManagerService } = createService({ focused$ });
const ribbons: IMenuSchema[][] = [];
const ribbonSub = service.ribbon$.subscribe((ribbon) => ribbons.push(ribbon));
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
order: 0,
[`${RibbonPosition.START}.focus-group`]: {
order: 0,
focusCommand: {
order: 0,
menuItemFactory: () => ({ id: 'focus-command', type: MenuItemType.BUTTON }),
},
},
},
},
} as MenuSchemaType);
const publishCountAfterMenuLoad = ribbons.length;
focused$.next('unit-1');
expect(ribbons.length).toBe(publishCountAfterMenuLoad);
focused$.next('unit-2');
expect(ribbons.length).toBe(publishCountAfterMenuLoad + 1);
ribbonSub.unsubscribe();
});
it('unsubscribes hidden states that are no longer in the ribbon', () => {
const { menuManagerService } = createService();
let subscriptions = 0;
let unsubscriptions = 0;
const hidden$ = new Observable<boolean>(() => {
subscriptions += 1;
return () => {
unsubscriptions += 1;
};
});
const groupKey = `${RibbonPosition.START}.temporary-group`;
menuManagerService.appendRootMenu({
[MenuManagerPosition.RIBBON]: {
[RibbonPosition.START]: {
[groupKey]: {
order: 0,
temporaryCommand: {
order: 0,
menuItemFactory: () => ({ id: 'temporary-command', type: MenuItemType.BUTTON, hidden$ }),
},
},
},
},
} as MenuSchemaType);
expect(subscriptions).toBe(1);
menuManagerService.mergeMenu({
[groupKey]: {
replace: true,
order: 0,
visibleCommand: {
order: 0,
menuItemFactory: () => ({ id: 'visible-command', type: MenuItemType.BUTTON }),
},
},
} as MenuSchemaType);
expect(unsubscriptions).toBe(1);
});
});

View File

@@ -0,0 +1,69 @@
/**
* 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 { Injector } from '@univerjs/core';
import type { Observable } from 'rxjs';
import type { IRibbonService } from './ribbon.service';
import { createIdentifier, Disposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface IRibbonOverride {
id: string;
ribbonService: IRibbonService;
injector?: Pick<Injector, 'get' | 'has' | 'invoke'>;
portalContainer?: HTMLElement | null;
placeholderTitle?: string;
hideToolbar?: boolean;
}
export interface IRibbonOverrideService {
readonly override$: Observable<IRibbonOverride | null>;
getOverride(): IRibbonOverride | null;
activate(override: IRibbonOverride): void;
clear(id?: string): void;
}
export const IRibbonOverrideService = createIdentifier<IRibbonOverrideService>('univer.ribbon-override-service');
export class RibbonOverrideService extends Disposable implements IRibbonOverrideService {
private readonly _override$ = new BehaviorSubject<IRibbonOverride | null>(null);
readonly override$ = this._override$.asObservable();
getOverride(): IRibbonOverride | null {
return this._override$.getValue();
}
activate(override: IRibbonOverride): void {
this._override$.next(override);
}
clear(id?: string): void {
const current = this.getOverride();
if (!current) {
return;
}
if (!id || current.id === id) {
this._override$.next(null);
}
}
override dispose(): void {
this._override$.next(null);
this._override$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,253 @@
/**
* 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 { Observable, Subscription } from 'rxjs';
import type { IMenuSchema } from '../menu/menu-manager.service';
import { createIdentifier, Disposable, IUniverInstanceService } from '@univerjs/core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { distinctUntilChanged, startWith } from 'rxjs/operators';
import { IMenuManagerService } from '../menu/menu-manager.service';
import { MenuManagerPosition, RibbonPosition } from '../menu/types';
export const IRibbonService = createIdentifier<IRibbonService>('univer.ribbon-service');
function isSameHiddenMap(previous: boolean[], current: boolean[]): boolean {
return previous.length === current.length && previous.every((hidden, index) => hidden === current[index]);
}
export interface IRibbonService {
ribbon$: Observable<IMenuSchema[]>;
activatedTab$: Observable<string>;
collapsedIds$: Observable<string[]>;
fakeToolbarVisible$: Observable<boolean>;
setActivatedTab(tab: string): void;
showContextualTab(tab: string, options?: { activate?: boolean }): void;
hideContextualTab(tab: string): void;
hideAllContextualTabs(): void;
setCollapsedIds(ids: string[]): void;
setFakeToolbarVisible(visible: boolean): void;
}
export class DesktopRibbonService extends Disposable implements IRibbonService {
private readonly _ribbon$ = new BehaviorSubject<IMenuSchema[]>([]);
readonly ribbon$ = this._ribbon$.asObservable();
private readonly _activatedTab$ = new BehaviorSubject<string>(RibbonPosition.START);
readonly activatedTab$ = this._activatedTab$.asObservable();
private readonly _collapsedIds$ = new BehaviorSubject<string[]>([]);
readonly collapsedIds$ = this._collapsedIds$.asObservable();
private readonly _fakeToolbarVisible$ = new BehaviorSubject<boolean>(false);
readonly fakeToolbarVisible$ = this._fakeToolbarVisible$.asObservable();
private readonly _visibleContextualTabs = new Set<string>();
private readonly _contextualTabs = new Set<string>();
private _lastNonContextualActivatedTab: string = RibbonPosition.START;
private _hiddenSubscription: Subscription | null = null;
constructor(
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initRibbonSubscription();
}
setActivatedTab(tab: string): void {
if (!this._isContextualTab(tab)) {
this._lastNonContextualActivatedTab = tab;
}
this._activatedTab$.next(tab);
}
showContextualTab(tab: string, options?: { activate?: boolean }): void {
this._visibleContextualTabs.add(tab);
this._updateRibbon();
if (options?.activate) {
this.setActivatedTab(tab);
}
}
hideContextualTab(tab: string): void {
if (!this._visibleContextualTabs.delete(tab)) {
return;
}
this._updateRibbon();
}
hideAllContextualTabs(): void {
if (this._visibleContextualTabs.size === 0) {
return;
}
this._visibleContextualTabs.clear();
this._updateRibbon();
}
setCollapsedIds(ids: string[]): void {
this._collapsedIds$.next(ids);
}
setFakeToolbarVisible(visible: boolean): void {
this._fakeToolbarVisible$.next(visible);
}
private _initRibbonSubscription(): void {
this.disposeWithMe(
combineLatest([
this._menuManagerService.menuChanged$.pipe(startWith(undefined)),
this._univerInstanceService.focused$.pipe(startWith(undefined), distinctUntilChanged()),
]).subscribe(() => {
this._updateRibbon();
})
);
}
private _updateRibbon() {
const ribbon = this._filterContextualTabs(
this._menuManagerService.getMenuByPositionKey(MenuManagerPosition.RIBBON)
);
// Collect all hidden$ Observables and their corresponding paths
const hiddenObservableMap: Observable<boolean>[] = [];
const hiddenKeyMap: string[] = [];
for (const group of ribbon) {
if (group.children) {
for (const item of group.children) {
if (item.children) {
for (const child of item.children) {
if (child.item?.hidden$) {
hiddenObservableMap.push(child.item.hidden$);
hiddenKeyMap.push(child.key);
}
}
}
}
}
}
this._hiddenSubscription?.unsubscribe();
this._hiddenSubscription = null;
if (hiddenObservableMap.length === 0) {
this._setRibbon(ribbon);
return;
}
this._hiddenSubscription = combineLatest(hiddenObservableMap.map((hidden$) => hidden$.pipe(startWith(false))))
.pipe(distinctUntilChanged(isSameHiddenMap))
.subscribe((hiddenMap) => {
const newRibbon: IMenuSchema[] = [];
const hiddenPathMap = hiddenMap.map((hidden, index) => {
if (hidden) {
return hiddenKeyMap[index];
}
return null;
}).filter((item) => !!item) as string[];
for (const group of ribbon) {
const newGroup: IMenuSchema = { ...group, children: [] };
if (group.children?.length) {
for (const item of group.children) {
const newItem: IMenuSchema = { ...item, children: [] };
let shouldAddItem = true;
if (item.children?.length) {
for (const child of item.children) {
if (!hiddenPathMap.includes(child.key)) {
newItem.children?.push(child);
}
}
if (newItem.children?.every((child) => child.children?.length === 0)) {
shouldAddItem = false;
}
}
if (shouldAddItem) {
newGroup.children?.push(newItem);
}
}
}
if (newGroup.children?.length && newGroup.children.every((item) => item.children?.length)) {
newRibbon.push(newGroup);
}
}
this._setRibbon(newRibbon);
});
}
private _filterContextualTabs(ribbon: IMenuSchema[]): IMenuSchema[] {
this._contextualTabs.clear();
ribbon.forEach((group) => {
if (group.contextual) {
this._contextualTabs.add(group.key);
}
});
return ribbon.filter((group) => !group.contextual || this._visibleContextualTabs.has(group.key));
}
private _setRibbon(ribbon: IMenuSchema[]): void {
const activatedTab = this._activatedTab$.getValue();
const activeGroup = ribbon.find((group) => group.key === activatedTab);
if (!activeGroup && this._contextualTabs.has(activatedTab)) {
const fallbackTab = ribbon.find((group) => group.key === this._lastNonContextualActivatedTab && !group.contextual)
?? ribbon.find((group) => group.key === RibbonPosition.START)
?? ribbon[0];
if (fallbackTab) {
this._activatedTab$.next(fallbackTab.key);
}
} else if (!activeGroup && ribbon.some((group) => group.key === RibbonPosition.START)) {
const fallbackTab = ribbon.find((group) => group.key === RibbonPosition.START)!;
this._activatedTab$.next(fallbackTab.key);
if (!fallbackTab.contextual) {
this._lastNonContextualActivatedTab = fallbackTab.key;
}
} else if (activeGroup && !activeGroup.contextual) {
this._lastNonContextualActivatedTab = activeGroup.key;
}
this._ribbon$.next(ribbon);
}
private _isContextualTab(tab: string): boolean {
return this._contextualTabs.has(tab) || this._ribbon$.getValue().some((group) => group.key === tab && group.contextual);
}
override dispose(): void {
this._hiddenSubscription?.unsubscribe();
this._hiddenSubscription = null;
this._ribbon$.next([]);
this._ribbon$.complete();
this._activatedTab$.complete();
this._collapsedIds$.complete();
this._fakeToolbarVisible$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,49 @@
/**
* 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 { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
export interface IUIRuntimeScope {
unitId: string;
has(identifier: unknown): boolean;
get<T = unknown>(identifier: unknown): T;
}
export interface IUIRuntimeScopeService {
register(scope: IUIRuntimeScope): IDisposable;
get(unitId: string | null | undefined): IUIRuntimeScope | undefined;
}
export const IUIRuntimeScopeService = createIdentifier<IUIRuntimeScopeService>('ui.runtime-scope.service');
export class UIRuntimeScopeService extends Disposable implements IUIRuntimeScopeService {
private readonly _scopes = new Map<string, IUIRuntimeScope>();
register(scope: IUIRuntimeScope): IDisposable {
this._scopes.set(scope.unitId, scope);
return toDisposable(() => {
if (this._scopes.get(scope.unitId) === scope) {
this._scopes.delete(scope.unitId);
}
});
}
get(unitId: string | null | undefined): IUIRuntimeScope | undefined {
return unitId ? this._scopes.get(unitId) : undefined;
}
}

View File

@@ -0,0 +1,64 @@
/**
* 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 { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { ShortcutPanelService } from '../shortcut-panel.service';
function createService(): ShortcutPanelService {
const injector = new Injector();
injector.add([ShortcutPanelService]);
return injector.get(ShortcutPanelService);
}
describe('ShortcutPanelService', () => {
it('should open and close panel state', () => {
const service = createService();
expect(service.isOpen).toBe(false);
service.open();
expect(service.isOpen).toBe(true);
service.close();
expect(service.isOpen).toBe(false);
});
it('should emit distinct state values and complete on dispose', () => {
const service = createService();
const values: boolean[] = [];
let completed = false;
const sub = service.open$.subscribe({
next: (value) => values.push(value),
complete: () => {
completed = true;
},
});
service.close();
service.open();
service.open();
service.close();
expect(values).toEqual([false, true, false]);
service.dispose();
expect(completed).toBe(true);
sub.unsubscribe();
});
});

View File

@@ -0,0 +1,633 @@
/**
* 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.
*/
/**
* @vitest-environment jsdom
*/
import {
CommandService,
CommandType,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
} from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EMBED_CHILD_UNIT_ID_ATTRIBUTE } from '../../../utils/embed-boundary';
import { ILayoutService } from '../../layout/layout.service';
import { IPlatformService, PlatformService } from '../../platform/platform.service';
import { IUIRuntimeScopeService, UIRuntimeScopeService } from '../../runtime-scope/ui-runtime-scope.service';
import { KeyCode, MetaKeys } from '../keycode';
import { NativeTextEditorShortcutBehavior, ShortcutService } from '../shortcut.service';
function createKeyboardEvent(
keyCode: number,
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; altKey?: boolean }
): KeyboardEvent {
const event = new Event('keydown', {
bubbles: true,
cancelable: true,
}) as KeyboardEvent;
for (const [key, value] of Object.entries({
altKey: options?.altKey ?? false,
ctrlKey: options?.ctrlKey ?? false,
keyCode,
metaKey: options?.metaKey ?? false,
shiftKey: options?.shiftKey ?? false,
})) {
Object.defineProperty(event, key, {
configurable: true,
get: () => value,
});
}
return event;
}
function createService(options?: {
isMac?: boolean;
isWindows?: boolean;
isLinux?: boolean;
layoutAllowed?: boolean;
}) {
const eventTarget = new EventTarget();
vi.stubGlobal('window', {
addEventListener: eventTarget.addEventListener.bind(eventTarget),
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
});
vi.stubGlobal('document', {
createElement: () => ({}),
});
const executeCommand = vi.fn();
const platformService = {
isMac: options?.isMac ?? false,
isWindows: options?.isWindows ?? true,
isLinux: options?.isLinux ?? false,
};
const layoutService = {
checkElementInCurrentContainers: vi.fn(() => options?.layoutAllowed),
};
const getRuntimeScope = vi.fn();
class TestCommandService {
executeCommand = executeCommand;
}
class TestPlatformService {
isMac = platformService.isMac;
isWindows = platformService.isWindows;
isLinux = platformService.isLinux;
}
class TestContextService {}
class TestLayoutService {
checkElementInCurrentContainers = layoutService.checkElementInCurrentContainers;
}
class TestRuntimeScopeService {
register = vi.fn();
get = getRuntimeScope;
}
const injector = new Injector();
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([IPlatformService, { useClass: TestPlatformService }]);
injector.add([IContextService, { useClass: TestContextService }]);
injector.add([IUIRuntimeScopeService, { useClass: TestRuntimeScopeService }]);
if (options?.layoutAllowed !== undefined) {
injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
}
injector.add([ShortcutService]);
const service = injector.get(ShortcutService);
return { service, executeCommand, getRuntimeScope, layoutService };
}
describe('ShortcutService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
{ keyCode: KeyCode.BACKSPACE, ctrlKey: false, shiftKey: false },
{ keyCode: KeyCode.DELETE, ctrlKey: false, shiftKey: false },
{ keyCode: KeyCode.A, ctrlKey: true, shiftKey: false },
{ keyCode: KeyCode.X, ctrlKey: true, shiftKey: false },
{ keyCode: KeyCode.C, ctrlKey: true, shiftKey: false },
{ keyCode: KeyCode.V, ctrlKey: true, shiftKey: false },
{ keyCode: KeyCode.Z, ctrlKey: true, shiftKey: false },
{ keyCode: KeyCode.Z, ctrlKey: true, shiftKey: true },
{ keyCode: KeyCode.Y, ctrlKey: true, shiftKey: false },
])('keeps toolbar input key $keyCode (ctrl=$ctrlKey, shift=$shiftKey) out of grid shortcuts', (keys) => {
const injector = new Injector([
[ICommandService, { useClass: CommandService }],
[IConfigService, { useClass: ConfigService }],
[IContextService, { useClass: ContextService }],
[ILogService, { useClass: DesktopLogService }],
[IPlatformService, { useClass: PlatformService }],
[IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }],
[ShortcutService],
]);
const control = document.createElement('div');
control.dataset.uCommand = 'sheet.command.set-range-fontsize';
const input = document.createElement('input');
control.appendChild(input);
document.body.appendChild(control);
try {
const service = injector.get(ShortcutService);
const binding = keys.keyCode | (keys.ctrlKey ? MetaKeys.CTRL_COMMAND : 0) | (keys.shiftKey ? MetaKeys.SHIFT : 0);
service.registerShortcut({ id: 'test.grid-edit', binding });
const event = new KeyboardEvent('input-key-test', { ...keys, cancelable: true });
input.dispatchEvent(event);
expect(service.dispatch(event)).toBeUndefined();
expect(event.defaultPrevented).toBe(false);
const canvas = document.createElement('canvas');
const canvasEvent = new KeyboardEvent('input-key-test', keys);
canvas.dispatchEvent(canvasEvent);
expect(service.dispatch(canvasEvent)?.id).toBe('test.grid-edit');
} finally {
control.remove();
injector.dispose();
}
});
it.each([KeyCode.ENTER, KeyCode.SPACE, KeyCode.TAB, KeyCode.ESC, KeyCode.ARROW_DOWN, KeyCode.ARROW_UP, KeyCode.ARROW_LEFT, KeyCode.ARROW_RIGHT, KeyCode.HOME, KeyCode.END])(
'yields menu navigation key %s while retaining canvas shortcuts',
(keyCode) => {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IPlatformService, { useClass: PlatformService }]);
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
injector.add([ShortcutService]);
const container = document.createElement('div');
document.body.appendChild(container);
try {
const service = injector.get(ShortcutService);
service.registerShortcut({ id: 'test.grid-navigation', binding: keyCode });
const trigger = document.createElement('div');
trigger.dataset.uCommand = 'sheet.command.set-horizontal-text-align';
trigger.setAttribute('role', 'button');
const menu = document.createElement('div');
menu.setAttribute('role', 'menu');
const item = document.createElement('button');
item.setAttribute('role', 'menuitem');
menu.appendChild(item);
const floatingMenu = document.createElement('div');
floatingMenu.setAttribute('data-embed-floating-menu', 'true');
const compactTrigger = document.createElement('button');
floatingMenu.appendChild(compactTrigger);
const fontControl = document.createElement('div');
fontControl.dataset.uCommand = 'sheet.command.set-range-font-family';
const fontInput = document.createElement('input');
fontControl.appendChild(fontInput);
container.append(trigger, menu, floatingMenu, fontControl);
for (const target of [trigger, item, compactTrigger, fontInput]) {
const event = new KeyboardEvent('navigation-test', { keyCode, cancelable: true });
target.dispatchEvent(event);
expect(service.dispatch(event)).toBeUndefined();
expect(event.defaultPrevented).toBe(false);
}
const canvas = document.createElement('canvas');
container.appendChild(canvas);
const event = new KeyboardEvent('navigation-test', { keyCode });
canvas.dispatchEvent(event);
expect(service.dispatch(event)?.id).toBe('test.grid-navigation');
} finally {
container.remove();
injector.dispose();
}
}
);
it.each([
['Enter', KeyCode.ENTER],
['Escape', KeyCode.ESC],
['ArrowLeft', KeyCode.ARROW_LEFT],
['ArrowRight', KeyCode.ARROW_RIGHT],
['Backspace', KeyCode.BACKSPACE],
['Tab', KeyCode.TAB],
])('yields composing %s to the IME and resumes the shortcut after composition', async (key, keyCode) => {
const injector = new Injector();
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IPlatformService, { useClass: PlatformService }]);
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
injector.add([ShortcutService]);
try {
const service = injector.get(ShortcutService);
const commandService = injector.get(ICommandService);
let executionCount = 0;
commandService.registerCommand({
id: 'test.ime-shortcut',
type: CommandType.OPERATION,
handler: () => {
executionCount += 1;
return true;
},
});
service.registerShortcut({
id: 'test.ime-shortcut',
binding: keyCode,
nativeTextEditorBehavior: NativeTextEditorShortcutBehavior.OVERRIDE_NATIVE,
});
const composingEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
isComposing: true,
key,
keyCode,
});
expect(service.dispatch(composingEvent)).toBeUndefined();
window.dispatchEvent(composingEvent);
expect(executionCount).toBe(0);
expect(composingEvent.defaultPrevented).toBe(false);
// Safari may deliver the IME confirmation key after compositionend with keyCode 229.
const confirmationEvent = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
isComposing: false,
key,
keyCode: 229,
});
window.dispatchEvent(confirmationEvent);
expect(executionCount).toBe(0);
expect(confirmationEvent.defaultPrevented).toBe(false);
const regularEvent = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key, keyCode });
window.dispatchEvent(regularEvent);
await expect.poll(() => executionCount).toBe(1);
expect(regularEvent.defaultPrevented).toBe(true);
} finally {
injector.dispose();
}
});
it('should register and unregister shortcuts', () => {
const { service } = createService();
const changed = vi.fn();
const sub = service.shortcutChanged$.subscribe(changed);
const disposable = service.registerShortcut({
id: 'cmd.save',
binding: KeyCode.S | MetaKeys.CTRL_COMMAND,
});
expect(service.getAllShortcuts()).toHaveLength(1);
expect(changed).toHaveBeenCalledTimes(1);
disposable.dispose();
expect(service.getAllShortcuts()).toHaveLength(0);
expect(changed).toHaveBeenCalledTimes(2);
sub.unsubscribe();
service.dispose();
});
it('should ignore invalid shortcuts without binding', () => {
const { service } = createService();
service.registerShortcut({
id: 'cmd.invalid',
});
expect(service.getAllShortcuts()).toHaveLength(0);
service.dispose();
});
it('should resolve candidate by priority and preconditions', () => {
const { service } = createService();
const low = {
id: 'cmd.low',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
priority: 1,
};
const highBlocked = {
id: 'cmd.blocked',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
priority: 10,
preconditions: () => false,
};
const eventBlocked = {
id: 'cmd.event-blocked',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
priority: 11,
eventPreconditions: () => false,
};
const highAllowed = {
id: 'cmd.high',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
priority: 9,
preconditions: () => true,
};
service.registerShortcut(low);
service.registerShortcut(highBlocked);
service.registerShortcut(eventBlocked);
service.registerShortcut(highAllowed);
const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
const candidate = service.dispatch(event);
expect(candidate?.id).toBe('cmd.high');
service.dispose();
});
it('should ignore dispatch when escaped, disabled, or target not in layout container', () => {
const escapedService = createService().service;
escapedService.registerShortcut({
id: 'cmd.esc',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
});
const escapedDisposable = escapedService.forceEscape();
const nestedEscapedDisposable = escapedService.forceEscape();
expect(escapedService.dispatch(createKeyboardEvent(KeyCode.A, { ctrlKey: true }))).toBeUndefined();
escapedDisposable.dispose();
expect(escapedService.dispatch(createKeyboardEvent(KeyCode.A, { ctrlKey: true }))).toBeUndefined();
nestedEscapedDisposable.dispose();
expect(escapedService.dispatch(createKeyboardEvent(KeyCode.A, { ctrlKey: true }))?.id).toBe('cmd.esc');
escapedService.dispose();
const disabledService = createService().service;
disabledService.registerShortcut({
id: 'cmd.disabled',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
});
const disableDisposable = disabledService.forceDisable();
expect(disabledService.dispatch(createKeyboardEvent(KeyCode.A, { ctrlKey: true }))).toBeUndefined();
disableDisposable.dispose();
disabledService.dispose();
const withLayout = createService({ layoutAllowed: false }).service;
withLayout.registerShortcut({
id: 'cmd.layout',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
});
const layoutEvent = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(layoutEvent, 'target', {
configurable: true,
value: document.createElement('div'),
});
expect(withLayout.dispatch(layoutEvent)).toBeUndefined();
withLayout.dispose();
});
it('should format shortcut display according to platform-specific bindings', () => {
const mac = createService({ isMac: true, isWindows: false }).service;
const macItem = {
id: 'cmd.mac',
binding: KeyCode.C,
mac: KeyCode.C | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT | MetaKeys.ALT | MetaKeys.MAC_CTRL,
};
expect(mac.getShortcutDisplay(macItem)).toBe('⌘+⇧+⌥+⌃+C');
mac.dispose();
const win = createService({ isWindows: true }).service;
const winItem = {
id: 'cmd.win',
binding: KeyCode.C | MetaKeys.CTRL_COMMAND | MetaKeys.ALT,
win: KeyCode.C | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
};
expect(win.getShortcutDisplay(winItem)).toBe('Ctrl+Shift+C');
win.dispose();
});
it('should return display string of command id and null when not found', () => {
const { service } = createService();
service.registerShortcut({
id: 'cmd.find',
binding: KeyCode.Z | MetaKeys.CTRL_COMMAND,
});
expect(service.getShortcutDisplayOfCommand('cmd.find')).toBe('Ctrl+Z');
expect(service.getShortcutDisplayOfCommand('cmd.none')).toBeNull();
service.dispose();
});
it('should execute command when native keydown matches registered shortcut', () => {
const { service, executeCommand } = createService();
service.registerShortcut({
id: 'cmd.native',
binding: KeyCode.V | MetaKeys.CTRL_COMMAND,
staticParameters: { from: 'test' },
});
const event = createKeyboardEvent(KeyCode.V, { ctrlKey: true });
window.dispatchEvent(event);
expect(executeCommand).toHaveBeenCalledWith('cmd.native', { from: 'test' });
expect(event.defaultPrevented).toBe(true);
service.dispose();
});
it('uses the embedded child runtime context and command service for shortcuts', () => {
const runtime = document.createElement('div');
const canvas = document.createElement('canvas');
runtime.setAttribute(EMBED_CHILD_UNIT_ID_ATTRIBUTE, 'child-board');
runtime.appendChild(canvas);
const scopedExecuteCommand = vi.fn(() => Promise.resolve(true));
const scopedContextService = {
getContextValue: vi.fn((key: string) => key === 'FOCUSING_BOARD'),
};
const scopedCommandService = {
executeCommand: scopedExecuteCommand,
};
const runtimeScope = {
has: vi.fn(() => true),
get: vi.fn((identifier: unknown) => identifier === IContextService ? scopedContextService : scopedCommandService),
};
const { service, executeCommand, getRuntimeScope } = createService();
getRuntimeScope.mockReturnValue(runtimeScope);
service.registerShortcut({
id: 'board.operation.grouping-shortcut',
binding: KeyCode.ENTER,
preconditions: (contextService) => contextService.getContextValue('FOCUSING_BOARD'),
});
const event = createKeyboardEvent(KeyCode.ENTER);
Object.defineProperty(event, 'target', {
configurable: true,
get: () => canvas,
});
window.dispatchEvent(event);
expect(getRuntimeScope).toHaveBeenCalledWith('child-board');
expect(scopedContextService.getContextValue).toHaveBeenCalledWith('FOCUSING_BOARD');
expect(scopedExecuteCommand).toHaveBeenCalledWith('board.operation.grouping-shortcut', undefined);
expect(executeCommand).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
service.dispose();
});
it('lets embed-owned native text editors handle select-all without dispatching Univer shortcuts', () => {
const embedRoot = document.createElement('div');
embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1');
const textEditor = document.createElement('div');
textEditor.contentEditable = 'true';
Object.defineProperty(textEditor, 'isContentEditable', {
configurable: true,
get: () => true,
});
embedRoot.appendChild(textEditor);
const { service, executeCommand } = createService();
service.registerShortcut({
id: 'cmd.select-all',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
nativeTextEditorBehavior: NativeTextEditorShortcutBehavior.ALLOW_NATIVE,
});
const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(event, 'target', {
configurable: true,
get: () => textEditor,
});
const candidate = service.dispatch(event);
expect(candidate).toBeUndefined();
expect(executeCommand).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
service.dispose();
});
it('lets an explicit shortcut override native select-all in an embedded text editor', () => {
const embedRoot = document.createElement('div');
embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1');
embedRoot.setAttribute(EMBED_CHILD_UNIT_ID_ATTRIBUTE, 'child-doc');
const textEditor = document.createElement('div');
textEditor.contentEditable = 'true';
Object.defineProperty(textEditor, 'isContentEditable', {
configurable: true,
get: () => true,
});
embedRoot.appendChild(textEditor);
const otherTextEditor = document.createElement('div');
otherTextEditor.contentEditable = 'true';
embedRoot.appendChild(otherTextEditor);
const scopedExecuteCommand = vi.fn(() => Promise.resolve(true));
const scopedContextService = {
getContextValue: vi.fn(),
};
const scopedCommandService = {
executeCommand: scopedExecuteCommand,
};
const runtimeScope = {
has: vi.fn(() => true),
get: vi.fn((identifier: unknown) => identifier === IContextService ? scopedContextService : scopedCommandService),
};
const { service, executeCommand, getRuntimeScope } = createService();
getRuntimeScope.mockReturnValue(runtimeScope);
service.registerShortcut({
id: 'doc.command.select-all',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
eventPreconditions: (event) => event.target === textEditor,
nativeTextEditorBehavior: NativeTextEditorShortcutBehavior.OVERRIDE_NATIVE,
});
const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(event, 'target', {
configurable: true,
get: () => textEditor,
});
window.dispatchEvent(event);
expect(getRuntimeScope).toHaveBeenCalledWith('child-doc');
expect(scopedExecuteCommand).toHaveBeenCalledWith('doc.command.select-all', undefined);
expect(executeCommand).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(true);
const otherEvent = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(otherEvent, 'target', {
configurable: true,
get: () => otherTextEditor,
});
expect(service.dispatch(otherEvent)).toBeUndefined();
service.dispose();
});
it('lets embed-owned Univer internal editors handle select-all without dispatching global shortcuts', () => {
const embedRoot = document.createElement('div');
embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1');
const editorContainer = document.createElement('div');
editorContainer.id = 'univer-doc-selection-container-__INTERNAL_EDITOR__DOCS_NORMAL';
const textEditor = document.createElement('div');
textEditor.id = '__editor___INTERNAL_EDITOR__DOCS_NORMAL';
textEditor.contentEditable = 'true';
Object.defineProperty(textEditor, 'isContentEditable', {
configurable: true,
get: () => true,
});
editorContainer.appendChild(textEditor);
embedRoot.appendChild(editorContainer);
const { service, executeCommand } = createService();
service.registerShortcut({
id: 'cmd.select-all',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
});
const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(event, 'target', {
configurable: true,
get: () => textEditor,
});
const candidate = service.dispatch(event);
expect(candidate).toBeUndefined();
expect(executeCommand).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
service.dispose();
});
it('still dispatches select-all for non-text embed targets', () => {
const embedRoot = document.createElement('div');
embedRoot.setAttribute('data-embed-interaction-boundary-owner', 'embed-1');
const canvas = document.createElement('canvas');
embedRoot.appendChild(canvas);
const { service, executeCommand } = createService();
service.registerShortcut({
id: 'cmd.select-all',
binding: KeyCode.A | MetaKeys.CTRL_COMMAND,
});
const event = createKeyboardEvent(KeyCode.A, { ctrlKey: true });
Object.defineProperty(event, 'target', {
configurable: true,
get: () => canvas,
});
const candidate = service.dispatch(event);
expect(candidate?.id).toBe('cmd.select-all');
expect(executeCommand).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
service.dispose();
});
});

View File

@@ -0,0 +1,187 @@
/**
* 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.
*/
/* eslint-disable ts/prefer-literal-enum-member */
// Useful keyboard event inspection tool https://www.toptal.com/developers/keycode
/** KeyCode that maps to browser standard keycode. */
export enum KeyCode {
UNKNOWN = 0,
BACKSPACE = 8,
TAB = 9,
ENTER = 13,
SHIFT = 16,
CTRL = 17,
ESC = 27,
SPACE = 32,
END = 35,
HOME = 36,
ARROW_LEFT = 37,
ARROW_UP = 38,
ARROW_RIGHT = 39,
ARROW_DOWN = 40,
INSERT = 45,
DELETE = 46,
Digit0 = 48,
Digit1,
Digit2,
Digit3,
Digit4,
Digit5,
Digit6,
Digit7,
Digit8,
Digit9,
A = 65,
B,
C,
D,
E,
F,
G,
H,
I,
J,
K,
L,
M,
N,
O,
P,
Q,
R,
S,
T,
U,
V,
W,
X,
Y,
Z,
F1 = 112,
F2 = 113,
F3 = 114,
F4 = 115,
F5 = 116,
F6 = 117,
F7 = 118,
F8 = 119,
F9 = 120,
F10 = 121,
F11 = 122,
F12 = 123,
NUM_LOCK = 144,
SCROLL_LOCK = 145,
EQUAL = 187,
COMMA = 188,
MINUS = 189,
PERIOD = 190,
BACK_SLASH = 220,
}
export const KeyCodeToChar: { [key: number]: string } = {
[KeyCode.BACKSPACE]: 'Backspace',
[KeyCode.TAB]: 'Tab',
[KeyCode.ENTER]: 'Enter',
[KeyCode.DELETE]: 'Del',
[KeyCode.ESC]: 'Esc',
[KeyCode.SPACE]: 'Space',
[KeyCode.HOME]: 'Home',
[KeyCode.END]: 'End',
[KeyCode.ARROW_LEFT]: '←',
[KeyCode.ARROW_RIGHT]: '→',
[KeyCode.ARROW_UP]: '↑',
[KeyCode.ARROW_DOWN]: '↓',
[KeyCode.Digit0]: '0',
[KeyCode.Digit1]: '1',
[KeyCode.Digit2]: '2',
[KeyCode.Digit3]: '3',
[KeyCode.Digit4]: '4',
[KeyCode.Digit5]: '5',
[KeyCode.Digit6]: '6',
[KeyCode.Digit7]: '7',
[KeyCode.Digit8]: '8',
[KeyCode.Digit9]: '9',
[KeyCode.A]: 'A',
[KeyCode.B]: 'B',
[KeyCode.C]: 'C',
[KeyCode.D]: 'D',
[KeyCode.E]: 'E',
[KeyCode.F]: 'F',
[KeyCode.G]: 'G',
[KeyCode.H]: 'H',
[KeyCode.I]: 'I',
[KeyCode.J]: 'J',
[KeyCode.K]: 'K',
[KeyCode.L]: 'L',
[KeyCode.M]: 'M',
[KeyCode.N]: 'N',
[KeyCode.O]: 'O',
[KeyCode.P]: 'P',
[KeyCode.Q]: 'Q',
[KeyCode.R]: 'R',
[KeyCode.S]: 'S',
[KeyCode.T]: 'T',
[KeyCode.U]: 'U',
[KeyCode.V]: 'V',
[KeyCode.W]: 'W',
[KeyCode.X]: 'X',
[KeyCode.Y]: 'Y',
[KeyCode.Z]: 'Z',
[KeyCode.F1]: 'F1',
[KeyCode.F2]: 'F2',
[KeyCode.F3]: 'F3',
[KeyCode.F4]: 'F4',
[KeyCode.F5]: 'F5',
[KeyCode.F6]: 'F6',
[KeyCode.F7]: 'F7',
[KeyCode.F8]: 'F8',
[KeyCode.F9]: 'F9',
[KeyCode.F10]: 'F10',
[KeyCode.F11]: 'F11',
[KeyCode.F12]: 'F12',
[KeyCode.MINUS]: '-',
[KeyCode.EQUAL]: '=',
[KeyCode.PERIOD]: '.',
[KeyCode.COMMA]: ',',
[KeyCode.BACK_SLASH]: '\\',
};
/** Define meta key numbers. */
export enum MetaKeys {
SHIFT = 1 << 10,
/** Option key on MacOS. Alt key on other systems. */
ALT = 1 << 11,
/** Command key on MacOS. Ctrl key on other systems. */
CTRL_COMMAND = 1 << 12,
/** Ctrl key for MacOS. Not valid on other systems. */
MAC_CTRL = 1 << 13,
}

View File

@@ -0,0 +1,41 @@
/**
* 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 { Disposable } from '@univerjs/core';
import { BehaviorSubject, distinctUntilChanged } from 'rxjs';
export class ShortcutPanelService extends Disposable {
private _open$ = new BehaviorSubject<boolean>(false);
open$ = this._open$.pipe(distinctUntilChanged());
get isOpen(): boolean {
return this._open$.getValue();
}
override dispose(): void {
super.dispose();
this._open$.next(false);
this._open$.complete();
}
open(): void {
this._open$.next(true);
}
close(): void {
this._open$.next(false);
}
}

View File

@@ -0,0 +1,440 @@
/**
* 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 { Observable } from 'rxjs';
import { createIdentifier, Disposable, ICommandService, IContextService, Optional, toDisposable } from '@univerjs/core';
import { Subject } from 'rxjs';
import { fromGlobalEvent } from '../../common/lifecycle';
import { getEmbedChildUnitId, isEmbedBoundaryTarget } from '../../utils/embed-boundary';
import { ILayoutService } from '../layout/layout.service';
import { IPlatformService } from '../platform/platform.service';
import { IUIRuntimeScopeService } from '../runtime-scope/ui-runtime-scope.service';
import { KeyCode, KeyCodeToChar, MetaKeys } from './keycode';
const MENU_NAVIGATION_KEYS = new Set([
KeyCode.ENTER,
KeyCode.SPACE,
KeyCode.TAB,
KeyCode.ESC,
KeyCode.ARROW_DOWN,
KeyCode.ARROW_UP,
KeyCode.ARROW_LEFT,
KeyCode.ARROW_RIGHT,
KeyCode.HOME,
KeyCode.END,
]);
/**
* Defines whether a Univer shortcut should yield to the browser's native behavior
* when the keyboard event originates from an editable text element.
*/
export enum NativeTextEditorShortcutBehavior {
ALLOW_NATIVE,
OVERRIDE_NATIVE,
}
/**
* A shortcut item that could be registered to the {@link IShortcutService}.
*/
export interface IShortcutItem<P extends object = object> {
/** Id of the shortcut item. It should reuse the corresponding {@link ICommand}'s id. */
id: string;
/** Description of the shortcut. */
description?: string;
/** If two shortcuts have the same binding, the one with higher priority would be check first. */
priority?: number;
/**
* A callback that will be triggered to examine if the shortcut should be invoked. The `{@link IContextService}`
* would be passed to the callback.
*/
preconditions?: (contextService: IContextService) => boolean;
/**
* A callback that examines the keyboard event before this shortcut is selected.
*/
eventPreconditions?: (event: KeyboardEvent) => boolean;
/**
* The binding of the shortcut. It should be a combination of {@link KeyCode} and {@link MetaKeys}.
*
* A command can be bound to several bindings, with different static parameters perhaps.
*
* @example { binding: KeyCode.ENTER | MetaKeys.ALT }
*/
binding?: KeyCode | number;
/**
* The binding of the shortcut for macOS. If the property is not specified, the default binding would be used.
*/
mac?: number;
/**
* The binding of the shortcut for Windows. If the property is not specified, the default binding would be used.
*/
win?: number;
/**
* The binding of the shortcut for Linux. If the property is not specified, the default binding would be used.
*/
linux?: number;
/**
* Controls whether this shortcut can override the browser's native behavior
* for editable text elements. Native behavior is preserved by default.
*/
nativeTextEditorBehavior?: NativeTextEditorShortcutBehavior;
/**
* The group of the menu item should belong to. The shortcut item would be rendered in the
* panel if this is set.
*
* @example { group: '10_global-shortcut' }
*/
group?: string;
/**
* The locale key for the group title displayed in the shortcut panel.
* If not specified, the group name (without the sequence prefix) will be used as the locale key.
* It supports dot-notation paths for namespaced locale keys.
*
* @example { groupTitle: 'ui.global-shortcut' }
*/
groupTitle?: string;
/**
* Static parameters of this shortcut. Would be send to {@link ICommandService.executeCommand} as the second
* parameter when the corresponding command is executed.
*
* You can define multi shortcuts with the same command id but different static parameters.
*/
staticParameters?: P;
}
/**
* The dependency injection identifier of the {@link IShortcutService}.
*/
export const IShortcutService = createIdentifier<IShortcutService>('ui.shortcut.service');
/**
* The interface of the shortcut service.
*/
export interface IShortcutService {
/**
* An observable that emits when the shortcuts are changed.
*/
shortcutChanged$: Observable<void>;
/**
* Make the shortcut service ignore all keyboard events.
* @returns {IDisposable} a disposable that could be used to cancel the force escaping.
*/
forceEscape(): IDisposable;
/**
* Used by API to force disable all shortcut keys, which will not be restored by selection
* @returns {IDisposable} a disposable that could be used to cancel the force disabling.
*/
forceDisable(): IDisposable;
/**
* Dispatch a keyboard event to the shortcut service and check if there is a shortcut that matches the event.
* @param e - the keyboard event to be dispatched.
*/
dispatch(e: KeyboardEvent): IShortcutItem<object> | undefined;
/**
* Register a shortcut item to the shortcut service.
* @param {IShortcutItem} shortcut - the shortcut item to be registered.
* @returns {IDisposable} a disposable that could be used to unregister the shortcut.
*/
registerShortcut(shortcut: IShortcutItem): IDisposable;
/**
* Get the display string of the shortcut item.
* @param shortcut - the shortcut item to get the display string.
* @returns {string | null} The display string of the shortcut. For example `Ctrl+Enter`.
*/
getShortcutDisplay(shortcut: IShortcutItem): string | null;
/**
* Get the display string of the shortcut of the command.
* @param id the id of the command to get the shortcut display.
* @returns {string | null} the display string of the shortcut. For example `Ctrl+Enter`.
*/
getShortcutDisplayOfCommand(id: string): string | null;
/**
* Get all the shortcuts registered in the shortcut service.
* @returns {IShortcutItem[]} all the shortcuts registered in the shortcut service.
*/
getAllShortcuts(): IShortcutItem[];
}
/**
* @ignore
*/
export class ShortcutService extends Disposable implements IShortcutService {
private readonly _shortCutMapping = new Map<number, Set<IShortcutItem>>();
private readonly _commandIDMapping = new Map<string, Set<IShortcutItem>>();
private readonly _shortcutChanged$ = new Subject<void>();
readonly shortcutChanged$ = this._shortcutChanged$.asObservable();
private _forceEscapeCount = 0;
private _forceDisabled = false;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IPlatformService private readonly _platformService: IPlatformService,
@IContextService private readonly _contextService: IContextService,
@IUIRuntimeScopeService private readonly _runtimeScopeService: IUIRuntimeScopeService,
@Optional(ILayoutService) private readonly _layoutService?: ILayoutService
) {
super();
// Register native keydown event handler to trigger shortcuts.
this.disposeWithMe(
fromGlobalEvent('keydown', (e: KeyboardEvent) => {
this._resolveKeyboardEvent(e);
}, {
capture: true,
})
);
}
getAllShortcuts(): IShortcutItem[] {
return Array.from(this._shortCutMapping.values())
.map((v) => Array.from(v.values()))
.flat();
}
registerShortcut(shortcut: IShortcutItem): IDisposable {
// first map shortcut to a number, so it could be converted and fetched quickly
const binding = this._getBindingFromItem(shortcut);
if (!binding) return toDisposable(() => {});
const bindingSet = this._shortCutMapping.get(binding);
if (bindingSet) {
bindingSet.add(shortcut);
} else {
this._shortCutMapping.set(binding, new Set([shortcut]));
}
const commandID = shortcut.id;
const commandIDSet = this._commandIDMapping.get(commandID);
if (commandIDSet) {
commandIDSet.add(shortcut);
} else {
this._commandIDMapping.set(commandID, new Set([shortcut]));
}
this._emitShortcutChanged();
return toDisposable(() => {
this._shortCutMapping.get(binding)?.delete(shortcut);
if (this._shortCutMapping.get(binding)?.size === 0) {
this._shortCutMapping.delete(binding);
}
this._commandIDMapping.get(commandID)?.delete(shortcut);
if (this._commandIDMapping.get(commandID)?.size === 0) {
this._commandIDMapping.delete(commandID);
}
this._emitShortcutChanged();
});
}
getShortcutDisplayOfCommand(id: string): string | null {
const set = this._commandIDMapping.get(id);
if (!set) {
return null;
}
const shortcut = set.values().next().value;
if (shortcut) {
return this.getShortcutDisplay(shortcut);
}
return null;
}
getShortcutDisplay(shortcut: IShortcutItem): string | null {
const binding = this._getBindingFromItem(shortcut);
if (!binding) return null;
const ctrlKey = binding & MetaKeys.CTRL_COMMAND;
const shiftKey = binding & MetaKeys.SHIFT;
const altKey = binding & MetaKeys.ALT;
const macCtrl = binding & MetaKeys.MAC_CTRL;
const body = KeyCodeToChar[binding & 0xFF] ?? '<->';
if (this._platformService.isMac) {
return `${ctrlKey ? '⌘+' : ''}${shiftKey ? '⇧+' : ''}${altKey ? '⌥+' : ''}${macCtrl ? '⌃+' : ''}${body}`;
}
return `${ctrlKey ? 'Ctrl+' : ''}${shiftKey ? 'Shift+' : ''}${altKey ? 'Alt+' : ''}${body}`;
}
private _emitShortcutChanged(): void {
this._shortcutChanged$.next();
}
forceEscape(): IDisposable {
this._forceEscapeCount += 1;
return toDisposable(() => {
this._forceEscapeCount = Math.max(0, this._forceEscapeCount - 1);
});
}
forceDisable(): IDisposable {
this._forceDisabled = true;
return toDisposable(() => {
this._forceDisabled = false;
});
}
private _resolveKeyboardEvent(e: KeyboardEvent): void {
const candidate = this.dispatch(e);
if (candidate) {
this._getRuntimeService<ICommandService>(e, ICommandService)?.executeCommand(candidate.id, candidate.staticParameters) ??
this._commandService.executeCommand(candidate.id, candidate.staticParameters);
e.preventDefault();
}
}
dispatch(e: KeyboardEvent): IShortcutItem<object> | undefined {
// The capture listener runs before the editor can handle IME candidate keys.
if (e.isComposing) {
return;
}
// Toolbar fields own their native editing and history before component key handlers run.
if (e.target instanceof HTMLInputElement && e.target.closest('[data-u-command]')) {
return;
}
// Scoped editor context can remain active while a portalled menu owns DOM focus.
if (
!e.ctrlKey && !e.metaKey && !e.altKey && MENU_NAVIGATION_KEYS.has(e.keyCode) &&
e.target instanceof HTMLElement &&
(e.target.matches('button[data-u-command], [data-u-command][role="button"], [data-embed-floating-menu="true"] button, [data-u-command] input') ||
e.target.closest('[role="menu"]'))
) {
return;
}
// Should get the container element of the Univer instance and see if
// the event target is a descendant of the container element.
// Also we should check through escape list and force catching list.
// if the target is not focused on the univer instance we should ignore the keyboard event.
// Maybe the user has forcibly disabled the shortcut keys, and the shortcut keys should not be processed at this time.
if (this._forceEscapeCount > 0 || this._forceDisabled) return;
if (
this._layoutService &&
!this._layoutService.checkElementInCurrentContainers(e.target as HTMLElement)
) {
return;
}
const binding = this._deriveBindingFromEvent(e);
if (binding === null) {
return undefined;
}
const shortcuts = this._shortCutMapping.get(binding);
if (shortcuts === undefined) {
return undefined;
}
const contextService = this._getRuntimeService<IContextService>(e, IContextService) ?? this._contextService;
const candidateShortcut = Array.from(shortcuts)
.sort((s1, s2) => (s2.priority ?? 0) - (s1.priority ?? 0))
.find((s) =>
(s.eventPreconditions?.(e) ?? true) &&
(s.preconditions?.(contextService) ?? true)
);
if (
this._shouldLetEmbedTextEditorHandleNativeShortcut(e, binding) &&
candidateShortcut?.nativeTextEditorBehavior !== NativeTextEditorShortcutBehavior.OVERRIDE_NATIVE
) {
return undefined;
}
return candidateShortcut;
}
private _getBindingFromItem(item: IShortcutItem): number | undefined {
if (this._platformService.isMac && item.mac) {
return item.mac;
}
if (this._platformService.isWindows && item.win) {
return item.win;
}
if (this._platformService.isLinux && item.linux) {
return item.linux;
}
return item.binding;
}
private _deriveBindingFromEvent(e: KeyboardEvent): number | null {
const { shiftKey, metaKey, altKey, keyCode } = e;
let binding = keyCode;
if (shiftKey) {
binding |= MetaKeys.SHIFT;
}
if (altKey) {
binding |= MetaKeys.ALT;
}
const ctrlKey = this._platformService.isMac ? metaKey : e.ctrlKey;
if (ctrlKey) {
binding |= MetaKeys.CTRL_COMMAND;
}
if (this._platformService.isMac && e.ctrlKey) {
binding |= MetaKeys.MAC_CTRL;
}
return binding;
}
private _shouldLetEmbedTextEditorHandleNativeShortcut(e: KeyboardEvent, binding: number): boolean {
if (binding !== (KeyCode.A | MetaKeys.CTRL_COMMAND)) {
return false;
}
const target = e.target;
if (!(target instanceof HTMLElement)) {
return false;
}
const isNativeTextEditor = target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement;
return isNativeTextEditor && isEmbedBoundaryTarget(target);
}
private _getRuntimeService<T>(event: KeyboardEvent, identifier: unknown): T | undefined {
const childUnitId = getEmbedChildUnitId(event.target);
const runtimeScope = this._runtimeScopeService.get(childUnitId);
return runtimeScope?.has(identifier) ? runtimeScope.get<T>(identifier) : undefined;
}
}

View File

@@ -0,0 +1,68 @@
/**
* 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 { Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopSidebarService } from '../desktop-sidebar.service';
import { ISidebarService } from '../sidebar.service';
function createService(): ISidebarService {
const injector = new Injector();
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
return injector.get(ISidebarService);
}
describe('DesktopSidebarService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('opens and closes the requested sidebar panel', () => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(1);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const service = createService();
const snapshots: unknown[] = [];
const opened: string[] = [];
const closed: string[] = [];
const sub = service.sidebarOptions$.subscribe((value) => snapshots.push(value));
service.open({ id: 'inspector', onOpen: () => opened.push('inspector'), onClose: (id) => closed.push(id ?? '') });
service.close('inspector');
expect(service.visible).toBe(false);
expect(opened).toEqual(['inspector']);
expect(closed).toEqual(['']);
expect(snapshots).toMatchObject([{ id: 'inspector', visible: true }, { id: 'inspector', visible: false }]);
sub.unsubscribe();
});
it('ignores close requests for a different sidebar id', () => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
callback(1);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const service = createService();
service.open({ id: 'inspector' });
service.close('other');
expect(service.visible).toBe(true);
});
});

View File

@@ -0,0 +1,103 @@
/**
* 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 { ISidebarMethodOptions } from '../../views/components/sidebar/Sidebar';
import type { ISidebarService } from './sidebar.service';
import { Disposable, toDisposable } from '@univerjs/core';
import { Subject } from 'rxjs';
export class DesktopSidebarService extends Disposable implements ISidebarService {
private _sidebarOptions: ISidebarMethodOptions = {};
readonly sidebarOptions$ = new Subject<ISidebarMethodOptions>();
readonly scrollEvent$ = new Subject<Event>();
private _container?: HTMLElement;
private _openAnimationFrameId: number | null = null;
private _width?: number;
get visible(): boolean {
return this._sidebarOptions.visible || false;
}
get options() {
return this._sidebarOptions;
}
get width(): number | undefined {
return this._width;
}
setWidth(value: number): void {
this._width = value;
}
override dispose(): void {
super.dispose();
this.close();
this.sidebarOptions$.complete();
this.scrollEvent$.complete();
}
private _clearPendingOpenFrame() {
if (this._openAnimationFrameId !== null) {
cancelAnimationFrame(this._openAnimationFrameId);
this._openAnimationFrameId = null;
}
}
open(params: ISidebarMethodOptions): IDisposable {
this._sidebarOptions = {
...params,
id: params.id,
visible: true,
};
this.sidebarOptions$.next(this._sidebarOptions);
this._clearPendingOpenFrame();
this._openAnimationFrameId = requestAnimationFrame(() => {
this._openAnimationFrameId = null;
this._sidebarOptions.onOpen && this._sidebarOptions.onOpen();
});
return toDisposable(() => {
this.close();
});
}
close(id?: string) {
if (id && this._sidebarOptions.id !== id) {
return;
}
this._clearPendingOpenFrame();
this._sidebarOptions = {
...this._sidebarOptions,
visible: false,
};
this.sidebarOptions$.next(this._sidebarOptions);
this._sidebarOptions.onClose?.();
}
getContainer() {
return this._container;
}
setContainer(element: HTMLElement) {
this._container = element;
}
}

View File

@@ -0,0 +1,55 @@
/**
* 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 { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useDependency } from '../../../../utils/di';
import { useSidebarClick } from '../use-sidebar-click';
vi.mock('../../../../utils/di', () => ({
useDependency: vi.fn(),
}));
describe('useSidebarClick', () => {
it('should attach and detach mousedown listener when container exists', () => {
const container = document.createElement('div');
const addEventListener = vi.spyOn(container, 'addEventListener');
const removeEventListener = vi.spyOn(container, 'removeEventListener');
vi.mocked(useDependency).mockReturnValue({
getContainer: () => container,
} as any);
const callback = vi.fn();
const { unmount } = renderHook(() => useSidebarClick(callback));
expect(addEventListener).toHaveBeenCalledWith('mousedown', callback);
unmount();
expect(removeEventListener).toHaveBeenCalledWith('mousedown', callback);
});
it('should skip listener binding when container is missing', () => {
vi.mocked(useDependency).mockReturnValue({
getContainer: () => undefined,
} as any);
const callback = vi.fn();
const { unmount } = renderHook(() => useSidebarClick(callback));
unmount();
expect(callback).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,36 @@
/**
* 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 { useEffect } from 'react';
import { useDependency } from '../../../utils/di';
import { ISidebarService } from '../sidebar.service';
/**
* Response panel to a click event.
*/
export const useSidebarClick = (cb: (event: MouseEvent) => void) => {
const sidebarService = useDependency(ISidebarService);
const container = sidebarService.getContainer();
useEffect(() => {
if (container) {
container.addEventListener('mousedown', cb);
return () => {
container.removeEventListener('mousedown', cb);
};
}
}, [cb, container]);
};

View File

@@ -0,0 +1,41 @@
/**
* 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 type { ISidebarMethodOptions } from '../../views/components/sidebar/Sidebar';
import { createIdentifier } from '@univerjs/core';
export interface ISidebarService {
readonly sidebarOptions$: Subject<ISidebarMethodOptions>;
readonly scrollEvent$: Subject<Event>;
open(params: ISidebarMethodOptions): IDisposable;
close(id?: string): void;
get visible(): boolean;
get options(): ISidebarMethodOptions;
getContainer(): HTMLElement | undefined;
setContainer(element?: HTMLElement): void;
get width(): number | undefined;
setWidth(value: number): void;
}
export const ILeftSidebarService = createIdentifier<ISidebarService>('ui.left-sidebar.service');
export const ISidebarService = createIdentifier<ISidebarService>('ui.sidebar.service');

View File

@@ -0,0 +1,104 @@
/**
* 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 { Theme } from '@univerjs/themes';
import { Injector } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ThemeSwitcherService } from '../theme-switcher.service';
function createService(): ThemeSwitcherService {
const injector = new Injector();
injector.add([ThemeSwitcherService]);
return injector.get(ThemeSwitcherService);
}
describe('ThemeSwitcherService', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('injects theme tokens as CSS variables and replaces previous theme style', () => {
interface IStyleElementStub {
id?: string;
textContent?: string;
setAttribute: (key: string, value: string) => void;
remove: () => void;
}
const appended: IStyleElementStub[] = [];
const existing = { remove: vi.fn() };
vi.stubGlobal('document', {
getElementById: () => existing,
createElement: () => ({
setAttribute(key: string, value: string) {
if (key === 'id') {
this.id = value;
}
},
remove: vi.fn(),
textContent: '',
} as IStyleElementStub),
head: {
appendChild: (element: IStyleElementStub) => appended.push(element),
},
});
const service = createService();
service.injectThemeToHead({ primary: { color: 'red' }, radius: 4 } as unknown as Theme);
expect(existing.remove).toHaveBeenCalledTimes(1);
expect(appended[0].id).toBe('univer-theme-css-variables');
expect(appended[0].textContent).toContain('--univer-primary-color: red;');
expect(appended[0].textContent).toContain('--univer-radius: 4;');
});
it('injects a theme when no previous style element exists', () => {
const appended: Array<{ textContent?: string }> = [];
vi.stubGlobal('document', {
getElementById: () => null,
createElement: () => ({
setAttribute: vi.fn(),
textContent: '',
}),
head: {
appendChild: (element: { textContent?: string }) => appended.push(element),
},
});
const service = createService();
service.injectThemeToHead({ color: { text: '#111' } } as unknown as Theme);
expect(appended[0].textContent).toContain('--univer-color-text: #111;');
});
it('disposes without removing the active theme style element', () => {
const existing = { remove: vi.fn() };
vi.stubGlobal('document', {
getElementById: () => existing,
createElement: () => ({
setAttribute: vi.fn(),
textContent: '',
}),
head: {
appendChild: vi.fn(),
},
});
const service = createService();
service.dispose();
expect(existing.remove).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,65 @@
/**
* 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 { Theme } from '@univerjs/themes';
import { Disposable } from '@univerjs/core';
export class ThemeSwitcherService extends Disposable {
private _styleSheetId = 'univer-theme-css-variables';
injectThemeToHead(theme: Theme) {
function generateCSSVariables(theme: any, prefix = '--univer') {
const variables: string[] = [];
function traverse(obj: any, path = '') {
for (const key in obj) {
const value = obj[key];
const currentPath = path ? `${path}-${key}` : key;
if (typeof value === 'object' && value !== null) {
traverse(value, currentPath);
} else {
variables.push(`${prefix}-${currentPath}: ${value};`);
}
}
}
traverse(theme);
return variables.join('\n');
}
const cssVariables = generateCSSVariables(theme);
const existingStyleElement = document.getElementById(this._styleSheetId);
if (existingStyleElement) {
existingStyleElement.remove();
}
const styleElement = document.createElement('style');
styleElement.setAttribute('id', this._styleSheetId);
styleElement.textContent = `:root {\n${cssVariables}\n}`;
document.head.appendChild(styleElement);
}
override dispose() {
super.dispose();
// const existingStyleElement = document.getElementById(this._styleSheetId);
// if (existingStyleElement) {
// document.head.removeChild(existingStyleElement);
// }
}
}

View File

@@ -0,0 +1,48 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { UndoRedoGroupService } from '../undo-redo-group.service';
describe('UndoRedoGroupService', () => {
it('reuses only active timed groups and always closes the core scope', () => {
const dispose = vi.fn();
const beginUndoRedoGroup = vi.fn((_unitId: string, _groupId: string) => ({ dispose }));
const service = new UndoRedoGroupService({ beginUndoRedoGroup });
vi.spyOn(Date, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(1_900).mockReturnValueOnce(2_901);
service.runTimed('unit-1', 'drawing:nudge', () => true);
service.runTimed('unit-1', 'drawing:nudge', () => true);
service.runTimed('unit-1', 'drawing:nudge', () => true);
expect(beginUndoRedoGroup.mock.calls[1][1]).toBe(beginUndoRedoGroup.mock.calls[0][1]);
expect(beginUndoRedoGroup.mock.calls[2][1]).not.toBe(beginUndoRedoGroup.mock.calls[0][1]);
expect(dispose).toHaveBeenCalledTimes(3);
});
it('creates a runner that can group separated command executions', () => {
const dispose = vi.fn();
const beginUndoRedoGroup = vi.fn((_unitId: string, _groupId: string) => ({ dispose }));
const service = new UndoRedoGroupService({ beginUndoRedoGroup });
const run = service.createRunner('unit-1');
run(() => true);
run(() => true);
expect(beginUndoRedoGroup.mock.calls[1][1]).toBe(beginUndoRedoGroup.mock.calls[0][1]);
expect(dispose).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,63 @@
/**
* 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 { generateRandomId, IUndoRedoService } from '@univerjs/core';
const HISTORY_GROUP_WINDOW = 1_000;
export class UndoRedoGroupService {
private readonly _sessions = new Map<string, { expiresAt: number; id: string }>();
constructor(
@IUndoRedoService private readonly _undoRedoService: Pick<IUndoRedoService, 'beginUndoRedoGroup'>
) {}
run<T>(unitId: string, action: () => T, mode: 'replace' | 'append' = 'append'): T {
return this._run(unitId, generateRandomId(8), mode, action);
}
createRunner(unitId: string, mode: 'replace' | 'append' = 'append') {
const groupId = generateRandomId(8);
return <T>(action: () => T): T => this._run(unitId, groupId, mode, action);
}
runTimed<T>(unitId: string, scope: string, action: () => T, mode: 'replace' | 'append' = 'replace'): T {
const now = Date.now();
const sessionKey = `${unitId}:${scope}`;
const current = this._sessions.get(sessionKey);
const groupId = current && current.expiresAt >= now ? current.id : generateRandomId(8);
this._sessions.set(sessionKey, { id: groupId, expiresAt: now + HISTORY_GROUP_WINDOW });
if (this._sessions.size > 100) {
this._sessions.forEach((session, key) => {
if (session.expiresAt < now) {
this._sessions.delete(key);
}
});
}
return this._run(unitId, groupId, mode, action);
}
private _run<T>(unitId: string, groupId: string, mode: 'replace' | 'append', action: () => T): T {
const group = this._undoRedoService.beginUndoRedoGroup(unitId, groupId, mode);
try {
return action();
} finally {
group.dispose();
}
}
}

View File

@@ -0,0 +1,80 @@
/**
* 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 { IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IWorkbenchService, WorkbenchService } from '../workbench.service';
describe('WorkbenchService', () => {
let univer: Univer;
let instanceService: IUniverInstanceService;
let service: IWorkbenchService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IWorkbenchService, { useClass: WorkbenchService }]);
instanceService = injector.get(IUniverInstanceService);
service = injector.get(IWorkbenchService);
});
afterEach(() => univer.dispose());
it('keeps the skeleton visible until every loading token is disposed', () => {
const states: boolean[] = [];
service.skeletonVisible$.subscribe((visible) => states.push(visible));
const first = service.acquireSkeleton();
const second = service.acquireSkeleton();
first.dispose();
expect(states).toEqual([false, true]);
second.dispose();
expect(states).toEqual([false, true, false]);
});
it('identifies root units without treating editors or embedded units as workbench roots', () => {
const states: Array<UniverInstanceType | null> = [];
service.rootUnitType$.subscribe((unitType) => states.push(unitType));
const rootDoc = instanceService.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'root-doc' });
instanceService.createUnit(UniverInstanceType.UNIVER_DOC, { id: '__INTERNAL_EDITOR__doc' });
const backgroundSheet = instanceService.createUnit(
UniverInstanceType.UNIVER_SHEET,
{ id: 'background-sheet' },
{ makeCurrent: false }
);
instanceService.focusUnit(backgroundSheet.getUnitId());
const embeddedDoc = instanceService.createUnit(
UniverInstanceType.UNIVER_DOC,
{ id: 'embedded-doc' },
{ makeCurrent: false, skipAutoRender: true, embeddedRender: true }
);
instanceService.focusUnit(embeddedDoc.getUnitId());
instanceService.disposeUnit(backgroundSheet.getUnitId());
expect(rootDoc.getUnitId()).toBe('root-doc');
expect(states).toEqual([
null,
UniverInstanceType.UNIVER_DOC,
UniverInstanceType.UNIVER_SHEET,
null,
]);
});
});

View File

@@ -0,0 +1,108 @@
/**
* 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 { ICreateUnitOptions, IDisposable, UniverInstanceType } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { createIdentifier, Disposable, Inject, isInternalEditorID, IUniverInstanceService, toDisposable } from '@univerjs/core';
import { BehaviorSubject, Subscription } from 'rxjs';
export interface IWorkbenchService {
readonly rootUnitType$: Observable<UniverInstanceType | null>;
readonly skeletonVisible$: Observable<boolean>;
acquireSkeleton(): IDisposable;
}
export const IWorkbenchService = createIdentifier<IWorkbenchService>('univer.ui.workbench.service');
export class WorkbenchService extends Disposable implements IWorkbenchService {
private readonly _tokens = new Set<symbol>();
private readonly _subscriptions = new Subscription();
private _rootUnitId: string | null = null;
private readonly _rootUnitType$ = new BehaviorSubject<UniverInstanceType | null>(null);
private readonly _skeletonVisible$ = new BehaviorSubject(false);
readonly rootUnitType$ = this._rootUnitType$.asObservable();
readonly skeletonVisible$ = this._skeletonVisible$.asObservable();
constructor(@Inject(IUniverInstanceService) univerInstanceService: IUniverInstanceService) {
super();
this._subscriptions.add(univerInstanceService.unitAdded$.subscribe(({ unit, options }) => {
const unitId = unit.getUnitId();
if (options?.makeCurrent === false || !isWorkbenchRootUnit(unitId, options)) {
return;
}
this._setRootUnit(unitId, unit.type);
}));
this._subscriptions.add(univerInstanceService.focused$.subscribe((unitId) => {
if (!unitId) {
return;
}
const unit = univerInstanceService.getUnit(unitId);
if (unit && isWorkbenchRootUnit(unitId, univerInstanceService.getUnitCreateOptions(unitId) ?? undefined)) {
this._setRootUnit(unitId, unit.type);
}
}));
this._subscriptions.add(univerInstanceService.unitDisposed$.subscribe((unit) => {
const unitId = unit.getUnitId();
if (this._rootUnitId === unitId) {
this._setRootUnit(null, null);
}
}));
}
private _setRootUnit(unitId: string | null, unitType: UniverInstanceType | null): void {
this._rootUnitId = unitId;
if (this._rootUnitType$.getValue() !== unitType) {
this._rootUnitType$.next(unitType);
}
}
acquireSkeleton(): IDisposable {
const token = Symbol('workbench-skeleton');
this._tokens.add(token);
if (this._tokens.size === 1) {
this._skeletonVisible$.next(true);
}
const disposable = toDisposable(() => {
if (!this._tokens.delete(token) || this._tokens.size > 0) {
return;
}
this._skeletonVisible$.next(false);
});
return this.disposeWithMe(disposable);
}
override dispose(): void {
this._subscriptions.unsubscribe();
this._tokens.clear();
this._rootUnitId = null;
this._rootUnitType$.complete();
this._skeletonVisible$.complete();
super.dispose();
}
}
function isWorkbenchRootUnit(unitId: string, options?: ICreateUnitOptions): boolean {
return !isInternalEditorID(unitId) && !options?.skipAutoRender && !options?.embeddedRender;
}