fix(ui): align docs and sheets paste controls (#7765)

This commit is contained in:
Univer
2026-09-28 18:40:17 -07:00
committed by GitHub
parent d725a78ec4
commit 8059d7b659
26 changed files with 186 additions and 65 deletions
@@ -22,7 +22,11 @@ import {
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
import { AddDocCommentMenuItemFactory, AddDocDrawingCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu';
import {
AddDocCommentMenuItemFactory,
AddDocDrawingCommentMenuItemFactory,
ToolbarDocCommentMenuItemFactory,
} from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.OTHERS]: {
@@ -49,14 +53,6 @@ export const menuSchema: MenuSchemaType = {
menuItemFactory: AddDocDrawingCommentMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[StartAddCommentOperation.id]: {
order: 1,
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
},
[ContextMenuPosition.DRAWING]: {
[ContextMenuGroup.DATA]: {
[AddDocDrawingCommentOperation.id]: {
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'لصق خاص',
title: 'خيارات اللصق',
source: 'الاحتفاظ بتنسيق المصدر',
destination: 'مطابقة تنسيق الوجهة',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Enganxament especial',
title: 'Opcions d’enganxament',
source: 'Mantén el format d’origen',
destination: 'Adapta al format de destinació',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Inhalte einfügen',
title: 'Einfügeoptionen',
source: 'Ursprüngliche Formatierung beibehalten',
destination: 'Zielformatierung übernehmen',
+1
View File
@@ -17,6 +17,7 @@
const locale = {
'docs-ui': {
pasteOptions: {
special: 'Paste special',
title: 'Paste options',
source: 'Keep source formatting',
destination: 'Match destination formatting',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Pegado especial',
title: 'Opciones de pegado',
source: 'Mantener formato de origen',
destination: 'Adaptar al formato de destino',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'چسباندن ویژه',
title: 'گزینه‌های چسباندن',
source: 'حفظ قالب‌بندی مبدأ',
destination: 'تطبیق با قالب‌بندی مقصد',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Collage spécial',
title: 'Options de collage',
source: 'Conserver la mise en forme source',
destination: 'Adapter à la mise en forme de destination',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Tempel khusus',
title: 'Opsi tempel',
source: 'Pertahankan format sumber',
destination: 'Sesuaikan format tujuan',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Incolla speciale',
title: 'Opzioni di incolla',
source: 'Mantieni formattazione originale',
destination: 'Adatta alla formattazione di destinazione',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: '形式を選択して貼り付け',
title: '貼り付けのオプション',
source: '元の書式を保持',
destination: '貼り付け先の書式に合わせる',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: '선택하여 붙여넣기',
title: '붙여넣기 옵션',
source: '원본 서식 유지',
destination: '대상 서식에 맞추기',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Wklej specjalnie',
title: 'Opcje wklejania',
source: 'Zachowaj formatowanie źródłowe',
destination: 'Dopasuj formatowanie docelowe',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Colar especial',
title: 'Opções de colagem',
source: 'Manter formatação original',
destination: 'Usar formatação de destino',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Специальная вставка',
title: 'Параметры вставки',
source: 'Сохранить исходное форматирование',
destination: 'Использовать форматирование назначения',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Prilepiť špeciálne',
title: 'Možnosti prilepenia',
source: 'Zachovať zdrojové formátovanie',
destination: 'Prispôsobiť cieľovému formátovaniu',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: 'Dán đặc biệt',
title: 'Tùy chọn dán',
source: 'Giữ định dạng nguồn',
destination: 'Khớp định dạng đích',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: '选择性粘贴',
title: '粘贴选项',
source: '保留源格式',
destination: '匹配目标格式',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: '選擇性貼上',
title: '貼上選項',
source: '保留來源格式',
destination: '符合目標格式',
+1
View File
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'docs-ui': {
pasteOptions: {
special: '選擇性貼上',
title: '貼上選項',
source: '保留來源格式',
destination: '符合目的地格式',
@@ -14,83 +14,125 @@
* limitations under the License.
*/
import { Injector, IPermissionService, IUniverInstanceService, PermissionService } from '@univerjs/core';
import { DocSelectionManagerService, setDocumentPermissionValue } from '@univerjs/docs';
import {
IPermissionService,
IUniverInstanceService,
LocaleService,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, setDocumentPermissionValue, UniverDocsPlugin } from '@univerjs/docs';
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { ContextMenuGroup } from '@univerjs/ui';
import { firstValueFrom, of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { ContextMenuGroup, ContextMenuPosition, MenuItemType, RibbonStartGroup } from '@univerjs/ui';
import { firstValueFrom } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { DocPasteCommand, DocPasteSpecialCommand } from '../../commands/commands/clipboard.command';
import { DocSelectAllCommand, DocSelectWordCommand } from '../../commands/commands/doc-select-all.command';
import { DOC_CARET_MENU_ID } from '../../consts/mobile-context';
import enUS from '../../locale/en-US';
import {
CopyMenuFactory,
ParagraphSettingMenuFactory,
PasteMenuFactory,
PasteRibbonMenuFactory,
PasteSpecialMenuFactory,
SelectAllMenuFactory,
SelectWordMenuFactory,
} from '../context-menu';
import { mobileMenuSchema } from '../mobile-schema';
import { menuSchema } from '../schema';
describe('settings context menu factories', () => {
it('registers a mobile caret menu with both paste and format choices', () => {
const instances: Univer[] = [];
afterEach(() => instances.splice(0).forEach((univer) => univer.dispose()));
function createMenuTestBed() {
const univer = new Univer({ locale: LocaleType.EN_US, locales: { [LocaleType.EN_US]: enUS } });
instances.push(univer);
univer.registerPlugin(UniverDocsPlugin);
univer.createUnit(UniverInstanceType.UNIVER_DOC, {
id: 'doc-1',
body: { dataStream: 'Selected text\r\n' },
});
const accessor = univer.__getInjector();
accessor.get(LocaleService).setDirection('ltr');
accessor.get(IUniverInstanceService).setCurrentUnitForType('doc-1');
return accessor;
}
it('keeps ordinary paste in the mobile quick tiles and paste special in a separate menu row', () => {
const caretMenu = Object.entries(mobileMenuSchema).find(([position]) => position === DOC_CARET_MENU_ID)?.[1];
expect(caretMenu).toEqual({
[ContextMenuGroup.QUICK]: {
quickLayout: 'tile',
[DocPasteCommand.id]: { order: 0, menuItemFactory: PasteMenuFactory },
[DocPasteSpecialCommand.id]: { order: 0.5, menuItemFactory: PasteSpecialMenuFactory },
[DocSelectWordCommand.id]: { order: 1, menuItemFactory: SelectWordMenuFactory },
[DocSelectAllCommand.id]: { order: 2, menuItemFactory: SelectAllMenuFactory },
},
[ContextMenuGroup.FORMAT]: {
order: 1,
[DocPasteSpecialCommand.id]: { order: 0, menuItemFactory: PasteSpecialMenuFactory },
},
});
});
it('disables copy without Unit Copy while keeping copy available in read-only mode', async () => {
const permissionService = new PermissionService();
const accessor = new Injector([
[DocSelectionManagerService, {
useValue: {
textSelection$: of({}),
getDocRanges: () => [{ collapsed: false }],
},
}],
[IUniverInstanceService, {
useValue: {
getCurrentTypeOfUnit$: () => of({ getUnitId: () => 'doc-1' }),
},
}],
[IPermissionService, { useValue: permissionService }],
]);
it('places paste special outside desktop quick tiles and uses a standard ribbon selector', () => {
const desktopMenu = Object.entries(menuSchema).find(([position]) => position === ContextMenuPosition.MAIN_AREA)?.[1];
const ribbonMenu = Object.entries(menuSchema).find(([position]) => position === RibbonStartGroup.HISTORY)?.[1];
expect(desktopMenu[ContextMenuGroup.QUICK][DocPasteCommand.id].menuItemFactory).toBe(PasteMenuFactory);
expect(desktopMenu[ContextMenuGroup.QUICK][DocPasteSpecialCommand.id]).toBeUndefined();
expect(desktopMenu[ContextMenuGroup.FORMAT][DocPasteSpecialCommand.id].menuItemFactory).toBe(PasteSpecialMenuFactory);
expect(ribbonMenu[DocPasteSpecialCommand.id].menuItemFactory).toBe(PasteRibbonMenuFactory);
});
it('disables copy without Unit Copy while keeping copy available in read-only mode', () => {
const accessor = createMenuTestBed();
const permissionService = accessor.get(IPermissionService);
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Copy, false);
const copyDisabled$ = CopyMenuFactory(accessor).disabled$;
if (!copyDisabled$) {
throw new Error('Copy menu must expose disabled state.');
}
expect(await firstValueFrom(copyDisabled$)).toBe(true);
const disabled: boolean[] = [];
const subscription = copyDisabled$.subscribe((value) => disabled.push(value));
try {
accessor.get(DocSelectionManagerService).__replaceTextRangesWithNoRefresh({
textRanges: [{ startOffset: 0, endOffset: 8, collapsed: false, isActive: true }],
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: false,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
}, { unitId: 'doc-1', subUnitId: 'doc-1' });
expect(disabled.pop()).toBe(true);
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Copy, true);
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(await firstValueFrom(copyDisabled$)).toBe(false);
accessor.dispose();
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Copy, true);
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(disabled.pop()).toBe(false);
} finally {
subscription.unsubscribe();
}
});
it('disables mutating context-menu actions in read-only mode', async () => {
const permissionService = new PermissionService();
const accessor = new Injector([
[IUniverInstanceService, {
useValue: {
getCurrentTypeOfUnit$: () => of({ getUnitId: () => 'doc-1' }),
},
}],
[IPermissionService, { useValue: permissionService }],
]);
const accessor = createMenuTestBed();
const permissionService = accessor.get(IPermissionService);
setDocumentPermissionValue(permissionService, 'doc-1', 'doc-1', UnitAction.Edit, false);
expect(PasteMenuFactory(accessor).type).toBe(MenuItemType.BUTTON);
expect(PasteRibbonMenuFactory(accessor).type).toBe(MenuItemType.SELECTOR);
const pasteSpecial = PasteSpecialMenuFactory(accessor);
expect(pasteSpecial.type).toBe(MenuItemType.SELECTOR);
expect(pasteSpecial.selections).toEqual([
{ value: 'source', label: 'docs-ui.pasteOptions.source' },
{ value: 'destination', label: 'docs-ui.pasteOptions.destination' },
{ value: 'text', label: 'docs-ui.pasteOptions.text' },
]);
expect(await firstValueFrom(pasteSpecial.disabled$!)).toBe(true);
const pasteDisabled$ = PasteMenuFactory(accessor).disabled$;
const paragraphSettingDisabled$ = ParagraphSettingMenuFactory(accessor).disabled$;
if (!pasteDisabled$ || !paragraphSettingDisabled$) {
@@ -98,7 +140,5 @@ describe('settings context menu factories', () => {
}
expect(await firstValueFrom(pasteDisabled$)).toBe(true);
expect(await firstValueFrom(paragraphSettingDisabled$)).toBe(true);
accessor.dispose();
});
});
+22 -4
View File
@@ -19,11 +19,18 @@ import type { IRectRangeWithStyle } from '@univerjs/engine-render';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { Subscriber } from 'rxjs';
import type { LocaleKey } from '../locale/types';
import { DOC_RANGE_TYPE, DocumentFlavor, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import {
DOC_RANGE_TYPE,
DocumentFlavor,
FOCUSING_SHAPE_TEXT_EDITOR,
IContextService,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { UnitAction } from '@univerjs/protocol';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map, Observable } from 'rxjs';
import { combineLatest, map, Observable, startWith } from 'rxjs';
import {
DocCopyCommand,
DocCutCommand,
@@ -394,10 +401,21 @@ export function PasteSpecialMenuFactory(accessor: IAccessor): IMenuSelectorItem<
id: DocPasteSpecialCommand.id,
type: MenuItemType.SELECTOR,
icon: 'DocPasteOptionsIcon',
title: 'docs-ui.pasteOptions.title',
tooltip: 'docs-ui.pasteOptions.title',
title: 'docs-ui.pasteOptions.special',
tooltip: 'docs-ui.pasteOptions.special',
selections: DOC_PASTE_OPTIONS.map((option) => ({ ...option })),
disabled$: disableMenuWithoutDocumentUnitPermission(accessor, UnitAction.Edit),
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export function PasteRibbonMenuFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
...PasteSpecialMenuFactory(accessor),
title: 'docs-ui.rightClick.paste',
hidden$: combineLatest([
getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
accessor.get(IContextService).subscribeContextValue$(FOCUSING_SHAPE_TEXT_EDITOR).pipe(startWith(false)),
]).pipe(map(([hidden, editing]) => hidden || editing)),
};
}
+7 -4
View File
@@ -35,10 +35,6 @@ export const mobileMenuSchema: MenuSchemaType = {
order: 0,
menuItemFactory: PasteMenuFactory,
},
[DocPasteSpecialCommand.id]: {
order: 0.5,
menuItemFactory: PasteSpecialMenuFactory,
},
[DocSelectWordCommand.id]: {
order: 1,
menuItemFactory: SelectWordMenuFactory,
@@ -48,6 +44,13 @@ export const mobileMenuSchema: MenuSchemaType = {
menuItemFactory: SelectAllMenuFactory,
},
},
[ContextMenuGroup.FORMAT]: {
order: 1,
[DocPasteSpecialCommand.id]: {
order: 0,
menuItemFactory: PasteSpecialMenuFactory,
},
},
},
};
+11 -8
View File
@@ -95,6 +95,7 @@ import {
InsertRowBeforeMenuItemFactory,
ParagraphSettingMenuFactory,
PasteMenuFactory,
PasteRibbonMenuFactory,
PasteSpecialMenuFactory,
SectionSettingMenuFactory,
TABLE_DELETE_MENU_ID,
@@ -226,12 +227,14 @@ export const floatToolbarMenuSchema: MenuSchemaType = {
export const menuSchema: MenuSchemaType = {
...floatToolbarMenuSchema,
[RibbonStartGroup.OTHERS]: {
[RibbonStartGroup.HISTORY]: {
[DocPasteSpecialCommand.id]: {
order: -1,
gridLayout: { row: 1, column: 2, rowSpan: 2, showLabel: true },
menuItemFactory: PasteSpecialMenuFactory,
order: 4,
gridLayout: { row: 1, column: 3, rowSpan: 2, showLabel: true },
menuItemFactory: PasteRibbonMenuFactory,
},
},
[RibbonStartGroup.OTHERS]: {
[OpenDocPermissionPanelOperation.id]: {
order: 0,
gridLayout: { row: 1, column: 1, rowSpan: 2, showLabel: true },
@@ -415,12 +418,12 @@ export const menuSchema: MenuSchemaType = {
order: 2,
menuItemFactory: PasteMenuFactory,
},
[DocPasteSpecialCommand.id]: {
order: 3,
menuItemFactory: PasteSpecialMenuFactory,
},
} as MenuSchemaType),
[ContextMenuGroup.FORMAT]: {
[DocPasteSpecialCommand.id]: {
order: -1,
menuItemFactory: PasteSpecialMenuFactory,
},
[DeleteLeftCommand.id]: {
order: 0,
menuItemFactory: DeleteMenuFactory,
+13
View File
@@ -1058,6 +1058,19 @@ export function PasteSpacialMenuItemFactory(accessor: IAccessor): IMenuSelectorI
};
}
export const PASTE_RIBBON_MENU_ID = 'sheet.menu.paste';
export function PasteRibbonMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
...PasteSpacialMenuItemFactory(accessor),
id: PASTE_RIBBON_MENU_ID,
title: 'sheets-ui.rightClick.paste',
hidden$: combineLatest([
getObservableWithExclusiveRange$(accessor, getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET)),
accessor.get(IContextService).subscribeContextValue$(FOCUSING_SHAPE_TEXT_EDITOR).pipe(startWith(false)),
]).pipe(map(([hidden, editing]) => hidden || editing)),
};
}
export function PasteValueMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey, string> {
return {
id: SheetPasteValueCommand.id,
+29
View File
@@ -173,11 +173,13 @@ import {
HideRowMenuItemFactory,
HorizontalAlignMenuItemFactory,
ItalicMenuItemFactory,
PASTE_RIBBON_MENU_ID,
PASTE_SPECIAL_MENU_ID,
PasteBesidesBorderMenuItemFactory,
PasteColWidthMenuItemFactory,
PasteFormatMenuItemFactory,
PasteMenuItemFactory,
PasteRibbonMenuItemFactory,
PasteSpacialMenuItemFactory,
PasteValueMenuItemFactory,
ResetBackgroundColorMenuItemFactory,
@@ -227,6 +229,33 @@ import { ZOOM_RATIO_MENU_ID, ZoomRatioMenuItemFactory } from './zoom.menu';
export const menuSchema: MenuSchemaType = {
[RibbonPosition.START]: {
[RibbonStartGroup.HISTORY]: {
[PASTE_RIBBON_MENU_ID]: {
order: 4,
gridLayout: { row: 1, column: 3, rowSpan: 2, showLabel: true },
menuItemFactory: PasteRibbonMenuItemFactory,
[SheetPasteCommand.name]: {
order: 0,
menuItemFactory: (accessor) => ({ ...PasteMenuItemFactory(accessor), icon: undefined }),
},
[SheetPasteValueCommand.id]: {
order: 1,
menuItemFactory: PasteValueMenuItemFactory,
},
[SheetPasteFormatCommand.id]: {
order: 2,
menuItemFactory: PasteFormatMenuItemFactory,
},
[SheetPasteColWidthCommand.id]: {
order: 3,
menuItemFactory: PasteColWidthMenuItemFactory,
},
[SheetPasteBesidesBorderCommand.id]: {
order: 4,
menuItemFactory: PasteBesidesBorderMenuItemFactory,
},
},
},
[RibbonStartGroup.FORMAT]: {
[SetRangeFontFamilyCommand.id]: {
order: 1,