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,61 @@
/**
* 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 { ICommandService, Injector, RANGE_DIRECTION } from '@univerjs/core';
import { CutContentCommand } from '@univerjs/docs-ui';
import { describe, expect, it, vi } from 'vitest';
import { DeleteSearchKeyCommand } from '../doc-quick-insert.command';
class RecordingCommandService {
readonly syncExecuteCommand = vi.fn(() => true);
}
function createAccessor(bindings: Array<[unknown, unknown]>) {
const injector = new Injector();
bindings.forEach(([token, useClass]) => {
injector.add([token as never, { useClass: useClass as never }]);
});
return injector;
}
describe('DeleteSearchKeyCommand', () => {
it('cuts the slash-triggered keyword range from the document', () => {
const accessor = createAccessor([
[ICommandService, RecordingCommandService],
]);
const commandService = accessor.get(ICommandService) as unknown as RecordingCommandService;
const result = DeleteSearchKeyCommand.handler(accessor, { start: 4, end: 9 });
expect(result).toBe(true);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(CutContentCommand.id, {
segmentId: '',
textRanges: [{
startOffset: 4,
endOffset: 4,
collapsed: true,
}],
selections: [{
startOffset: 4,
endOffset: 9,
collapsed: false,
direction: RANGE_DIRECTION.FORWARD,
}],
});
});
});

View File

@@ -0,0 +1,50 @@
/**
* 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 { ICommand } from '@univerjs/core';
import type { IInnerCutCommandParams } from '@univerjs/docs-ui';
import { CommandType, ICommandService, RANGE_DIRECTION } from '@univerjs/core';
import { CutContentCommand } from '@univerjs/docs-ui';
interface IDeleteSearchKeyCommandParams {
start: number;
end: number;
}
export const DeleteSearchKeyCommand: ICommand<IDeleteSearchKeyCommandParams> = {
id: 'doc.command.delete-search-key',
type: CommandType.COMMAND,
handler: (accessor, params: IDeleteSearchKeyCommandParams) => {
const commandService = accessor.get(ICommandService);
const { start, end } = params;
return commandService.syncExecuteCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [{
startOffset: start,
endOffset: start,
collapsed: true,
}],
selections: [{
startOffset: start,
endOffset: end,
collapsed: false,
direction: RANGE_DIRECTION.FORWARD,
}],
} as IInnerCutCommandParams);
},
};

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 { Injector } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { DocQuickInsertPopupService } from '../../../services/doc-quick-insert-popup.service';
import { CloseQuickInsertPopupOperation, ShowQuickInsertPopupOperation } from '../quick-insert-popup.operation';
class RecordingQuickInsertPopupService {
readonly showPopup = vi.fn();
readonly closePopup = vi.fn();
}
function createAccessor(bindings: Array<[unknown, unknown]>) {
const injector = new Injector();
bindings.forEach(([token, useClass]) => {
injector.add([token as never, { useClass: useClass as never }]);
});
return injector;
}
describe('quick insert popup operations', () => {
it('shows a popup only when parameters are provided', () => {
const accessor = createAccessor([
[DocQuickInsertPopupService, RecordingQuickInsertPopupService],
]);
const popupService = accessor.get(DocQuickInsertPopupService) as unknown as RecordingQuickInsertPopupService;
const params = {
unitId: 'doc-1',
index: 3,
popup: {
keyword: '/',
menus$: { subscribe: vi.fn() },
},
} as never;
expect(ShowQuickInsertPopupOperation.handler(accessor, undefined)).toBe(false);
expect(ShowQuickInsertPopupOperation.handler(accessor, params)).toBe(true);
expect(popupService.showPopup).toHaveBeenCalledWith(params);
});
it('closes the active popup session', () => {
const accessor = createAccessor([
[DocQuickInsertPopupService, RecordingQuickInsertPopupService],
]);
const popupService = accessor.get(DocQuickInsertPopupService) as unknown as RecordingQuickInsertPopupService;
expect(CloseQuickInsertPopupOperation.handler(accessor)).toBe(true);
expect(popupService.closePopup).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,54 @@
/**
* 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 { ICommand } from '@univerjs/core';
import type { IDocPopup } from '../../services/doc-quick-insert-popup.service';
import { CommandType } from '@univerjs/core';
import { DocQuickInsertPopupService } from '../../services/doc-quick-insert-popup.service';
export interface IShowQuickInsertPopupOperationParams {
unitId: string;
index: number;
popup: IDocPopup;
}
export const ShowQuickInsertPopupOperation: ICommand<IShowQuickInsertPopupOperationParams> = {
type: CommandType.OPERATION,
id: 'doc.operation.show-quick-insert-popup',
handler(accessor, params) {
const docQuickInsertPopupService = accessor.get(DocQuickInsertPopupService);
if (!params) {
return false;
}
docQuickInsertPopupService.showPopup(params);
return true;
},
};
export const CloseQuickInsertPopupOperation: ICommand = {
type: CommandType.OPERATION,
id: 'doc.operation.close-quick-insert-popup',
handler(accessor) {
const docQuickInsertPopupService = accessor.get(DocQuickInsertPopupService);
docQuickInsertPopupService.closePopup();
return true;
},
};

View File

@@ -0,0 +1,28 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY = 'docs-quick-insert-ui.config';
export const DOCS_QUICK_INSERT_UI_PLUGIN_NAME = 'DOC_QUICK_INSERT_UI_PLUGIN';
export const configSymbol = Symbol(DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDocsQuickInsertUIConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverDocsQuickInsertUIConfig = {};

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() {
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export type { IUniverDocsQuickInsertUIConfig } from './config/config';
export { DocQuickInsertTriggerController } from './controllers/doc-quick-insert-trigger.controller';
export { DocQuickInsertUIController } from './controllers/ui.controller';
export { UniverDocsQuickInsertMobileUIPlugin } from './mobile-plugin';
export { UniverDocsQuickInsertUIPlugin } from './plugin';
export type { DocPopupMenu, IDocPopup, IDocPopupGroupItem, IDocPopupMenuItem } from './services/doc-quick-insert-popup.service';
export { DocQuickInsertPopupService } from './services/doc-quick-insert-popup.service';
export { KeywordInputPlaceholderComponentKey } from './views/KeywordInputPlaceholder';
export { QuickInsertPlaceholderComponentKey } from './views/QuickInsertPlaceholder';

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'قائمة مرقمة',
bulletedList: 'قائمة نقطية',
divider: 'فاصل',
text: 'نص',
table: 'جدول',
image: 'صورة',
},
group: {
basics: 'أساسيات',
},
placeholder: 'لم يُعثر على نتائج',
keywordInputPlaceholder: 'أدخل الكلمات المفتاحية',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Llista numerada',
bulletedList: 'Llista amb vinyetes',
divider: 'Divisor',
text: 'Text',
table: 'Taula',
image: 'Imatge',
},
group: {
basics: 'Bàsics',
},
placeholder: 'No s\'han trobat resultats',
keywordInputPlaceholder: 'Introdueix paraules clau',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Nummerierte Liste',
bulletedList: 'Aufzählungsliste',
divider: 'Trennlinie',
text: 'Text',
table: 'Tabelle',
image: 'Bild',
},
group: {
basics: 'Grundlagen',
},
placeholder: 'Keine Ergebnisse gefunden',
keywordInputPlaceholder: 'Stichwörter eingeben',
},
};
export default locale;

View File

@@ -0,0 +1,35 @@
/**
* 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.
*/
const locale = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Numbered List',
bulletedList: 'Bulleted List',
divider: 'Divider',
text: 'Text',
table: 'Table',
image: 'Image',
},
group: {
basics: 'Basics',
},
placeholder: 'No results found',
keywordInputPlaceholder: 'Enter keywords',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Lista numerada',
bulletedList: 'Lista con viñetas',
divider: 'Divisor',
text: 'Texto',
table: 'Tabla',
image: 'Imagen',
},
group: {
basics: 'Básicos',
},
placeholder: 'No se han encontrado resultados',
keywordInputPlaceholder: 'Introduce palabras clave',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'لیست شمارهدار',
bulletedList: 'لیست نقطهدار',
divider: 'خط تقسیم',
text: 'متن',
table: 'جدول',
image: 'تصویر',
},
group: {
basics: 'بنیادی',
},
placeholder: 'هیچ نتیجهای یافت نشد',
keywordInputPlaceholder: 'وارد کلمات کلیدی',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Liste numérotée',
bulletedList: 'Liste à puces',
divider: 'Ligne de séparation',
text: 'Texte',
table: 'Tableau',
image: 'Image',
},
group: {
basics: 'Basiques',
},
placeholder: 'Aucun résultat',
keywordInputPlaceholder: 'Entrez des mots clés',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Daftar Bernomor',
bulletedList: 'Daftar Berpoin',
divider: 'Pembatas',
text: 'Teks',
table: 'Tabel',
image: 'Gambar',
},
group: {
basics: 'Dasar',
},
placeholder: 'Tidak ada hasil ditemukan',
keywordInputPlaceholder: 'Masukkan kata kunci',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Elenco Numerato',
bulletedList: 'Elenco Puntato',
divider: 'Divisore',
text: 'Testo',
table: 'Tabella',
image: 'Immagine',
},
group: {
basics: 'Elementi Base',
},
placeholder: 'Nessun risultato trovato',
keywordInputPlaceholder: 'Inserisci parole chiave',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: '番号付きリスト',
bulletedList: '箇条書き',
divider: '区切り線',
text: 'テキスト',
table: '表',
image: '画像',
},
group: {
basics: '基本',
},
placeholder: '結果がありません',
keywordInputPlaceholder: 'キーワードを入力',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: '번호가 매겨진 목록',
bulletedList: '글머리 기호 목록',
divider: '구분선',
text: '텍스트',
table: '표',
image: '이미지',
},
group: {
basics: '기본',
},
placeholder: '결과가 없습니다',
keywordInputPlaceholder: '키워드 입력',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Lista numerowana',
bulletedList: 'Lista punktowana',
divider: 'Separator',
text: 'Tekst',
table: 'Tabela',
image: 'Obraz',
},
group: {
basics: 'Podstawy',
},
placeholder: 'Nie znaleziono wyników',
keywordInputPlaceholder: 'Wpisz słowa kluczowe',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Lista Numerada',
bulletedList: 'Lista com Marcadores',
divider: 'Divisória',
text: 'Texto',
table: 'Tabela',
image: 'Imagem',
},
group: {
basics: 'Básicos',
},
placeholder: 'Nenhum resultado encontrado',
keywordInputPlaceholder: 'Digite palavras-chave',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Список с нумерацией',
bulletedList: 'Маркированный список',
divider: 'Разделительная линия',
text: 'Текст',
table: 'Таблица',
image: 'Изображение',
},
group: {
basics: 'Основные',
},
placeholder: 'Нет результатов',
keywordInputPlaceholder: 'Введите ключевые слова',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Číslovaný zoznam',
bulletedList: 'Odrážkový zoznam',
divider: 'Oddeľovač',
text: 'Text',
table: 'Tabuľka',
image: 'Obrázok',
},
group: {
basics: 'Základy',
},
placeholder: 'Nenašli sa žiadne výsledky',
keywordInputPlaceholder: 'Zadajte kľúčové slová',
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: 'Danh sách được đánh số',
bulletedList: 'Danh sách không được đánh số',
divider: 'Đường phân cách',
text: 'Văn bản',
table: 'Bảng',
image: 'Hình ảnh',
},
group: {
basics: 'Cơ bản',
},
placeholder: 'Không có kết quả',
keywordInputPlaceholder: 'Nhập từ khóa',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: '有序列表',
bulletedList: '无序列表',
divider: '分隔线',
text: '文本',
table: '表格',
image: '图片',
},
group: {
basics: '基础',
},
placeholder: '暂无结果',
keywordInputPlaceholder: '输入关键词',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: '有序列表',
bulletedList: '無序列表',
divider: '分隔線',
text: '文字',
table: '表格',
image: '圖片',
},
group: {
basics: '基礎',
},
placeholder: '無結果',
keywordInputPlaceholder: '輸入關鍵詞',
},
};
export default locale;

View File

@@ -0,0 +1,37 @@
/**
* 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 enUS from './en-US';
const locale: typeof enUS = {
'docs-quick-insert-ui': {
menu: {
numberedList: '有序列表',
bulletedList: '無序列表',
divider: '分隔線',
text: '文字',
table: '表格',
image: '圖片',
},
group: {
basics: '基礎',
},
placeholder: '無結果',
keywordInputPlaceholder: '輸入關鍵詞',
},
};
export default locale;

View File

@@ -0,0 +1,101 @@
/**
* 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 { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocQuickInsertMenuController } from '../doc-quick-insert-menu.controller';
function createParagraph(startIndex: number, paragraphEnd = startIndex) {
return {
startIndex,
paragraphStart: startIndex,
paragraphEnd,
firstLine: { left: 1, top: 2, right: 10, bottom: 12 },
};
}
describe('DocQuickInsertMenuController', () => {
it('shows the quick insert button for empty paragraphs and hides it when the line is no longer eligible', () => {
const hoverParagraphLeftRealTime$ = new BehaviorSubject<ReturnType<typeof createParagraph> | null>(null);
const hoverParagraphRealTime$ = new BehaviorSubject<ReturnType<typeof createParagraph> | null>(null);
const firstDisposable = { dispose: vi.fn(), canDispose: vi.fn(() => true) };
const secondDisposable = { dispose: vi.fn(), canDispose: vi.fn(() => true) };
const attachPopupToRect = vi.fn()
.mockReturnValueOnce(firstDisposable)
.mockReturnValueOnce(secondDisposable);
const controller = new DocQuickInsertMenuController(
{
unit: {
getDisabled: () => false,
getUnitId: () => 'doc-1',
},
} as never,
{ hoverParagraphLeftRealTime$, hoverParagraphRealTime$ } as never,
{ editPopup: null } as never,
{ attachPopupToRect } as never
);
const emptyParagraph = createParagraph(6);
hoverParagraphRealTime$.next(emptyParagraph);
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
expect(controller.popup).toEqual({ startIndex: 6, disposable: firstDisposable });
hoverParagraphRealTime$.next(emptyParagraph);
expect(attachPopupToRect).toHaveBeenCalledTimes(1);
hoverParagraphRealTime$.next(createParagraph(6, 9));
expect(firstDisposable.dispose).toHaveBeenCalledTimes(1);
expect(controller.popup).toBeNull();
hoverParagraphLeftRealTime$.next(createParagraph(8));
expect(attachPopupToRect).toHaveBeenCalledTimes(2);
expect(controller.popup).toEqual({ startIndex: 8, disposable: secondDisposable });
controller.dispose();
});
it('keeps the left-side button mounted while the edit popup itself is active', () => {
const hoverParagraphLeftRealTime$ = new BehaviorSubject<ReturnType<typeof createParagraph> | null>(createParagraph(3));
const hoverParagraphRealTime$ = new BehaviorSubject<ReturnType<typeof createParagraph> | null>(null);
const disposable = { dispose: vi.fn(), canDispose: vi.fn(() => true) };
const quickInsertPopupService = {
editPopup: null as { anchor: number } | null,
};
const controller = new DocQuickInsertMenuController(
{
unit: {
getDisabled: () => false,
getUnitId: () => 'doc-1',
},
} as never,
{ hoverParagraphLeftRealTime$, hoverParagraphRealTime$ } as never,
quickInsertPopupService as never,
{ attachPopupToRect: vi.fn(() => disposable) } as never
);
quickInsertPopupService.editPopup = { anchor: 3 };
hoverParagraphLeftRealTime$.next(null);
hoverParagraphRealTime$.next(null);
expect(disposable.dispose).not.toHaveBeenCalled();
expect(controller.popup).toEqual({ startIndex: 3, disposable });
controller.dispose();
});
});

View File

@@ -0,0 +1,76 @@
/**
* 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, INeedCheckDisposable, Nullable } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, Inject } from '@univerjs/core';
import { DocCanvasPopManagerService, DocEventManagerService } from '@univerjs/docs-ui';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { DocQuickInsertPopupService } from '../services/doc-quick-insert-popup.service';
import { QuickInsertButtonComponentKey } from '../views/QuickInsertButton';
export class DocQuickInsertMenuController extends Disposable implements IRenderModule {
private _popup$ = new BehaviorSubject<Nullable<{ startIndex: number; disposable: INeedCheckDisposable }>>(null);
readonly popup$ = this._popup$.asObservable();
get popup() {
return this._popup$.value;
}
constructor(
private _context: IRenderContext<DocumentDataModel>,
@Inject(DocEventManagerService) private _docEventManagerService: DocEventManagerService,
@Inject(DocQuickInsertPopupService) private _docQuickInsertPopupService: DocQuickInsertPopupService,
@Inject(DocCanvasPopManagerService) private _docCanvasPopManagerService: DocCanvasPopManagerService
) {
super();
this._init();
}
private _init() {
this.disposeWithMe(combineLatest([this._docEventManagerService.hoverParagraphLeftRealTime$, this._docEventManagerService.hoverParagraphRealTime$]).subscribe(([left, paragraph]) => {
const p = left ?? paragraph;
const isDisabled = this._context.unit.getDisabled();
if (!p || isDisabled) {
this._hideMenu(true);
return;
}
if (p.paragraphStart === p.paragraphEnd) {
if (this._docQuickInsertPopupService.editPopup || p.startIndex === this.popup?.startIndex) return;
this._hideMenu(true);
const disposable = this._docCanvasPopManagerService.attachPopupToRect(p.firstLine, {
componentKey: QuickInsertButtonComponentKey,
direction: 'left-center',
}, this._context.unit.getUnitId());
this._popup$.next({
startIndex: p.startIndex,
disposable,
});
} else {
this._hideMenu(true);
}
}));
}
private _hideMenu(force: boolean) {
if (this._docQuickInsertPopupService.editPopup) return;
if (this.popup && (force || this.popup.disposable.canDispose())) {
this.popup.disposable.dispose();
this._popup$.next(null);
}
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocPopupMenu, IDocPopupMenuItem } from '../services/doc-quick-insert-popup.service';
import { InsertDocImageCommand } from '@univerjs/docs-drawing-ui';
import {
BulletListCommand,
DocCreateTableOperation,
HorizontalLineCommand,
OrderListCommand,
} from '@univerjs/docs-ui';
export enum QuickInsertMenuGroup {
Basic = 'quick.insert.group.basic',
Media = 'quick.insert.group.media',
}
export const textMenu: IDocPopupMenuItem = {
id: 'quick-insert.text.menu',
title: 'docs-quick-insert-ui.menu.text',
icon: 'TextIcon',
keywords: ['text'],
};
export const numberedListMenu: IDocPopupMenuItem = {
id: OrderListCommand.id,
title: 'docs-quick-insert-ui.menu.numberedList',
icon: 'OrderIcon',
keywords: ['numbered', 'list', 'ordered'],
};
export const bulletedListMenu: IDocPopupMenuItem = {
id: BulletListCommand.id,
title: 'docs-quick-insert-ui.menu.bulletedList',
icon: 'UnorderIcon',
keywords: ['bulleted', 'list', 'unordered'],
};
export const dividerMenu: IDocPopupMenuItem = {
id: HorizontalLineCommand.id,
title: 'docs-quick-insert-ui.menu.divider',
icon: 'DividerIcon',
keywords: ['divider', 'line', 'separate'],
};
export const tableMenu: IDocPopupMenuItem = {
id: DocCreateTableOperation.id,
title: 'docs-quick-insert-ui.menu.table',
icon: 'GridIcon',
keywords: ['table', 'grid', 'spreadsheet'],
};
export const imageMenu: IDocPopupMenuItem = {
id: InsertDocImageCommand.id,
title: 'docs-quick-insert-ui.menu.image',
icon: 'AdditionAndSubtractionIcon',
keywords: ['image', 'picture', 'photo'],
};
export const builtInMenus: DocPopupMenu[] = [
{
title: 'docs-quick-insert-ui.group.basics',
id: QuickInsertMenuGroup.Basic,
children: [
textMenu,
numberedListMenu,
bulletedListMenu,
dividerMenu,
tableMenu,
imageMenu,
],
},
];
export const builtInMenuCommandIds = new Set([
numberedListMenu.id,
bulletedListMenu.id,
dividerMenu.id,
tableMenu.id,
imageMenu.id,
]);

View File

@@ -0,0 +1,93 @@
/**
* 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 { Dependency } from '@univerjs/core';
import type { IUniverDocsQuickInsertUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDocsDrawingMobileUIPlugin } from '@univerjs/docs-drawing-ui';
import { UniverDocsMobileUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingMobileUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY, DOCS_QUICK_INSERT_UI_PLUGIN_NAME } from './config/config';
import { DocQuickInsertTriggerController } from './controllers/doc-quick-insert-trigger.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { DocQuickInsertUIController } from './controllers/ui.controller';
import { DocQuickInsertMenuController } from './menu/doc-quick-insert-menu.controller';
import { DocQuickInsertPopupService } from './services/doc-quick-insert-popup.service';
@DependentOn(
UniverDocsPlugin,
UniverDocsDrawingPlugin,
UniverDocsDrawingMobileUIPlugin,
UniverDocsMobileUIPlugin,
UniverDrawingPlugin,
UniverDrawingMobileUIPlugin,
UniverRenderEnginePlugin
)
export class UniverDocsQuickInsertMobileUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_DOC;
static override pluginName = DOCS_QUICK_INSERT_UI_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDocsQuickInsertUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@Inject(IRenderManagerService) private _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[MobileComponentsController],
[DocQuickInsertUIController],
[DocQuickInsertTriggerController],
[DocQuickInsertPopupService],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
this._injector.get(MobileComponentsController);
this._injector.get(DocQuickInsertUIController);
}
override onRendered(): void {
this._injector.get(DocQuickInsertTriggerController);
this._injector.get(DocQuickInsertPopupService);
([
[DocQuickInsertMenuController],
] as Dependency[]).forEach((m) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, m);
});
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsQuickInsertUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsDrawingPlugin } from '@univerjs/docs-drawing';
import { UniverDocsDrawingUIPlugin } from '@univerjs/docs-drawing-ui';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverDrawingUIPlugin } from '@univerjs/drawing-ui';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY, DOCS_QUICK_INSERT_UI_PLUGIN_NAME } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DocQuickInsertTriggerController } from './controllers/doc-quick-insert-trigger.controller';
import { DocQuickInsertUIController } from './controllers/ui.controller';
import { DocQuickInsertMenuController } from './menu/doc-quick-insert-menu.controller';
import { DocQuickInsertPopupService } from './services/doc-quick-insert-popup.service';
@DependentOn(
UniverDocsPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsDrawingPlugin,
UniverDocsUIPlugin,
UniverDrawingUIPlugin,
UniverDocsDrawingUIPlugin
)
export class UniverDocsQuickInsertUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_DOC;
static override pluginName = DOCS_QUICK_INSERT_UI_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDocsQuickInsertUIConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@Inject(IRenderManagerService) private _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_QUICK_INSERT_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
const dependencies: Dependency[] = [
[ComponentsController],
[DocQuickInsertUIController],
[DocQuickInsertTriggerController],
[DocQuickInsertPopupService],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
this._injector.get(ComponentsController);
this._injector.get(DocQuickInsertUIController);
}
override onRendered(): void {
this._injector.get(DocQuickInsertTriggerController);
this._injector.get(DocQuickInsertPopupService);
([
[DocQuickInsertMenuController],
] as Dependency[]).forEach((m) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, m);
});
}
}

View File

@@ -0,0 +1,121 @@
/**
* 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, IDocumentData } from '@univerjs/core';
import { createRequire } from 'node:module';
import { DocumentFlavor, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import { DocCanvasPopManagerService, DocEventManagerService, DocLayoutInteractionService } from '@univerjs/docs-ui';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService, RenderUnit } from '@univerjs/engine-render';
import { CanvasPopupService, ICanvasPopupService } from '@univerjs/ui';
import { of } from 'rxjs';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { DocQuickInsertPopupService } from '../doc-quick-insert-popup.service';
beforeAll(() => {
vi.stubGlobal('jest', vi);
createRequire(import.meta.url)('jest-canvas-mock');
});
afterAll(() => vi.unstubAllGlobals());
function createTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocQuickInsertPopupService]);
return { univer, injector };
}
describe('DocQuickInsertPopupService', () => {
it('uses the target document layout interaction instead of a global service', () => {
const { univer, injector } = createTestBed();
try {
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'quick-insert-doc',
body: {
dataStream: '/\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-1' }],
sectionBreaks: [{ startIndex: 2, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId());
if (!(render instanceof RenderUnit)) {
throw new TypeError('Expected a render unit');
}
render.deactivate();
render.engine.resizeBySize(300, 400);
render.addRenderDependencies([[DocSkeletonManagerService], [DocLayoutInteractionService]]);
const documents = new Documents('quick-insert-doc', render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
render.addRenderDependencies([[DocEventManagerService]]);
injector.get(DocSelectionManagerService).replaceDocRanges([{
startOffset: 1,
endOffset: 1,
collapsed: true,
segmentId: '',
}]);
const service = injector.get(DocQuickInsertPopupService);
const interaction = render.with(DocLayoutInteractionService);
const popup = { keyword: '/', menus$: of([]) };
const unregister = service.registerPopup(popup);
expect(service.resolvePopup('/')).toBe(popup);
service.showPopup({ popup, index: 0, unitId: model.getUnitId() });
expect(service.editPopup?.unitId).toBe(model.getUnitId());
expect(interaction.isActive).toBe(true);
service.setIsComposing(true);
service.setIsComposing(false);
service.closePopup();
service.closePopup();
expect(interaction.isActive).toBe(false);
expect(service.editPopup).toBeNull();
service.showPopup({ popup, index: 0, unitId: model.getUnitId() });
expect(interaction.isActive).toBe(true);
service.dispose();
service.dispose();
expect(interaction.isActive).toBe(false);
expect(injector.get(ICanvasPopupService).popups).toHaveLength(0);
unregister();
expect(service.resolvePopup('/')).toBeUndefined();
} finally {
univer.dispose();
}
});
it('does not open a popup for a document without a render', () => {
const { univer, injector } = createTestBed();
try {
const service = injector.get(DocQuickInsertPopupService);
service.showPopup({ popup: { keyword: '/', menus$: of([]) }, index: 0, unitId: 'missing' });
expect(service.editPopup).toBeUndefined();
} finally {
univer.dispose();
}
});
});

View File

@@ -0,0 +1,381 @@
/**
* 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, IDisposable, Nullable } from '@univerjs/core';
import type { IInsertTextCommandParams } from '@univerjs/docs';
import type { Documents, DocumentSkeleton, IBoundRectNoAngle, IDocumentSkeletonGlyph, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { Observable } from 'rxjs';
import { Disposable, DisposableCollection, ICommandService, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService } from '@univerjs/docs';
import {
DocCanvasPopManagerService,
DocEventManagerService,
DocLayoutInteractionService,
getAnchorBounding,
NodePositionConvertToCursor,
} from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { BehaviorSubject, combineLatest, distinctUntilChanged, map, tap } from 'rxjs';
import { DeleteSearchKeyCommand } from '../commands/commands/doc-quick-insert.command';
import { KeywordInputPlaceholder } from '../views/KeywordInputPlaceholder';
import { QuickInsertPopup } from '../views/QuickInsertPopup';
export interface IDocPopupGroupItem {
id: string;
icon?: string;
title: string;
children?: IDocPopupMenuItem[];
}
export interface IDocPopupMenuItem {
id: string;
icon?: string;
title: string;
keywords?: string[];
}
export type DocPopupMenu = IDocPopupGroupItem | IDocPopupMenuItem;
export interface IDocPopup {
keyword: string;
menus$: Observable<DocPopupMenu[]>;
Placeholder?: React.ComponentType;
preconditions?: (params: IInsertTextCommandParams) => boolean;
}
const noopDisposable = {
dispose: () => {},
};
interface IKeywordInputPlaceholderExtraProps extends Record<string, unknown> {
fontSize?: number;
fontString?: string;
fontFamily?: string;
fontStyle?: 'normal' | 'italic';
fontWeight?: 'normal' | 'bold';
ascent?: number;
contentHeight?: number;
}
export class DocQuickInsertPopupService extends Disposable {
private readonly _popups: Set<IDocPopup> = new Set();
get popups() {
return Array.from(this._popups);
}
private readonly _editPopup$ = new BehaviorSubject<Nullable<{
popup: IDocPopup;
anchor: number;
disposable: IDisposable;
unitId: string;
}>>(undefined);
readonly editPopup$ = this._editPopup$.asObservable();
get editPopup() {
return this._editPopup$.value;
}
private readonly _isComposing$ = new BehaviorSubject<boolean>(false);
readonly isComposing$ = this._isComposing$.asObservable();
get isComposing() {
return this._isComposing$.value;
}
setIsComposing(isComposing: boolean) {
this._isComposing$.next(isComposing);
}
private readonly _inputOffset$ = new BehaviorSubject<{ start: number; end: number }>({ start: 0, end: 0 });
readonly inputOffset$ = this._inputOffset$.asObservable();
get inputOffset() {
return this._inputOffset$.value;
}
setInputOffset(offset: { start: number; end: number }) {
this._inputOffset$.next(offset);
}
readonly filterKeyword$: Observable<string>;
private _menuSelectedCallbacks: Set<(menu: IDocPopupMenuItem) => void> = new Set();
private _inputPlaceholderRenderRoot: {
unmount?: IDisposable;
mount: () => void;
} | null = null;
private _getDocEventManagerService(unitId: string) {
return this._renderManagerService.getRenderUnitById(unitId)?.with(DocEventManagerService);
}
constructor(
@Inject(DocCanvasPopManagerService) private readonly _docCanvasPopupManagerService: DocCanvasPopManagerService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService,
@Inject(ICommandService) private readonly _commandService: ICommandService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService
) {
super();
this.disposeWithMe(this._editPopup$);
this.disposeWithMe(this._isComposing$);
this.disposeWithMe(this._inputOffset$);
this.disposeWithMe(this._renderManagerService.disposed$.subscribe((unitId) => {
if (this.editPopup?.unitId === unitId) {
this.closePopup();
}
}));
const getBodySlice = (start: number, end: number) => this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)?.getBody()?.dataStream.slice(start, end);
let lastFilterKeyword = '';
this.filterKeyword$ = this._inputOffset$.pipe(
map((offset) => {
const slice = getBodySlice(offset.start, offset.end);
return slice?.slice(1) ?? '';
}),
distinctUntilChanged(),
tap((filterKeyword) => {
lastFilterKeyword = filterKeyword;
})
);
this.disposeWithMe(combineLatest([
this.filterKeyword$.pipe(tap((filterKeyword) => {
if (filterKeyword.length > 0) {
this._inputPlaceholderRenderRoot?.unmount?.dispose();
} else {
this._inputPlaceholderRenderRoot?.mount();
}
})),
this.isComposing$.pipe(tap((isComposing) => {
if (isComposing) {
this._inputPlaceholderRenderRoot?.unmount?.dispose();
} else {
// If the last filter keyword is empty, after the composition end, mount the input placeholder render root
lastFilterKeyword.length <= 0 && this._inputPlaceholderRenderRoot?.mount();
}
})),
this.editPopup$.pipe(tap((popup) => {
if (!popup) {
this._inputPlaceholderRenderRoot?.unmount?.dispose();
this._inputPlaceholderRenderRoot = null;
}
})),
]).subscribe());
}
override dispose(): void {
if (this._disposed) {
return;
}
this.closePopup();
this._menuSelectedCallbacks.clear();
this._popups.clear();
super.dispose();
}
resolvePopup(keyword: string) {
return Array.from(this._popups).find((popup) => popup.keyword === keyword);
}
registerPopup(popup: IDocPopup) {
this._popups.add(popup);
return () => {
this._popups.delete(popup);
};
}
private _createInputPlaceholderRenderRoot(mount: () => IDisposable) {
const renderRoot: {
isMounted: boolean;
unmount?: IDisposable;
mount: () => void;
} = {
isMounted: false,
mount() {
// Prevent duplicate mounting
if (this.isMounted) {
return;
}
this.isMounted = true;
const unmount = mount();
this.unmount = {
dispose: () => {
unmount.dispose();
this.isMounted = false;
},
};
},
};
return renderRoot;
}
private _getParagraphBound(unitId: string, index: number) {
const currentDoc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId);
const paragraph = currentDoc?.getBody()?.paragraphs?.find((p) => p.startIndex > index);
if (!paragraph) {
return null;
}
const docEventManagerService = this._getDocEventManagerService(unitId);
return docEventManagerService?.findParagraphBoundByIndex(paragraph.startIndex) ?? null;
}
private _getKeywordPlaceholderAnchorRect(
document: Documents,
skeleton: DocumentSkeleton,
activeRange: ITextRangeWithStyle,
fallbackRect: IBoundRectNoAngle
): IBoundRectNoAngle {
const startPosition = skeleton.findNodePositionByCharIndex(activeRange.startOffset, true, activeRange.segmentId, activeRange.segmentPage);
if (!startPosition) {
return fallbackRect;
}
const documentOffsetConfig = document.getOffsetConfig();
const convertor = new NodePositionConvertToCursor(documentOffsetConfig, skeleton);
const { contentBoxPointGroup } = convertor.getRangePointData(startPosition, startPosition);
if (contentBoxPointGroup.length === 0) {
return fallbackRect;
}
const anchor = getAnchorBounding(contentBoxPointGroup);
const left = anchor.left + documentOffsetConfig.docsLeft;
const top = anchor.top + documentOffsetConfig.docsTop;
return {
left,
right: left,
top,
bottom: top + anchor.height,
};
}
private _getKeywordPlaceholderExtraProps(curGlyph: IDocumentSkeletonGlyph): IKeywordInputPlaceholderExtraProps {
return {
fontSize: curGlyph.ts?.fs,
fontString: curGlyph.fontStyle?.fontString,
fontFamily: curGlyph.fontStyle?.fontFamily ?? curGlyph.ts?.ff ?? undefined,
fontStyle: curGlyph.ts?.it ? 'italic' : 'normal',
fontWeight: curGlyph.ts?.bl ? 'bold' : 'normal',
ascent: curGlyph.bBox?.ba,
contentHeight: ((curGlyph.bBox?.ba ?? 0) + (curGlyph.bBox?.bd ?? 0)) || undefined,
};
}
private _mountInputPlaceholder(unitId: string, fallbackRect: IBoundRectNoAngle): IDisposable {
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
const docSkeletonManagerService = currentRender?.with(DocSkeletonManagerService);
const activeRange = this._docSelectionManagerService.getActiveTextRange();
if (!currentRender || !docSkeletonManagerService || !activeRange) {
return noopDisposable;
}
const skeleton = docSkeletonManagerService.getSkeleton();
const curGlyph = skeleton.findNodeByCharIndex(activeRange.startOffset, activeRange.segmentId, activeRange.segmentPage);
const isEmptyLine = curGlyph?.content === '\r';
if (!isEmptyLine || !curGlyph) {
return noopDisposable;
}
const document = currentRender.mainComponent as Documents;
const placeholderAnchorRect = this._getKeywordPlaceholderAnchorRect(document, skeleton, activeRange, fallbackRect);
const extraProps = this._getKeywordPlaceholderExtraProps(curGlyph);
const disposable = this._docCanvasPopupManagerService.attachPopupToRect(
placeholderAnchorRect,
{
componentKey: KeywordInputPlaceholder.componentKey,
extraProps,
onClickOutside: () => {
disposable.dispose();
},
direction: 'horizontal',
},
unitId
);
return disposable;
}
showPopup(options: { popup: IDocPopup; index: number; unitId: string }) {
const { popup, index, unitId } = options;
this.closePopup();
const render = this._renderManagerService.getRenderUnitById(unitId);
if (!render) {
return;
}
const paragraphBound = this._getParagraphBound(unitId, index);
if (!paragraphBound) {
return;
}
this._inputPlaceholderRenderRoot = this._createInputPlaceholderRenderRoot(() => this._mountInputPlaceholder(unitId, paragraphBound.firstLine));
this._inputPlaceholderRenderRoot.mount();
const layoutInteraction = render.with(DocLayoutInteractionService).beginInteraction();
const popupDisposable = this._docCanvasPopupManagerService.attachPopupToRect(
paragraphBound.firstLine,
{
componentKey: QuickInsertPopup.componentKey,
onClickOutside: () => {
this.closePopup();
},
direction: 'bottom',
},
unitId
);
const disposable = new DisposableCollection();
disposable.add(popupDisposable);
disposable.add(layoutInteraction);
this._editPopup$.next({ disposable, popup, anchor: index, unitId });
}
closePopup() {
if (this.editPopup) {
this.editPopup.disposable.dispose();
this._editPopup$.next(null);
}
}
onMenuSelected(callback: (menu: IDocPopupMenuItem) => void) {
this._menuSelectedCallbacks.add(callback);
return () => {
this._menuSelectedCallbacks.delete(callback);
};
}
emitMenuSelected(menu: IDocPopupMenuItem) {
const { start, end } = this.inputOffset;
this._commandService.syncExecuteCommand(DeleteSearchKeyCommand.id, {
start,
end,
});
setTimeout(() => {
this._menuSelectedCallbacks.forEach((callback) => callback(menu));
}, 0);
}
}

View File

@@ -0,0 +1,96 @@
/**
* 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 { IPopup } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { LocaleService } from '@univerjs/core';
import { ptToPixel } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { useMemo } from 'react';
export const KeywordInputPlaceholderComponentKey = 'docs.quick.insert.keyword-input-placeholder';
const DEFAULT_FONT_SIZE = 11;
interface IKeywordInputPlaceholderExtraProps {
fontSize?: number;
fontString?: string;
fontFamily?: string;
fontStyle?: 'normal' | 'italic';
fontWeight?: 'normal' | 'bold';
ascent?: number;
contentHeight?: number;
}
function measureTextWidth(text: string, font: string) {
if (typeof document === 'undefined') {
return text.length * DEFAULT_FONT_SIZE;
}
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
return text.length * DEFAULT_FONT_SIZE;
}
context.font = font;
return Math.ceil(context.measureText(text).width);
}
export const KeywordInputPlaceholder = ({ popup }: { popup: IPopup<IKeywordInputPlaceholderExtraProps> }) => {
const localeService = useDependency(LocaleService);
const placeholder = localeService.t<LocaleKey>('docs-quick-insert-ui.keywordInputPlaceholder');
const fontSize = popup.extraProps?.fontSize ?? DEFAULT_FONT_SIZE;
const fontSizePx = ptToPixel(fontSize);
const fontString = popup.extraProps?.fontString ?? `${fontSizePx}px sans-serif`;
const ascent = popup.extraProps?.ascent ?? fontSizePx;
const contentHeight = Math.max(popup.extraProps?.contentHeight ?? fontSizePx, fontSizePx);
const textWidth = useMemo(() => measureTextWidth(placeholder, fontString), [fontString, placeholder]);
return (
<div
className={`
univer-select-none univer-font-normal univer-text-gray-500 univer-transition-colors
dark:!univer-text-gray-400
`}
>
<svg
width={textWidth}
height={contentHeight}
viewBox={`0 0 ${textWidth} ${contentHeight}`}
style={{ overflow: 'visible', display: 'block' }}
>
<text
x={0}
y={ascent}
fill="currentColor"
style={{
font: fontString,
fontFamily: popup.extraProps?.fontFamily,
fontStyle: popup.extraProps?.fontStyle,
fontWeight: popup.extraProps?.fontWeight,
}}
>
{placeholder}
</text>
</svg>
</div>
);
};
KeywordInputPlaceholder.componentKey = KeywordInputPlaceholderComponentKey;

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { MouseEventHandler } from 'react';
import type { IDocPopup } from '../services/doc-quick-insert-popup.service';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { borderClassName, clsx } from '@univerjs/design';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IncreaseIcon } from '@univerjs/icons';
import { ILayoutService, useDependency, useEvent, useObservable } from '@univerjs/ui';
import { useMemo } from 'react';
import { combineLatest, map } from 'rxjs';
import { DocQuickInsertMenuController } from '../menu/doc-quick-insert-menu.controller';
import { DocQuickInsertPopupService } from '../services/doc-quick-insert-popup.service';
export const QuickInsertButtonComponentKey = 'doc.quick-insert.button';
export const QuickInsertButton = ({ className = '' }: {
className?: string;
}) => {
const docQuickInsertPopupService = useDependency(DocQuickInsertPopupService);
const univerInstanceService = useDependency(IUniverInstanceService);
const renderManagerService = useDependency(IRenderManagerService);
const currentDoc = useObservable(useMemo(() => univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC), [univerInstanceService]));
const currentUnit = currentDoc && renderManagerService.getRenderUnitById(currentDoc.getUnitId());
const docQuickInsertMenuController = currentUnit?.with(DocQuickInsertMenuController);
const layoutService = useDependency(ILayoutService);
const docSelectionManagerService = useDependency(DocSelectionManagerService);
const editPopup = useObservable(docQuickInsertPopupService.editPopup$);
const onClick: MouseEventHandler<HTMLDivElement> = useEvent(() => {
const p = docQuickInsertMenuController?.popup;
if (!p) {
return;
}
const allPopups = docQuickInsertPopupService.popups;
// combine all popups into one
const popup: IDocPopup = {
keyword: '',
menus$: combineLatest(allPopups.map((p) => p.menus$))
.pipe(
map((menusCollection) => menusCollection.flat())
),
};
docSelectionManagerService.replaceDocRanges([{
startOffset: p.startIndex,
endOffset: p.startIndex,
}]);
docQuickInsertPopupService.setInputOffset({ start: p.startIndex - 1, end: p.startIndex - 1 });
docQuickInsertPopupService.showPopup({
popup,
index: p.startIndex - 1,
unitId: currentDoc?.getUnitId() ?? '',
});
setTimeout(() => {
// keep the cursor in doc
layoutService.focus();
});
});
return (
<div
className={clsx(`
univer-mr-1 univer-flex univer-cursor-pointer univer-items-center univer-gap-2.5 univer-rounded-full
univer-p-1.5 univer-shadow-sm
hover:univer-bg-gray-100
dark:!univer-text-gray-200
dark:hover:!univer-bg-gray-700
`, borderClassName, {
'univer-bg-gray-100 dark:!univer-bg-gray-700': editPopup,
'univer-bg-gray-0 dark:!univer-bg-gray-900': !editPopup,
}, className)}
role="button"
tabIndex={0}
onClick={onClick}
>
<IncreaseIcon
className={`
univer-text-gray-800
dark:!univer-text-gray-200
`}
/>
</div>
);
};
QuickInsertButton.componentKey = QuickInsertButtonComponentKey;

View File

@@ -0,0 +1,173 @@
/**
* 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 { DocPopupMenu, IDocPopupMenuItem } from '../services/doc-quick-insert-popup.service';
import { borderBottomClassName, borderClassName, clsx, scrollbarClassName, Tooltip } from '@univerjs/design';
import { IconManager, useDependency } from '@univerjs/ui';
import { useEffect, useMemo, useRef } from 'react';
interface IQuickInsertMenuProps {
menus: DocPopupMenu[];
focusedMenuIndex: number;
onFocusedMenuIndexChange: (index: number) => void;
onFocusedMenuChange: (menu: IDocPopupMenuItem | null) => void;
onSelect: (menu: IDocPopupMenuItem) => void;
}
function isMenuGroup(menu: DocPopupMenu): menu is DocPopupMenu & { children: IDocPopupMenuItem[] } {
return 'children' in menu;
}
function flattenMenuItems(menus: DocPopupMenu[]): IDocPopupMenuItem[] {
return menus.flatMap((menu) => {
if (isMenuGroup(menu)) {
return flattenMenuItems(menu.children);
}
return menu;
});
}
export function getQuickInsertMenuLeafCount(menus: DocPopupMenu[]) {
return flattenMenuItems(menus).length;
}
export function QuickInsertMenu(props: IQuickInsertMenuProps) {
const {
menus,
focusedMenuIndex,
onFocusedMenuIndexChange,
onFocusedMenuChange,
onSelect,
} = props;
const iconManager = useDependency(IconManager);
const flatMenus = useMemo(() => flattenMenuItems(menus), [menus]);
const menuNodeMapRef = useRef(new Map<string, HTMLElement>());
useEffect(() => {
const focusedMenu = Number.isNaN(focusedMenuIndex)
? null
: flatMenus[focusedMenuIndex] ?? null;
onFocusedMenuChange(focusedMenu);
if (!focusedMenu) {
return;
}
menuNodeMapRef.current.get(focusedMenu.id)?.scrollIntoView({
block: 'nearest',
});
}, [flatMenus, focusedMenuIndex, onFocusedMenuChange]);
useEffect(() => {
const menuNodeMap = menuNodeMapRef.current;
return () => {
menuNodeMap.clear();
};
}, []);
const itemIndexRef = useRef(0);
itemIndexRef.current = 0;
function renderMenus(currentMenus: DocPopupMenu[]) {
return currentMenus.map((menu, index) => {
const iconKey = menu.icon;
const Icon = iconKey ? iconManager.get(iconKey) : null;
if (isMenuGroup(menu)) {
return (
<div
key={menu.id}
className={clsx('univer-grid univer-gap-1 univer-py-1', index !== currentMenus.length - 1 && borderBottomClassName)}
>
<div
className={`
univer-box-border univer-inline-flex univer-items-center univer-gap-2 univer-px-2
univer-text-xs univer-font-semibold univer-text-gray-600
dark:!univer-text-gray-300
`}
>
{Icon && <span className="univer-inline-flex univer-text-base"><Icon /></span>}
<span>{menu.title}</span>
</div>
<div className="univer-grid univer-gap-1">
{renderMenus(menu.children)}
</div>
</div>
);
}
const currentMenuIndex = itemIndexRef.current;
const isFocused = focusedMenuIndex === currentMenuIndex;
itemIndexRef.current += 1;
return (
<div
key={menu.id}
ref={(node) => {
if (node) {
menuNodeMapRef.current.set(menu.id, node);
return;
}
menuNodeMapRef.current.delete(menu.id);
}}
role="button"
tabIndex={-1}
className={clsx(`
univer-relative univer-box-border univer-flex univer-w-full univer-cursor-pointer
univer-items-center univer-justify-between univer-gap-3 univer-rounded-md univer-border-none
univer-bg-transparent univer-px-2 univer-text-left univer-text-sm univer-text-gray-900
univer-outline-none
dark:!univer-text-gray-0
`, {
'univer-min-h-8 hover:univer-bg-gray-50 dark:hover:!univer-bg-gray-600': true,
'hover:univer-bg-transparent': !isFocused,
'univer-bg-gray-50 dark:!univer-bg-gray-600': isFocused,
})}
onMouseEnter={() => onFocusedMenuIndexChange(currentMenuIndex)}
onMouseLeave={() => onFocusedMenuIndexChange(Number.NaN)}
onClick={() => onSelect(menu)}
>
<div className="univer-inline-flex univer-w-full univer-items-center univer-gap-2">
{Icon && <span className="univer-inline-flex univer-text-base"><Icon /></span>}
<Tooltip showIfEllipsis title={menu.title} placement="right">
<span className="univer-truncate">{menu.title}</span>
</Tooltip>
</div>
</div>
);
});
}
return (
<div
className={clsx(`
univer-box-border univer-grid univer-gap-1 univer-overflow-y-auto univer-overflow-x-hidden
univer-overscroll-contain univer-bg-gray-0 univer-px-2 univer-text-gray-900 univer-shadow-md
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
`, borderClassName, scrollbarClassName, {
'univer-max-h-[360px] univer-rounded-md univer-py-1 univer-text-sm': true,
})}
onWheel={(event) => event.stopPropagation()}
>
{renderMenus(menus)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
/**
* 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 { LocaleService } from '@univerjs/core';
import { useDependency } from '@univerjs/ui';
export const QuickInsertPlaceholderComponentKey = 'docs.quick.insert.placeholder';
export const QuickInsertPlaceholder = () => {
const localeService = useDependency(LocaleService);
return (
<div
className={`
univer-flex univer-h-full univer-items-center univer-justify-center univer-rounded-lg univer-bg-gray-0
univer-px-12 univer-py-6 univer-text-gray-400 univer-shadow-lg
`}
>
<span>{localeService.t<LocaleKey>('docs-quick-insert-ui.placeholder')}</span>
</div>
);
};
QuickInsertPlaceholder.componentKey = QuickInsertPlaceholderComponentKey;

View File

@@ -0,0 +1,168 @@
/**
* 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 { IDocPopupMenuItem } from '../services/doc-quick-insert-popup.service';
import {
CommandType,
Direction,
DisposableCollection,
generateRandomId,
ICommandService,
toDisposable,
} from '@univerjs/core';
import { IShortcutService, KeyCode, useDependency } from '@univerjs/ui';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getQuickInsertMenuLeafCount, QuickInsertMenu } from './QuickInsertMenu';
import { useQuickInsertPopup } from './use-quick-insert-popup';
const interceptKeys = [KeyCode.ARROW_UP, KeyCode.ARROW_DOWN, KeyCode.ENTER];
export const QuickInsertPopup = () => {
const shortcutService = useDependency(IShortcutService);
const commandService = useDependency(ICommandService);
const id = useMemo(() => generateRandomId(), []);
const { filteredMenus, handleMenuSelect, Placeholder } = useQuickInsertPopup();
const [focusedMenuIndex, setFocusedMenuIndex] = useState(0);
const focusedMenuRef = useRef<IDocPopupMenuItem | null>(null);
const filteredMenuCount = getQuickInsertMenuLeafCount(filteredMenus);
const filteredMenuCountRef = useRef(filteredMenuCount);
filteredMenuCountRef.current = filteredMenuCount;
const handleFocusedMenuChange = useCallback((menu: IDocPopupMenuItem | null) => {
focusedMenuRef.current = menu;
}, []);
useEffect(() => {
/** Use up or down to navigate the focused menu instead of moving the cursor in documents. */
const disposableCollection = new DisposableCollection();
const shortcutItems = shortcutService.getAllShortcuts();
const interceptedShortcutItems = shortcutItems.filter((item) => item.binding && interceptKeys.includes(item.binding));
// disable the shortcut items of moving the cursor in documents
interceptedShortcutItems.forEach((item) => {
const rawPreconditions = item.preconditions;
item.preconditions = () => false;
disposableCollection.add(toDisposable(() => {
item.preconditions = rawPreconditions;
}));
});
const enterCommand = {
id: `quick.insert.popup.enter.${id}`,
type: CommandType.OPERATION,
handler: () => {
const menu = focusedMenuRef.current;
if (menu) {
handleMenuSelect(menu);
}
},
};
const moveCursorUpCommand = {
id: `quick.insert.popup.move.cursor.up.${id}`,
type: CommandType.OPERATION,
handler: () => {
setFocusedMenuIndex((index) => {
if (filteredMenuCountRef.current <= 0) {
return 0;
}
const nextIndex = (index - 1);
return nextIndex >= 0 ? nextIndex : filteredMenuCountRef.current - 1;
});
},
};
const moveCursorDownCommand = {
id: `quick.insert.popup.move.cursor.down.${id}`,
type: CommandType.OPERATION,
handler: () => {
setFocusedMenuIndex((index) => {
if (filteredMenuCountRef.current <= 0) {
return 0;
}
const nextIndex = (index + 1);
return nextIndex <= (filteredMenuCountRef.current - 1) ? nextIndex : 0;
});
},
};
// register the commands of moving the focused menu up and down
disposableCollection.add(commandService.registerCommand(moveCursorUpCommand));
disposableCollection.add(commandService.registerCommand(moveCursorDownCommand));
disposableCollection.add(commandService.registerCommand(enterCommand));
disposableCollection.add(shortcutService.registerShortcut({
priority: 1000,
id: moveCursorUpCommand.id,
binding: KeyCode.ARROW_UP,
preconditions: () => true,
staticParameters: {
direction: Direction.UP,
},
}));
disposableCollection.add(shortcutService.registerShortcut({
priority: 1000,
id: moveCursorDownCommand.id,
binding: KeyCode.ARROW_DOWN,
preconditions: () => true,
staticParameters: {
direction: Direction.DOWN,
},
}));
disposableCollection.add(shortcutService.registerShortcut({
priority: 1000,
id: enterCommand.id,
binding: KeyCode.ENTER,
preconditions: () => true,
}));
return () => {
disposableCollection.dispose();
};
}, [commandService, id, shortcutService]);
useEffect(() => {
setFocusedMenuIndex(0);
}, [filteredMenus]);
const hasMenus = filteredMenus.length > 0;
return (
<div className="univer-mt-2">
{hasMenus
? (
<QuickInsertMenu
menus={filteredMenus}
focusedMenuIndex={focusedMenuIndex}
onFocusedMenuIndexChange={setFocusedMenuIndex}
onFocusedMenuChange={handleFocusedMenuChange}
onSelect={handleMenuSelect}
/>
)
: Placeholder && <Placeholder />}
</div>
);
};
QuickInsertPopup.componentKey = 'docs.quick.insert.popup';

View File

@@ -0,0 +1,173 @@
/**
* 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 { Root } from 'react-dom/client';
import type { IDocPopupMenuItem } from '../../services/doc-quick-insert-popup.service';
import { DesktopLogService, ILogService, Injector } from '@univerjs/core';
import { IconManager, RediContext } from '@univerjs/ui';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { QuickInsertMenu } from '../QuickInsertMenu';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const insertTableMenu: IDocPopupMenuItem = {
id: 'insert-table',
title: 'Table',
};
const insertImageMenu: IDocPopupMenuItem = {
id: 'insert-image',
title: 'Image',
};
const insertEquationMenu: IDocPopupMenuItem = {
id: 'insert-equation',
title: 'Equation',
};
const nestedMenus = [
{
id: 'insert-group',
title: 'Insert',
children: [
insertTableMenu,
insertImageMenu,
],
},
insertEquationMenu,
];
class TestState {
static focusedMenus: Array<IDocPopupMenuItem | null> = [];
static focusedIndexes: number[] = [];
static selectedMenus: IDocPopupMenuItem[] = [];
static reset(): void {
this.focusedMenus = [];
this.focusedIndexes = [];
this.selectedMenus = [];
}
}
function createInjector() {
const injector = new Injector();
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IconManager, { useClass: IconManager }]);
return injector;
}
function QuickInsertMenuHarness() {
const [focusedMenuIndex, setFocusedMenuIndex] = useState(0);
return (
<QuickInsertMenu
menus={nestedMenus}
focusedMenuIndex={focusedMenuIndex}
onFocusedMenuIndexChange={(index) => {
TestState.focusedIndexes.push(index);
setFocusedMenuIndex(index);
}}
onFocusedMenuChange={(menu) => TestState.focusedMenus.push(menu)}
onSelect={(menu) => TestState.selectedMenus.push(menu)}
/>
);
}
describe('QuickInsertMenu', () => {
let container: HTMLDivElement;
let root: Root;
let scrollIntoViewDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
TestState.reset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: () => {},
});
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
});
function renderMenu() {
const injector = createInjector();
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
<QuickInsertMenuHarness />
</RediContext.Provider>
);
});
}
function findMenuItem(title: string) {
return Array.from(container.querySelectorAll('[role="button"]'))
.find((node) => node.textContent === title) as HTMLElement | undefined;
}
it('maps nested menu leaves to keyboard focus order and selects the hovered business item', () => {
renderMenu();
expect(TestState.focusedMenus.at(-1)).toBe(insertTableMenu);
const imageMenuNode = findMenuItem('Image');
expect(imageMenuNode).toBeDefined();
act(() => {
imageMenuNode!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
expect(TestState.focusedIndexes).toEqual([1]);
expect(TestState.focusedMenus.at(-1)).toBe(insertImageMenu);
act(() => {
imageMenuNode!.click();
});
expect(TestState.selectedMenus).toEqual([insertImageMenu]);
});
it('releases the focused menu when the pointer leaves a business item', () => {
renderMenu();
const tableMenuNode = findMenuItem('Table');
expect(tableMenuNode).toBeDefined();
act(() => {
tableMenuNode!.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
});
expect(Number.isNaN(TestState.focusedIndexes.at(-1))).toBe(true);
expect(TestState.focusedMenus.at(-1)).toBeNull();
});
});

View File

@@ -0,0 +1,393 @@
/**
* 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, IDocumentData } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import type { DocPopupMenu } from '../../services/doc-quick-insert-popup.service';
import { createRequire } from 'node:module';
import { DocumentFlavor, ICommandService, LocaleService, LocaleType, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { CutContentCommand, DocCanvasPopManagerService, DocEventManagerService, DocLayoutInteractionService } from '@univerjs/docs-ui';
import { CanvasColorService, Documents, ICanvasColorService, IRenderManagerService, RenderManagerService, RenderUnit } from '@univerjs/engine-render';
import {
CanvasPopupService,
ComponentManager,
ICanvasPopupService,
IconManager,
IPlatformService,
IShortcutService,
IUIRuntimeScopeService,
KeyCode,
PlatformService,
RediContext,
ShortcutService,
UIRuntimeScopeService,
} from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { BehaviorSubject } from 'rxjs';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { DeleteSearchKeyCommand } from '../../commands/commands/doc-quick-insert.command';
import { CloseQuickInsertPopupOperation } from '../../commands/operations/quick-insert-popup.operation';
import { DocQuickInsertPopupService } from '../../services/doc-quick-insert-popup.service';
import { QuickInsertPlaceholder } from '../QuickInsertPlaceholder';
import { QuickInsertPopup } from '../QuickInsertPopup';
const testUnivers: Univer[] = [];
beforeAll(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('jest', vi);
createRequire(import.meta.url)('jest-canvas-mock');
});
afterAll(() => vi.unstubAllGlobals());
const TEST_DOC_UNIT_ID = 'doc-quick-insert-test';
function createQuickInsertPopupTestBed(options?: {
dataStream?: string;
inputOffset?: { start: number; end: number };
}) {
const univer = new Univer();
testUnivers.push(univer);
const injector = univer.__getInjector();
let cutContentParams: unknown;
let selectedMenu: unknown;
injector.add([IPlatformService, { useClass: PlatformService }]);
injector.add([IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }]);
injector.add([IShortcutService, { useClass: ShortcutService }]);
injector.add([ComponentManager, { useClass: ComponentManager }]);
injector.add([IconManager, { useClass: IconManager }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocCanvasPopManagerService]);
injector.add([DocQuickInsertPopupService]);
const dataStream = options?.dataStream ?? '/ta\r\n';
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: TEST_DOC_UNIT_ID,
body: {
dataStream,
paragraphs: [{ startIndex: dataStream.length - 2, paragraphId: 'popup-paragraph' }],
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 400 },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
},
});
const render = injector.get(IRenderManagerService).createRender(model.getUnitId());
if (!(render instanceof RenderUnit)) {
throw new TypeError('Expected a render unit');
}
render.deactivate();
render.engine.resizeBySize(300, 400);
render.addRenderDependencies([[DocSkeletonManagerService], [DocLayoutInteractionService]]);
const documents = new Documents(TEST_DOC_UNIT_ID, render.with(DocSkeletonManagerService).getSkeleton());
render.mainComponent = documents;
render.scene.addObject(documents);
render.addRenderDependencies([[DocEventManagerService]]);
injector.get(DocSelectionManagerService).replaceDocRanges([{
startOffset: dataStream.length - 2,
endOffset: dataStream.length - 2,
collapsed: true,
segmentId: '',
}]);
const localeService = injector.get(LocaleService);
localeService.load({
[LocaleType.ZH_CN]: {
quick: {
insert: {
group: 'Insert',
table: 'Table',
image: 'Image',
},
},
'docs-quick-insert-ui': {
placeholder: 'No result',
},
},
});
localeService.setLocale(LocaleType.ZH_CN);
const componentManager = injector.get(ComponentManager);
componentManager.register(QuickInsertPlaceholder.componentKey, QuickInsertPlaceholder);
const commandService = injector.get(ICommandService);
commandService.registerCommand(CloseQuickInsertPopupOperation);
commandService.registerCommand(DeleteSearchKeyCommand);
commandService.registerCommand(CutContentCommand);
commandService.registerCommand(RichTextEditingMutation);
commandService.onCommandExecuted((command) => {
if (command.id === CutContentCommand.id) {
cutContentParams = command.params;
}
});
const menus$ = new BehaviorSubject<DocPopupMenu[]>([
{
id: 'insert-group',
title: 'quick.insert.group',
children: [
{
id: 'insert-table',
title: 'quick.insert.table',
keywords: ['table', 'grid'],
},
{
id: 'insert-image',
title: 'quick.insert.image',
keywords: ['image', 'picture'],
},
],
},
]);
const popupService = injector.get(DocQuickInsertPopupService);
popupService.onMenuSelected((menu) => {
selectedMenu = menu;
});
popupService.showPopup({
popup: {
keyword: '/',
menus$,
},
index: 0,
unitId: TEST_DOC_UNIT_ID,
});
popupService.setInputOffset(options?.inputOffset ?? { start: 0, end: 3 });
return {
getCutContentParams: () => cutContentParams,
getSelectedMenu: () => selectedMenu,
injector,
popupService,
popupManagerService: injector.get(ICanvasPopupService),
model,
};
}
describe('QuickInsertPopup', () => {
let container: HTMLDivElement;
let root: Root;
let scrollIntoViewDescriptor: PropertyDescriptor | undefined;
let requestIdleCallbackDescriptor: PropertyDescriptor | undefined;
let cancelIdleCallbackDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
requestIdleCallbackDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestIdleCallback');
cancelIdleCallbackDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelIdleCallback');
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: () => {},
});
Object.defineProperty(globalThis, 'requestIdleCallback', {
configurable: true,
value: (callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 0 });
return 1;
},
});
Object.defineProperty(globalThis, 'cancelIdleCallback', {
configurable: true,
value: () => {},
});
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
testUnivers.splice(0).forEach((univer) => univer.dispose());
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
if (requestIdleCallbackDescriptor) {
Object.defineProperty(globalThis, 'requestIdleCallback', requestIdleCallbackDescriptor);
} else {
delete (globalThis as { requestIdleCallback?: unknown }).requestIdleCallback;
}
if (cancelIdleCallbackDescriptor) {
Object.defineProperty(globalThis, 'cancelIdleCallback', cancelIdleCallbackDescriptor);
} else {
delete (globalThis as { cancelIdleCallback?: unknown }).cancelIdleCallback;
}
});
it('filters the popup menu by the typed keyword and closes the popup after selecting a business menu item', async () => {
const { getCutContentParams, getSelectedMenu, injector, model, popupManagerService, popupService } = createQuickInsertPopupTestBed();
await act(async () => {
root.render(
<RediContext.Provider value={{ injector }}>
<QuickInsertPopup />
</RediContext.Provider>
);
await Promise.resolve();
});
expect(container.textContent).toContain('Table');
expect(container.textContent).not.toContain('Image');
const tableMenuItem = Array.from(container.querySelectorAll<HTMLElement>('[role="button"]'))
.find((node) => node.textContent === 'Table');
expect(tableMenuItem).toBeDefined();
await act(async () => {
tableMenuItem!.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(getSelectedMenu()).toEqual({
id: 'insert-table',
title: 'Table',
keywords: ['table', 'grid', 'table'],
});
expect(getCutContentParams()).toEqual(expect.objectContaining({
selections: [expect.objectContaining({ startOffset: 0, endOffset: 3 })],
}));
expect(popupService.editPopup).toBeNull();
expect(popupManagerService.popups).toHaveLength(0);
expect(model.getBody()?.dataStream).toBe('\r\n');
});
it('uses popup keyboard commands to move focus and select the focused business menu item', async () => {
const { getCutContentParams, getSelectedMenu, injector, popupService } = createQuickInsertPopupTestBed({
dataStream: '/\r\n',
inputOffset: { start: 0, end: 1 },
});
const commandService = injector.get(ICommandService);
const shortcutService = injector.get(IShortcutService);
await act(async () => {
root.render(
<RediContext.Provider value={{ injector }}>
<QuickInsertPopup />
</RediContext.Provider>
);
await Promise.resolve();
});
const moveDownShortcut = shortcutService.getAllShortcuts().find((shortcut) => shortcut.binding === KeyCode.ARROW_DOWN);
const enterShortcut = shortcutService.getAllShortcuts().find((shortcut) => shortcut.binding === KeyCode.ENTER);
expect(moveDownShortcut).toBeDefined();
expect(enterShortcut).toBeDefined();
await act(async () => {
await commandService.executeCommand(moveDownShortcut!.id, moveDownShortcut!.staticParameters);
await Promise.resolve();
});
await act(async () => {
await commandService.executeCommand(enterShortcut!.id);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(getSelectedMenu()).toEqual({
id: 'insert-image',
title: 'Image',
keywords: ['image', 'picture', 'image'],
});
expect(getCutContentParams()).toEqual(expect.objectContaining({
selections: [expect.objectContaining({ startOffset: 0, endOffset: 1 })],
}));
expect(popupService.editPopup).toBeNull();
});
it('temporarily disables document cursor shortcuts while the popup owns keyboard navigation', async () => {
const { injector } = createQuickInsertPopupTestBed({
dataStream: '/\r\n',
inputOffset: { start: 0, end: 1 },
});
const shortcutService = injector.get(IShortcutService);
const documentArrowDownShortcut = {
id: 'doc.operation.move-cursor-down',
binding: KeyCode.ARROW_DOWN,
preconditions: () => true,
};
shortcutService.registerShortcut(documentArrowDownShortcut);
await act(async () => {
root.render(
<RediContext.Provider value={{ injector }}>
<QuickInsertPopup />
</RediContext.Provider>
);
await Promise.resolve();
});
expect(documentArrowDownShortcut.preconditions()).toBe(false);
await act(async () => {
root.render(<RediContext.Provider value={{ injector }} />);
await Promise.resolve();
});
expect(documentArrowDownShortcut.preconditions()).toBe(true);
});
it('keeps the typed trigger intact when enter is pressed with no matching menu', async () => {
const { getCutContentParams, getSelectedMenu, injector, popupService } = createQuickInsertPopupTestBed({
dataStream: '/unknown\r\n',
inputOffset: { start: 0, end: 8 },
});
const commandService = injector.get(ICommandService);
const shortcutService = injector.get(IShortcutService);
await act(async () => {
root.render(
<RediContext.Provider value={{ injector }}>
<QuickInsertPopup />
</RediContext.Provider>
);
await Promise.resolve();
});
expect(container.textContent).toContain('No result');
expect(container.textContent).not.toContain('Table');
const enterShortcut = shortcutService.getAllShortcuts().find((shortcut) => shortcut.binding === KeyCode.ENTER);
expect(enterShortcut).toBeDefined();
await act(async () => {
await commandService.executeCommand(enterShortcut!.id);
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(getSelectedMenu()).toBeUndefined();
expect(getCutContentParams()).toBeUndefined();
expect(popupService.editPopup).not.toBeNull();
});
});

View File

@@ -0,0 +1,69 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocPopupMenu, IDocPopupMenuItem } from '../../services/doc-quick-insert-popup.service';
import { Button, clsx, scrollbarClassName } from '@univerjs/design';
import { IconManager, useDependency } from '@univerjs/ui';
import { useQuickInsertPopup } from '../use-quick-insert-popup';
function MobileQuickInsertItems({ menus, onSelect }: { menus: DocPopupMenu[]; onSelect: (menu: IDocPopupMenuItem) => void }) {
const iconManager = useDependency(IconManager);
return menus.map((menu) => {
const Icon = menu.icon ? iconManager.get(menu.icon) : null;
if ('children' in menu) {
return (
<section key={menu.id} className="univer-grid univer-gap-1 univer-py-2">
<div className="univer-px-3 univer-text-sm univer-font-medium">{menu.title}</div>
<MobileQuickInsertItems menus={menu.children ?? []} onSelect={onSelect} />
</section>
);
}
return (
<Button
key={menu.id}
variant="text"
className="univer-min-h-12 univer-w-full univer-justify-start univer-gap-2 univer-px-3"
onClick={() => onSelect(menu)}
>
{Icon && <Icon />}
<span className="univer-truncate">{menu.title}</span>
</Button>
);
});
}
export function MobileQuickInsertPopup() {
const { filteredMenus, handleMenuSelect, Placeholder } = useQuickInsertPopup();
return (
<div className="univer-mt-1">
{filteredMenus.length > 0
? (
<div
className={clsx(`
univer-box-border univer-max-h-[min(42vh,360px)] univer-w-[min(360px,calc(100vw-24px))]
univer-overflow-y-auto univer-overscroll-contain univer-rounded-2xl univer-bg-gray-0
univer-p-2 univer-shadow-md
dark:!univer-bg-gray-700
`, scrollbarClassName)}
onWheel={(event) => event.stopPropagation()}
>
<MobileQuickInsertItems menus={filteredMenus} onSelect={handleMenuSelect} />
</div>
)
: Placeholder && <Placeholder />}
</div>
);
}

View File

@@ -0,0 +1,85 @@
/**
* 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 { DocPopupMenu, IDocPopupMenuItem } from '../services/doc-quick-insert-popup.service';
import {
ICommandService,
LocaleService,
} from '@univerjs/core';
import { useDependency, useObservable } from '@univerjs/ui';
import { useMemo } from 'react';
import { CloseQuickInsertPopupOperation } from '../commands/operations/quick-insert-popup.operation';
import { DocQuickInsertPopupService } from '../services/doc-quick-insert-popup.service';
import { QuickInsertPlaceholder } from './QuickInsertPlaceholder';
function filterMenusByKeyword(menus: DocPopupMenu[], keyword: string) {
return menus
.map((menu) => ({ ...menu }))
.filter((menu) => {
if ('children' in menu) {
menu.children = filterMenusByKeyword(menu.children!, keyword) as IDocPopupMenuItem[];
return menu.children.length > 0;
}
const keywords = (menu as IDocPopupMenuItem).keywords;
if (keywords) {
return keywords.some((word) => word.includes(keyword));
}
return menu.title.toLowerCase().includes(keyword);
});
}
function translateMenus(menus: DocPopupMenu[], localeService: LocaleService) {
return menus.map((_menu) => {
const menu = { ..._menu } as DocPopupMenu;
if ('children' in menu) {
menu.children = translateMenus(menu.children!, localeService) as IDocPopupMenuItem[];
}
menu.title = localeService.t(menu.title);
if ('keywords' in menu) {
menu.keywords = menu.keywords!
.concat(menu.title)
.map((word) => word.toLowerCase());
}
return menu;
});
}
export function useQuickInsertPopup() {
const localeService = useDependency(LocaleService);
const docQuickInsertPopupService = useDependency(DocQuickInsertPopupService);
const commandService = useDependency(ICommandService);
const locale = useObservable(localeService.currentLocale$);
const filterKeyword = useObservable(docQuickInsertPopupService.filterKeyword$, '');
const currentPopup = useObservable(docQuickInsertPopupService.editPopup$);
const menus = useObservable<DocPopupMenu[]>(currentPopup?.popup.menus$, []);
const filteredMenus = useMemo(
() => filterMenusByKeyword(translateMenus(menus, localeService), filterKeyword.toLowerCase()),
[menus, localeService, locale, filterKeyword]
);
const handleMenuSelect = (menu: IDocPopupMenuItem) => {
docQuickInsertPopupService.emitMenuSelected(menu);
commandService.executeCommand(CloseQuickInsertPopupOperation.id);
};
const Placeholder = currentPopup?.popup.Placeholder || QuickInsertPlaceholder;
return { filteredMenus, handleMenuSelect, Placeholder };
}