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
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:
126
packages/ui/src/services/__tests__/font.service.spec.ts
Normal file
126
packages/ui/src/services/__tests__/font.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
114
packages/ui/src/services/before-close/before-close.service.ts
Normal file
114
packages/ui/src/services/before-close/before-close.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
26
packages/ui/src/services/clipboard/clipboard-utils.ts
Normal file
26
packages/ui/src/services/clipboard/clipboard-utils.ts
Normal 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';
|
||||
}
|
||||
53
packages/ui/src/services/clipboard/clipboard.command.ts
Normal file
53
packages/ui/src/services/clipboard/clipboard.command.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
87
packages/ui/src/services/confirm/desktop-confirm.service.ts
Normal file
87
packages/ui/src/services/confirm/desktop-confirm.service.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
89
packages/ui/src/services/confirm/mobile-confirm.service.ts
Normal file
89
packages/ui/src/services/confirm/mobile-confirm.service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
86
packages/ui/src/services/contextmenu/contextmenu.service.ts
Normal file
86
packages/ui/src/services/contextmenu/contextmenu.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
94
packages/ui/src/services/dialog/desktop-dialog.service.ts
Normal file
94
packages/ui/src/services/dialog/desktop-dialog.service.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
32
packages/ui/src/services/dialog/dialog.service.ts
Normal file
32
packages/ui/src/services/dialog/dialog.service.ts
Normal 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[]>;
|
||||
}
|
||||
42
packages/ui/src/services/dialog/mobile-dialog.service.ts
Normal file
42
packages/ui/src/services/dialog/mobile-dialog.service.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
206
packages/ui/src/services/dom/canvas-dom-layer.service.ts
Normal file
206
packages/ui/src/services/dom/canvas-dom-layer.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
408
packages/ui/src/services/font.service.ts
Normal file
408
packages/ui/src/services/font.service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
65
packages/ui/src/services/gallery/desktop-gallery.service.ts
Normal file
65
packages/ui/src/services/gallery/desktop-gallery.service.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/services/gallery/gallery.service.ts
Normal file
27
packages/ui/src/services/gallery/gallery.service.ts
Normal 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;
|
||||
}
|
||||
263
packages/ui/src/services/layout/__tests__/layout.service.spec.ts
Normal file
263
packages/ui/src/services/layout/__tests__/layout.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
223
packages/ui/src/services/layout/layout.service.ts
Normal file
223
packages/ui/src/services/layout/layout.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
31
packages/ui/src/services/local-file/local-file.service.ts
Normal file
31
packages/ui/src/services/local-file/local-file.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
497
packages/ui/src/services/menu/menu-manager.service.ts
Normal file
497
packages/ui/src/services/menu/menu-manager.service.ts
Normal 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;
|
||||
}
|
||||
149
packages/ui/src/services/menu/menu.ts
Normal file
149
packages/ui/src/services/menu/menu.ts
Normal 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>;
|
||||
100
packages/ui/src/services/menu/types.ts
Normal file
100
packages/ui/src/services/menu/types.ts
Normal 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',
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
66
packages/ui/src/services/message/desktop-message.service.ts
Normal file
66
packages/ui/src/services/message/desktop-message.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/services/message/message.service.ts
Normal file
27
packages/ui/src/services/message/message.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
149
packages/ui/src/services/parts/parts.service.ts
Normal file
149
packages/ui/src/services/parts/parts.service.ts
Normal 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())]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
42
packages/ui/src/services/platform/platform.service.ts
Normal file
42
packages/ui/src/services/platform/platform.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
118
packages/ui/src/services/popup/canvas-popup.service.ts
Normal file
118
packages/ui/src/services/popup/canvas-popup.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
298
packages/ui/src/services/ribbon/__tests__/ribbon.service.spec.ts
Normal file
298
packages/ui/src/services/ribbon/__tests__/ribbon.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
packages/ui/src/services/ribbon/ribbon-override.service.ts
Normal file
69
packages/ui/src/services/ribbon/ribbon-override.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
253
packages/ui/src/services/ribbon/ribbon.service.ts
Normal file
253
packages/ui/src/services/ribbon/ribbon.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
187
packages/ui/src/services/shortcut/keycode.ts
Normal file
187
packages/ui/src/services/shortcut/keycode.ts
Normal 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,
|
||||
}
|
||||
41
packages/ui/src/services/shortcut/shortcut-panel.service.ts
Normal file
41
packages/ui/src/services/shortcut/shortcut-panel.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
440
packages/ui/src/services/shortcut/shortcut.service.ts
Normal file
440
packages/ui/src/services/shortcut/shortcut.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
103
packages/ui/src/services/sidebar/desktop-sidebar.service.ts
Normal file
103
packages/ui/src/services/sidebar/desktop-sidebar.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
36
packages/ui/src/services/sidebar/hooks/use-sidebar-click.ts
Normal file
36
packages/ui/src/services/sidebar/hooks/use-sidebar-click.ts
Normal 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]);
|
||||
};
|
||||
41
packages/ui/src/services/sidebar/sidebar.service.ts
Normal file
41
packages/ui/src/services/sidebar/sidebar.service.ts
Normal 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');
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
108
packages/ui/src/services/workbench/workbench.service.ts
Normal file
108
packages/ui/src/services/workbench/workbench.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user