feat(docs-ui): support post-paste options in internal editors (#7783)

This commit is contained in:
Univer
2026-09-29 15:59:34 -07:00
committed by GitHub
parent 618f9df2cb
commit c3c448f136
10 changed files with 305 additions and 23 deletions
@@ -19,11 +19,12 @@ import { BehaviorSubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../../common/const';
import { Injector } from '../../../common/di';
import { UniverInstanceType } from '../../../common/unit';
import { DocumentDataModel } from '../../../docs/data-model/document-data-model';
import { Univer } from '../../../univer';
import { CommandService, CommandType, ICommandService } from '../../command/command.service';
import { ConfigService, IConfigService } from '../../config/config.service';
import { EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET } from '../../context/context';
import { EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHAPE_TEXT_EDITOR, FOCUSING_SHEET } from '../../context/context';
import { ContextService, IContextService } from '../../context/context.service';
import { IUniverInstanceService } from '../../instance/instance.service';
import { DesktopLogService, ILogService, LogLevel } from '../../log/log.service';
@@ -77,6 +78,64 @@ describe('unit-specific undo redo status', () => {
});
});
describe('shape editor undo routing', () => {
it.each([UniverInstanceType.UNIVER_DOC, UniverInstanceType.UNIVER_SHEET])(
'uses the active shape history and returns to host history after editing in unit type %s',
(hostType) => {
const univer = new Univer();
const injector = univer.__getInjector();
const host = univer.createUnit(hostType, { id: 'host' });
const editor = univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'shape-editor' });
const instances = injector.get(IUniverInstanceService);
const context = injector.get(IContextService);
const history = injector.get(IUndoRedoService);
const commands = injector.get(ICommandService);
const mutations: string[] = [];
commands.registerCommand({
id: MUTATION_ID,
type: CommandType.MUTATION,
handler: (_accessor, params?: { label: string }) => {
mutations.push(params!.label);
return true;
},
});
try {
instances.focusUnit(host.getUnitId());
instances.setCurrentUnitForType(editor.getUnitId());
for (const unitID of [host.getUnitId(), editor.getUnitId(), DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY]) {
history.pushUndoRedo({
unitID,
undoMutations: [{ id: MUTATION_ID, params: { label: `${unitID}-undo` } }],
redoMutations: [{ id: MUTATION_ID, params: { label: `${unitID}-redo` } }],
});
}
context.setContextValue(EDITOR_ACTIVATED, true);
context.setContextValue(FOCUSING_SHAPE_TEXT_EDITOR, true);
// Shape text takes precedence over the sheet editor and formula-bar contexts.
for (const formulaBarFocused of [false, true]) {
context.setContextValue(FOCUSING_FX_BAR_EDITOR, formulaBarFocused);
expect(history.pitchTopUndoElement()?.unitID).toBe(editor.getUnitId());
expect(commands.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(history.pitchTopRedoElement()?.unitID).toBe(editor.getUnitId());
expect(commands.syncExecuteCommand(RedoCommandId)).toBe(true);
}
expect(mutations).toEqual(['shape-editor-undo', 'shape-editor-redo', 'shape-editor-undo', 'shape-editor-redo']);
expect(history.getUndoRedoStatus(host.getUnitId())).toEqual({ undos: 1, redos: 0 });
expect(instances.getFocusedUnit()).toBe(host);
context.setContextValue(EDITOR_ACTIVATED, false);
expect(history.pitchTopUndoElement()?.unitID).toBe(host.getUnitId());
context.setContextValue(FOCUSING_SHAPE_TEXT_EDITOR, false);
expect(commands.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(commands.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(mutations.slice(-2)).toEqual(['host-undo', 'host-redo']);
} finally {
univer.dispose();
}
}
);
});
class FocusedUnit {
constructor(private readonly _unitId: string) {}
@@ -21,10 +21,11 @@ import type { ICommand, IMutationInfo } from '../command/command.service';
import { BehaviorSubject } from 'rxjs';
import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../common/const';
import { createIdentifier } from '../../common/di';
import { UniverInstanceType } from '../../common/unit';
import { Disposable, toDisposable } from '../../shared/lifecycle';
import { CommandType, ICommandService, sequenceExecute } from '../command/command.service';
import { IConfigService } from '../config/config.service';
import { EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHEET } from '../context/context';
import { EDITOR_ACTIVATED, FOCUSING_FX_BAR_EDITOR, FOCUSING_SHAPE_TEXT_EDITOR, FOCUSING_SHEET } from '../context/context';
import { IContextService } from '../context/context.service';
import { IUniverInstanceService } from '../instance/instance.service';
@@ -56,9 +57,9 @@ export interface IUndoRedoService {
*/
beginUndoRedoGroup(unitId: string, groupId: string, mode?: 'replace' | 'append'): IDisposable;
/** Pitch the top redo element of the currently focused Univer document instance. */
/** Read the top undo entry for the focused editor or unit. */
pitchTopUndoElement(): Nullable<IUndoRedoItem>;
/** Pitch the top undo element of the currently focused Univer document instance. */
/** Read the top redo entry for the focused editor or unit. */
pitchTopRedoElement(): Nullable<IUndoRedoItem>;
popUndoToRedo(): void;
@@ -421,8 +422,13 @@ export class LocalUndoRedoService extends Disposable implements IUndoRedoService
const isFocusSheet = this._contextService.getContextValue(FOCUSING_SHEET);
const isFocusFormulaEditor = this._contextService.getContextValue(FOCUSING_FX_BAR_EDITOR);
const isFocusShapeEditor = this._contextService.getContextValue(FOCUSING_SHAPE_TEXT_EDITOR);
const isFocusEditor = this._contextService.getContextValue(EDITOR_ACTIVATED);
if (isFocusEditor && isFocusShapeEditor) {
return this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId() ?? '';
}
if (isFocusSheet) {
if (isFocusFormulaEditor && isFocusEditor) {
unitID = DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY;
@@ -193,6 +193,22 @@ describe('DocDrawingAddRemoveController with real commands and services', () =>
expect(model.getDrawings()!.a.transform).toEqual(original);
});
it('preserves published canvas coordinates when synchronizing updated drawing metadata', () => {
const transform = { left: 320, top: 180, width: 40, height: 30 };
manager.refreshTransform([{ ...drawing('a'), transform }]);
resize();
const rendered = manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'a' });
expect(rendered?.transform).toEqual(transform);
expect(rendered).toMatchObject({ docTransform: { size: { width: 60, height: 45 } } });
expect(model.getDrawings()!.a.transform).not.toEqual(transform);
const resizedTransform = { ...transform, width: 60, height: 45 };
manager.refreshTransform([{ ...drawing('a'), transform: resizedTransform }]);
expect(manager.getDrawingByParam({ unitId, subUnitId: unitId, drawingId: 'a' })?.transform).toEqual(resizedTransform);
});
it('keeps a thousand-drawing document stable during repeated single-image resizing', ({ task }) => {
const extraDrawings = Array.from({ length: 998 }, (_, index) => drawing(`extra-${index}`));
expect(commands.syncExecuteCommand(InsertDocDrawingCommand.id, {
@@ -496,9 +496,12 @@ export class DocDrawingAddRemoveController extends Disposable {
for (const drawingId of synchronizedDrawingIds) {
const current = drawingData[drawingId];
if (current) {
// Layout mutates render transforms. Never share the persisted drawing object or
// replace unrelated drawings whose published positions are still authoritative.
renderedDrawings[drawingId] = { ...current };
// Layout may publish before this listener runs. Preserve its canvas coordinates;
// persisted transforms do not include the document's page and anchor offsets.
renderedDrawings[drawingId] = {
...current,
transform: renderedDrawings[drawingId]?.transform ?? current.transform,
};
} else {
delete renderedDrawings[drawingId];
}
@@ -31,22 +31,26 @@ import {
validateDocumentStructure,
} from '@univerjs/core';
import {
DocLayoutExecutorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateChangeManagerService,
DocStateEmitService,
InsertTextCommand,
RichTextEditingMutation,
SetTextSelectionsOperation,
} from '@univerjs/docs';
import {
CanvasColorService,
DocumentViewModel,
ICanvasColorService,
IRenderManagerService,
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
RenderManagerService,
} from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createInternalClipboardDocData } from '../../../services/clipboard/internal-fragment';
import { getCutActionsFromDocRanges, InnerPasteCommand } from '../clipboard.inner.command';
import { CutContentCommand, getCutActionsFromDocRanges, InnerPasteCommand } from '../clipboard.inner.command';
describe('getCutActionsFromDocRanges', () => {
it.each([false, true])('keeps formatting only for embedded-editor clearing (%s), with reversible actions', (preserveFormatting) => {
@@ -227,3 +231,67 @@ describe('InnerPasteCommand', () => {
expect(document.getSnapshot()).toEqual(before);
});
});
describe('CutContentCommand whole-document selection', () => {
it('collapses the caret at zero and preserves selection through undo/redo when the last paragraph was active', async () => {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocLayoutExecutorService]);
injector.get(DocStateChangeManagerService);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'clear-selection',
body: {
dataStream: 'First\rLast\r\n',
paragraphs: [{ paragraphId: 'first', startIndex: 5 }, { paragraphId: 'last', startIndex: 10 }],
customBlocks: [],
customRanges: [],
customDecorations: [],
},
});
const renderManager = injector.get(IRenderManagerService);
renderManager.registerRenderModule(UniverInstanceType.UNIVER_DOC, [DocSkeletonManagerService]);
renderManager.createRender(doc.getUnitId());
const commands = injector.get(ICommandService);
[CutContentCommand, RichTextEditingMutation, SetTextSelectionsOperation].forEach((command) => commands.registerCommand(command));
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: doc.getUnitId(), subUnitId: doc.getUnitId() });
const ranges = [
{ startOffset: 0, endOffset: 6, collapsed: false, isActive: false },
{ startOffset: 6, endOffset: 11, collapsed: false, isActive: true },
];
selections.replaceSelectionInfoWithoutRefresh({
textRanges: ranges,
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
options: { wholeDocument: true },
});
try {
const before = structuredClone(doc.getBody());
expect(await commands.executeCommand(CutContentCommand.id, {
segmentId: '',
selections: [ranges[1]],
textRanges: [{ startOffset: 6, endOffset: 6, collapsed: true }],
})).toBe(true);
expect(doc.getBody()?.dataStream).toBe('\r\n');
expect(selections.getActiveTextRange()).toMatchObject({ startOffset: 0, endOffset: 0, collapsed: true });
injector.get(DocStateChangeManagerService).flushPendingChanges(doc.getUnitId());
expect(await commands.executeCommand(UndoCommand.id)).toBe(true);
expect(doc.getBody()).toEqual(before);
expect(selections.getTextRanges()?.map(({ startOffset, endOffset }) => [startOffset, endOffset])).toEqual([[0, 6], [6, 11]]);
expect(await commands.executeCommand(RedoCommand.id)).toBe(true);
expect(doc.getBody()?.dataStream).toBe('\r\n');
expect(selections.getActiveTextRange()).toMatchObject({ startOffset: 0, endOffset: 0, collapsed: true });
} finally {
univer.dispose();
}
});
});
@@ -59,7 +59,6 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition } from '../../basics/paragraph';
import {
IDocClipboardPasteAdapterService,
@@ -942,13 +941,19 @@ export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
}
const viewModel = docSkeletonManagerService.getViewModel();
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const clearsBody = wholeBodySelected || (body != null && isWholeBodySelected(selections, rectRanges, body));
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
// Whole-document selection can contain several ranges with the last one active.
// Clearing the body must place the caret at its new start, not that last range.
textRanges: clearsBody
? [{ ...textRanges[0], startOffset: 0, endOffset: 0, collapsed: true }]
: textRanges,
trigger: CutContentCommand.id,
},
};
@@ -16,7 +16,7 @@
import type { DocumentDataModel, IDisposable } from '@univerjs/core';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, IContextService, Inject, isInternalEditorID } from '@univerjs/core';
import { Disposable, IContextService, Inject } from '@univerjs/core';
import { MOBILE_UI_MODE } from '@univerjs/ui';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { DocCanvasPopManagerService } from '../../services/doc-popup-manager.service';
@@ -34,11 +34,11 @@ export class DocPasteOptionsRenderController extends Disposable implements IRend
@IContextService contextService: IContextService
) {
super();
if (isInternalEditorID(_context.unitId)) {
return;
}
const mobile = contextService.getContextValue(MOBILE_UI_MODE) === true;
this.disposeWithMe(selection.onKeydown$.subscribe(({ event }) => {
if (!this._clipboard.isPasteOptionsEnabled(this._context.unitId)) {
return;
}
const key = event as KeyboardEvent;
if ((key.ctrlKey || key.metaKey) && key.shiftKey && key.key.toLowerCase() === 'v') {
this._clipboard.setNextPasteMode('text');
@@ -47,8 +47,15 @@ export class DocPasteOptionsRenderController extends Disposable implements IRend
this._clipboard.dismissPasteOptions();
}
}));
this.disposeWithMe(selection.onInputBefore$.subscribe(() => this._clipboard.dismissPasteOptions()));
this.disposeWithMe(selection.onInputBefore$.subscribe(() => {
if (this._clipboard.isPasteOptionsEnabled(this._context.unitId)) {
this._clipboard.dismissPasteOptions();
}
}));
this.disposeWithMe(selection.onPointerDown$.subscribe(() => {
if (!this._clipboard.isPasteOptionsEnabled(this._context.unitId)) {
return;
}
this._clipboard.setNextPasteMode('source');
// A mobile pointer-down may start a pan. Actual selection changes
// already invalidate the session in the clipboard service.
@@ -56,7 +63,11 @@ export class DocPasteOptionsRenderController extends Disposable implements IRend
this._clipboard.dismissPasteOptions();
}
}));
this.disposeWithMe(selection.onBlur$.subscribe(() => this._clipboard.setNextPasteMode('source')));
this.disposeWithMe(selection.onBlur$.subscribe(() => {
if (this._clipboard.isPasteOptionsEnabled(this._context.unitId)) {
this._clipboard.setNextPasteMode('source');
}
}));
this.disposeWithMe(this._clipboard.pasteOptions$.subscribe((state) => {
this._popup?.dispose();
this._popup = undefined;
@@ -76,7 +87,9 @@ export class DocPasteOptionsRenderController extends Disposable implements IRend
}));
this.disposeWithMe(() => {
this._popup?.dispose();
this._clipboard.dismissPasteOptions();
if (this._popup) {
this._clipboard.dismissPasteOptions();
}
});
}
}
@@ -19,11 +19,15 @@ import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-
import type { IDocClipboardPasteAdapter } from '../doc-paste-mutation-adapter.service';
import {
BooleanNumber,
createInternalEditorID,
DataStreamTreeTokenType,
DOC_RANGE_TYPE,
DocumentBlockRangeType,
DocumentDataModel,
EDITOR_ACTIVATED,
FOCUSING_SHAPE_TEXT_EDITOR,
ICommandService,
IContextService,
ImageSourceType,
IPermissionService,
IUndoRedoService,
@@ -32,6 +36,7 @@ import {
SliceBodyType,
Tools,
UndoCommand,
Univer,
UniverInstanceType,
validateDocBodyStructure,
} from '@univerjs/core';
@@ -41,12 +46,16 @@ import {
RichTextEditingMutation,
setDocumentPermissionValue,
SetTextSelectionsOperation,
UniverDocsPlugin,
} from '@univerjs/docs';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { IClipboardInterfaceService } from '@univerjs/ui';
import { firstValueFrom } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { createCommandTestBed } from '../../../commands/commands/__tests__/create-command-test-bed';
import { CutContentCommand, InnerPasteCommand } from '../../../commands/commands/clipboard.inner.command';
import { DocMenuStyleService } from '../../doc-menu-style.service';
import {
convertClipboardHtmlToDocumentData,
DocClipboardService,
@@ -64,6 +73,7 @@ import {
DOC_INTERNAL_FRAGMENT_MIME,
parseInternalClipboardFragment,
} from '../internal-fragment';
import { DocHtmlExportService } from '../udm-to-html/doc-html-export.service';
class TestClipboardInterfaceService {
readonly writes: Array<{ text: string; html: string; custom?: Record<string, string> }> = [];
@@ -1927,3 +1937,76 @@ describe('DocClipboardService paste options', () => {
}
});
});
describe('internal editor paste options', () => {
it('opts in only the active editor, switches its own history, and clears options when editing ends', async () => {
const univer = new Univer();
univer.registerPlugin(UniverDocsPlugin);
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocMenuStyleService]);
injector.add([DocHtmlExportService]);
injector.add([IClipboardInterfaceService, { useClass: TestClipboardInterfaceService }]);
injector.add([IDocClipboardService, { useClass: DocClipboardService }]);
const host = univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'host', body: { dataStream: 'Host\r\n' } });
const unitId = createInternalEditorID('shape-paste');
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: unitId,
body: { dataStream: 'Before\r\n', paragraphs: [{ startIndex: 6, paragraphId: 'target' }], customBlocks: [] },
documentStyle: {},
});
const instanceService = injector.get(IUniverInstanceService);
instanceService.focusUnit(host.getUnitId());
instanceService.setCurrentUnitForType(unitId);
const context = injector.get(IContextService);
context.setContextValue(EDITOR_ACTIVATED, true);
context.setContextValue(FOCUSING_SHAPE_TEXT_EDITOR, true);
const selection = injector.get(DocSelectionManagerService);
selection.__TEST_ONLY_setCurrentSelection({ unitId, subUnitId: '' });
selection.__TEST_ONLY_add([{ startOffset: 6, endOffset: 6, collapsed: true, isActive: true, segmentId: '' }]);
const commands = injector.get(ICommandService);
commands.registerCommand(InnerPasteCommand);
const clipboard = injector.get(IDocClipboardService);
const history = injector.get(IUndoRedoService);
const payload = { unitId, files: [], html: '<b>Rich</b>', text: 'Rich' };
try {
expect(clipboard.isPasteOptionsEnabled(unitId)).toBe(false);
expect(await clipboard.legacyPaste(payload)).toBe(true);
expect(await firstValueFrom(clipboard.pasteOptions$)).toBeNull();
const hook = clipboard.addClipboardHook({ supportsPasteOptions: (id) => id === unitId });
expect(clipboard.isPasteOptionsEnabled(createInternalEditorID('other'))).toBe(false);
const before = Tools.deepClone(doc.getBody());
expect(await clipboard.legacyPaste(payload)).toBe(true);
expect(await firstValueFrom(clipboard.pasteOptions$)).toMatchObject({ unitId, mode: 'source' });
const source = Tools.deepClone(doc.getBody());
commands.syncExecuteCommand(RichTextEditingMutation.id, {
unitId: host.getUnitId(),
actions: [],
textRanges: null,
isSync: true,
});
expect(await firstValueFrom(clipboard.pasteOptions$)).toMatchObject({ unitId, mode: 'source' });
expect(await clipboard.changePasteMode('text')).toBe(true);
expect(doc.getBody()?.dataStream).toBe(source?.dataStream);
expect(await clipboard.changePasteMode('destination')).toBe(true);
expect(await clipboard.changePasteMode('source')).toBe(true);
expect(doc.getBody()).toEqual(source);
expect(history.getUndoRedoStatus(unitId).undos).toBe(2);
expect(history.getUndoRedoStatus(host.getUnitId()).undos).toBe(0);
expect(instanceService.getFocusedUnit()).toBe(host);
expect(commands.syncExecuteCommand(UndoCommand.id)).toBe(true);
expect(doc.getBody()).toEqual(before);
expect(commands.syncExecuteCommand(RedoCommand.id)).toBe(true);
expect(doc.getBody()).toEqual(source);
expect(await clipboard.legacyPaste(payload)).toBe(true);
context.setContextValue(FOCUSING_SHAPE_TEXT_EDITOR, false);
context.setContextValue(EDITOR_ACTIVATED, false);
hook.dispose();
expect(await firstValueFrom(clipboard.pasteOptions$)).toBeNull();
expect(await clipboard.changePasteMode('text')).toBe(false);
expect(clipboard.isPasteOptionsEnabled(unitId)).toBe(false);
} finally {
univer.dispose();
}
});
});
@@ -196,6 +196,8 @@ export interface IDocClipboardPasteContext {
}
export interface IDocClipboardHook {
/** Opt an internal rich-text editor into the post-paste format menu. */
supportsPasteOptions?(unitId: string): boolean;
/** Supply a rich fragment for selections owned by a plugin rather than native text ranges. */
onCopySelection?(unitId: string): { documentData: IDocumentData; plainText: string } | undefined;
onCopyDocData?(doc: Partial<IDocumentData>, context: IDocClipboardCopyDocDataContext): Partial<IDocumentData>;
@@ -235,6 +237,7 @@ export interface IDocClipboardService {
pasteFromClipboard(mode?: DocPasteMode): Promise<boolean>;
legacyPaste(options: IDocClipboardPayload): Promise<boolean>;
readonly pasteOptions$: Observable<IDocPasteOptionsState | null>;
isPasteOptionsEnabled(unitId: string): boolean;
setNextPasteMode(mode: DocPasteMode): void;
dismissPasteOptions(): void;
changePasteMode(mode: DocPasteMode): Promise<boolean>;
@@ -323,7 +326,12 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
super();
this._umdToHtml = new UDMToHtmlService(docHtmlExportService);
this.disposeWithMe(this._commandService.beforeCommandExecuted((command) => {
if (!this._applyingPaste && command.type === CommandType.MUTATION) {
if (this._applyingPaste || command.type !== CommandType.MUTATION) {
return;
}
const unitId = (command.params as { unitId?: string } | undefined)?.unitId;
const pasteUnitId = this._pasteOptions$.value?.unitId ?? this._getCurrentDocumentUnitId();
if (!unitId || unitId === pasteUnitId) {
this.dismissPasteOptions();
}
}));
@@ -433,6 +441,10 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
return this.legacyPaste({ ...payload, mode });
}
isPasteOptionsEnabled(unitId: string): boolean {
return !isInternalEditorID(unitId) || this._clipboardHooks.some((hook) => hook.supportsPasteOptions?.(unitId));
}
setNextPasteMode(mode: DocPasteMode): void {
this._nextPasteMode = mode;
}
@@ -554,7 +566,7 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
// Keep preceding debounced typing out of the paste's undo entry.
this._stateChangeManager.flushPendingChanges(unitId);
const result = this._paste(doc, unitId);
if (result && !isInternalEditorID(unitId)) {
if (result && this.isPasteOptionsEnabled(unitId)) {
this._savePasteSession(payload, mode === 'text' ? cachedSource : source, style, mode, unitId);
}
return result;
@@ -894,6 +906,10 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
if (index > -1) {
this._clipboardHooks.splice(index, 1);
const state = this._pasteOptions$.value;
if (state && !this.isPasteOptionsEnabled(state.unitId)) {
this.dismissPasteOptions();
}
}
});
}
+16 -3
View File
@@ -24,6 +24,7 @@ import { useState } from 'react';
import { DocChangePasteModeCommand } from '../commands/commands/clipboard.command';
import { IDocClipboardService } from '../services/clipboard/clipboard.service';
import { DOC_PASTE_OPTIONS } from '../services/clipboard/paste-options';
import { IEditorService } from '../services/editor/editor-manager.service';
export const DOC_PASTE_OPTIONS_COMPONENT = 'docs-ui.paste-options';
@@ -32,17 +33,27 @@ export function DocPasteOptions() {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const layoutService = useDependency(ILayoutService);
const editorService = useDependency(IEditorService);
const state = useObservable(clipboard.pasteOptions$, null);
const [busy, setBusy] = useState(false);
const [open, setOpen] = useState(false);
const focusEditor = () => {
const editor = state && clipboard.isPasteOptionsEnabled(state.unitId) && editorService.getEditor(state.unitId);
if (editor) {
editor.focus();
} else {
layoutService.focus();
}
};
const changeMode = async (value: DocPasteMode) => {
setBusy(true);
try {
await commandService.executeCommand(DocChangePasteModeCommand.id, { value });
} finally {
setBusy(false);
layoutService.focus();
focusEditor();
}
};
@@ -52,12 +63,13 @@ export function DocPasteOptions() {
return (
<DropdownMenu
data-u-editor-interaction-boundary="true"
align="start"
open={open}
onOpenChange={setOpen}
onCloseAutoFocus={(event) => {
event.preventDefault();
layoutService.focus();
focusEditor();
}}
onEscapeKeyDown={() => clipboard.dismissPasteOptions()}
items={DOC_PASTE_OPTIONS.map((option) => ({
@@ -70,6 +82,7 @@ export function DocPasteOptions() {
}))}
>
<Button
data-u-editor-interaction-boundary="true"
size="small"
disabled={busy}
aria-label={localeService.t<LocaleKey>('docs-ui.pasteOptions.title')}
@@ -78,7 +91,7 @@ export function DocPasteOptions() {
onKeyDown={(event) => {
if (event.key === 'Escape') {
clipboard.dismissPasteOptions();
layoutService.focus();
focusEditor();
}
}}
className="univer-shadow-sm"