mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
feat(docs-ui): support post-paste options in internal editors (#7783)
This commit is contained in:
@@ -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;
|
||||
|
||||
+16
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+20
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user