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

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

View File

@@ -0,0 +1,212 @@
/**
* 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 { DeleteDirection, Direction } from '@univerjs/core';
import { DeleteTextCommand, InsertTextCommand, RichTextEditingMutation } from '@univerjs/docs';
import { DeleteLeftCommand, IMEInputCommand, MoveCursorOperation } from '@univerjs/docs-ui';
import { KeyCode } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { CloseQuickInsertPopupOperation, ShowQuickInsertPopupOperation } from '../../commands/operations/quick-insert-popup.operation';
import { numberedListMenu, textMenu } from '../../menu/menu';
import { DocQuickInsertTriggerController } from '../doc-quick-insert-trigger.controller';
function createCommandService() {
const listeners = new Set<(commandInfo: { id: string; params?: unknown }) => void>();
return {
executeCommand: vi.fn(() => true),
onCommandExecuted: vi.fn((callback: (commandInfo: { id: string; params?: unknown }) => void) => {
listeners.add(callback);
return {
dispose: () => listeners.delete(callback),
};
}),
emit(commandInfo: { id: string; params?: unknown }) {
listeners.forEach((listener) => listener(commandInfo));
},
};
}
function createPopupService() {
let menuSelectedHandler: ((menu: { id: string }) => void) | null = null;
let editPopup: { anchor: number } | null = null;
let isComposing = false;
let inputOffset = { start: 0, end: 0 };
return {
get editPopup() {
return editPopup;
},
setEditPopup(value: { anchor: number } | null) {
editPopup = value;
},
get isComposing() {
return isComposing;
},
get inputOffset() {
return inputOffset;
},
setInputOffset: vi.fn((value: { start: number; end: number }) => {
inputOffset = value;
}),
resolvePopup: vi.fn(),
setIsComposing: vi.fn((value: boolean) => {
isComposing = value;
}),
onMenuSelected: vi.fn((handler: (menu: { id: string }) => void) => {
menuSelectedHandler = handler;
return () => {
menuSelectedHandler = null;
};
}),
triggerMenuSelected(menu: { id: string }) {
menuSelectedHandler?.(menu);
},
};
}
describe('DocQuickInsertTriggerController', () => {
it('reacts to insert, edit, cursor, delete and IME events like a real quick-insert session', () => {
vi.useFakeTimers();
const commandService = createCommandService();
const popupService = createPopupService();
const shortcutService = {
registerShortcut: vi.fn(() => ({ dispose: vi.fn() })),
};
const selectionManagerService = {
getActiveTextRange: vi.fn(() => ({ startOffset: 5, endOffset: 5 })),
};
const popup = {
keyword: '/',
menus$: { subscribe: vi.fn() },
preconditions: vi.fn(() => true),
};
popupService.resolvePopup.mockReturnValue(popup);
const controller = new DocQuickInsertTriggerController(
commandService as never,
selectionManagerService as never,
popupService as never,
shortcutService as never,
{
getCurrentUnitOfType: vi.fn(() => ({ getDisabled: () => false })),
} as never
);
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: CloseQuickInsertPopupOperation.id,
binding: KeyCode.ESC,
priority: 1000,
}));
commandService.emit({
id: InsertTextCommand.id,
params: {
body: { dataStream: '/' },
range: { endOffset: 5 },
unitId: 'doc-1',
},
});
expect(popupService.setInputOffset).toHaveBeenCalledWith({ start: 4, end: 5 });
expect(commandService.executeCommand).not.toHaveBeenCalledWith(ShowQuickInsertPopupOperation.id, expect.anything());
vi.advanceTimersByTime(100);
expect(commandService.executeCommand).toHaveBeenCalledWith(ShowQuickInsertPopupOperation.id, {
index: 4,
unitId: 'doc-1',
popup,
});
popupService.setEditPopup({ anchor: 4 });
commandService.emit({
id: InsertTextCommand.id,
params: {
body: { dataStream: 'a' },
range: { endOffset: 7 },
unitId: 'doc-1',
},
});
expect(popupService.setInputOffset).toHaveBeenLastCalledWith({ start: 4, end: 8 });
commandService.emit({
id: IMEInputCommand.id,
params: { isCompositionStart: true, isCompositionEnd: false },
});
commandService.emit({
id: IMEInputCommand.id,
params: { isCompositionStart: false, isCompositionEnd: true },
});
expect(popupService.setIsComposing).toHaveBeenNthCalledWith(1, true);
expect(popupService.setIsComposing).toHaveBeenNthCalledWith(2, false);
commandService.emit({
id: RichTextEditingMutation.id,
params: {
isCompositionEnd: true,
textRanges: [{ endOffset: 9 }],
},
});
expect(popupService.setInputOffset).toHaveBeenLastCalledWith({ start: 4, end: 9 });
commandService.emit({
id: DeleteTextCommand.id,
params: {
direction: DeleteDirection.LEFT,
len: 2,
range: { endOffset: 10 },
},
});
expect(popupService.setInputOffset).toHaveBeenLastCalledWith({ start: 4, end: 8 });
commandService.emit({
id: MoveCursorOperation.id,
params: { direction: Direction.LEFT },
});
expect(commandService.executeCommand).toHaveBeenCalledWith(CloseQuickInsertPopupOperation.id);
selectionManagerService.getActiveTextRange.mockReturnValue({ startOffset: 4, endOffset: 4 });
commandService.emit({ id: DeleteLeftCommand.id });
expect(commandService.executeCommand).toHaveBeenCalledWith(CloseQuickInsertPopupOperation.id);
controller.dispose();
vi.useRealTimers();
});
it('executes built-in menu commands but skips plain text selection', () => {
const commandService = createCommandService();
const popupService = createPopupService();
const controller = new DocQuickInsertTriggerController(
commandService as never,
{ getActiveTextRange: vi.fn() } as never,
popupService as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{
getCurrentUnitOfType: vi.fn(() => ({ getDisabled: () => false })),
} as never
);
popupService.triggerMenuSelected(textMenu);
popupService.triggerMenuSelected(numberedListMenu);
expect(commandService.executeCommand).not.toHaveBeenCalledWith(textMenu.id);
expect(commandService.executeCommand).toHaveBeenCalledWith(numberedListMenu.id);
controller.dispose();
});
});

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { DividerIcon, TextIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { KeywordInputPlaceholder } from '../views/KeywordInputPlaceholder';
import { QuickInsertButton } from '../views/QuickInsertButton';
import { QuickInsertPlaceholder } from '../views/QuickInsertPlaceholder';
import { QuickInsertPopup } from '../views/QuickInsertPopup';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) protected readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
DividerIcon,
TextIcon,
}));
}
protected _registerComponents(): void {
this._registerPopup();
([
[KeywordInputPlaceholder.componentKey, KeywordInputPlaceholder],
[QuickInsertPlaceholder.componentKey, QuickInsertPlaceholder],
[QuickInsertButton.componentKey, QuickInsertButton],
] as const).forEach(([key, comp]) => {
if (key) {
this.disposeWithMe(this._componentManager.register(key, comp));
}
});
}
protected _registerPopup(): void {
this.disposeWithMe(this._componentManager.register(QuickInsertPopup.componentKey, QuickInsertPopup));
}
}

View File

@@ -0,0 +1,178 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type {
IDeleteTextCommandParams,
IInsertTextCommandParams,
IRichTextEditingMutationParams,
} from '@univerjs/docs';
import type { IIMEInputCommandParams, IMoveCursorOperationParams } from '@univerjs/docs-ui';
import {
DeleteDirection,
Direction,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import {
DeleteTextCommand,
DocSelectionManagerService,
InsertTextCommand,
RichTextEditingMutation,
} from '@univerjs/docs';
import { DeleteLeftCommand, IMEInputCommand, MoveCursorOperation } from '@univerjs/docs-ui';
import { IShortcutService, KeyCode } from '@univerjs/ui';
import {
CloseQuickInsertPopupOperation,
ShowQuickInsertPopupOperation,
} from '../commands/operations/quick-insert-popup.operation';
import { builtInMenuCommandIds, textMenu } from '../menu/menu';
import { DocQuickInsertPopupService } from '../services/doc-quick-insert-popup.service';
export class DocQuickInsertTriggerController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@Inject(DocQuickInsertPopupService) private readonly _docQuickInsertPopupService: DocQuickInsertPopupService,
@Inject(IShortcutService) private readonly _shortcutService: IShortcutService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this.disposeWithMe(this._shortcutService.registerShortcut({
id: CloseQuickInsertPopupOperation.id,
binding: KeyCode.ESC,
preconditions: () => Boolean(this._docQuickInsertPopupService.editPopup),
priority: 1000,
}));
this._initTrigger();
this._initMenuHandler();
}
// eslint-disable-next-line max-lines-per-function
private _initTrigger() {
this.disposeWithMe(
// eslint-disable-next-line complexity, max-lines-per-function
this._commandService.onCommandExecuted((commandInfo) => {
const { _docQuickInsertPopupService, _textSelectionManagerService, _commandService } = this;
const documentDataModel = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (documentDataModel?.getDisabled()) {
return;
}
if (commandInfo.id === InsertTextCommand.id) {
const params = commandInfo.params as IInsertTextCommandParams;
if (_docQuickInsertPopupService.editPopup) {
_docQuickInsertPopupService.setInputOffset({
start: _docQuickInsertPopupService.inputOffset.start,
end: params.range.endOffset + 1,
});
return;
}
const activeRange = _textSelectionManagerService.getActiveTextRange();
if (!activeRange) {
return;
}
const popup = _docQuickInsertPopupService.resolvePopup(params.body.dataStream);
if (!popup) {
return;
}
const available = popup.preconditions ? popup.preconditions(params) : true;
if (!available) {
return;
}
_docQuickInsertPopupService.setInputOffset({ start: activeRange.startOffset - 1, end: activeRange.startOffset });
setTimeout(() => {
_commandService.executeCommand(ShowQuickInsertPopupOperation.id, {
index: activeRange.startOffset - 1,
unitId: params.unitId,
popup,
});
}, 100);
}
if (commandInfo.id === IMEInputCommand.id) {
const params = commandInfo.params as IIMEInputCommandParams;
if (!_docQuickInsertPopupService.isComposing && params.isCompositionStart) {
_docQuickInsertPopupService.setIsComposing(true);
}
if (_docQuickInsertPopupService.isComposing && params.isCompositionEnd) {
_docQuickInsertPopupService.setIsComposing(false);
}
}
if (commandInfo.id === RichTextEditingMutation.id) {
const params = commandInfo.params as IRichTextEditingMutationParams;
if (params.isCompositionEnd) {
const endOffset = params.textRanges?.[0]?.endOffset;
if (endOffset) {
_docQuickInsertPopupService.setInputOffset({ start: _docQuickInsertPopupService.inputOffset.start, end: endOffset });
}
}
}
if (commandInfo.id === DeleteTextCommand.id) {
const params = commandInfo.params as IDeleteTextCommandParams;
if (_docQuickInsertPopupService.editPopup && params.direction === DeleteDirection.LEFT) {
const len = params.len ?? 0;
_docQuickInsertPopupService.setInputOffset({ start: _docQuickInsertPopupService.inputOffset.start, end: params.range.endOffset - len });
}
}
if (commandInfo.id === MoveCursorOperation.id) {
const params = commandInfo.params as IMoveCursorOperationParams;
if (params.direction === Direction.LEFT || params.direction === Direction.RIGHT) {
_docQuickInsertPopupService.editPopup && _commandService.executeCommand(CloseQuickInsertPopupOperation.id);
}
}
if (commandInfo.id === DeleteLeftCommand.id) {
const activeRange = _textSelectionManagerService.getActiveTextRange();
if (!_docQuickInsertPopupService.editPopup || !activeRange) {
return;
}
if (activeRange.endOffset <= _docQuickInsertPopupService.editPopup.anchor) {
_commandService.executeCommand(CloseQuickInsertPopupOperation.id);
}
}
})
);
}
private _initMenuHandler() {
this.disposeWithMe(this._docQuickInsertPopupService.onMenuSelected((menu) => {
if (menu.id === textMenu.id) {
return;
}
if (builtInMenuCommandIds.has(menu.id)) {
this._commandService.executeCommand(menu.id);
}
}));
}
}

View File

@@ -0,0 +1,60 @@
/**
* 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, Inject } from '@univerjs/core';
import { DividerIcon, TextIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { KeywordInputPlaceholder } from '../../views/KeywordInputPlaceholder';
import { MobileQuickInsertPopup } from '../../views/mobile/MobileQuickInsertPopup';
import { QuickInsertButton } from '../../views/QuickInsertButton';
import { QuickInsertPlaceholder } from '../../views/QuickInsertPlaceholder';
import { QuickInsertPopup } from '../../views/QuickInsertPopup';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) protected readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
DividerIcon,
TextIcon,
}));
}
protected _registerComponents(): void {
this._registerPopup();
([
[KeywordInputPlaceholder.componentKey, KeywordInputPlaceholder],
[QuickInsertPlaceholder.componentKey, QuickInsertPlaceholder],
[QuickInsertButton.componentKey, QuickInsertButton],
] as const).forEach(([key, comp]) => {
if (key) {
this.disposeWithMe(this._componentManager.register(key, comp));
}
});
}
protected _registerPopup(): void {
this.disposeWithMe(this._componentManager.register(QuickInsertPopup.componentKey, MobileQuickInsertPopup));
}
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocPopup } from '../services/doc-quick-insert-popup.service';
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { of } from 'rxjs';
import { DeleteSearchKeyCommand } from '../commands/commands/doc-quick-insert.command';
import { CloseQuickInsertPopupOperation, ShowQuickInsertPopupOperation } from '../commands/operations/quick-insert-popup.operation';
import { builtInMenus } from '../menu/menu';
import { DocQuickInsertPopupService } from '../services/doc-quick-insert-popup.service';
export class DocQuickInsertUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocQuickInsertPopupService) private readonly _docQuickInsertPopupService: DocQuickInsertPopupService
) {
super();
this._initCommands();
this._initComponents();
this._initMenus();
}
private _initCommands() {
[
DeleteSearchKeyCommand,
ShowQuickInsertPopupOperation,
CloseQuickInsertPopupOperation,
].forEach((operation) => {
this.disposeWithMe(this._commandService.registerCommand(operation));
});
}
private _initComponents() {
const popups: IDocPopup[] = [
{
keyword: '/',
menus$: of(builtInMenus),
// only show when the cursor is at the beginning of a line
preconditions: (params) => (params.range as ITextRangeWithStyle).startNodePosition?.glyph === 0,
},
];
popups.forEach((popup) => {
this.disposeWithMe(this._docQuickInsertPopupService.registerPopup(popup));
});
}
private _initMenus() {
}
}