fix(docs): preserve floating drawings during structural edits (#7541)

This commit is contained in:
Univer
2026-08-18 00:45:46 +08:00
committed by GitHub
parent 7b9b8fb0ef
commit e84e978ef7
10 changed files with 373 additions and 99 deletions
@@ -15,13 +15,72 @@
*/
import { describe, expect, it } from 'vitest';
import { PositionedObjectLayoutType } from '../../../../../types/interfaces/i-document-data';
import { DrawingTypeEnum } from '../../../../../types/interfaces/i-drawing';
import { DocumentDataModel } from '../../../document-data-model';
import { JSONX } from '../../../json-x/json-x';
import { DataStreamTreeTokenType } from '../../../types';
import { getRichTextEditPath } from '../../utils';
import { addDrawing, getCustomBlockIdsInSelections } from '../drawings';
import { addDrawing, getCustomBlockIdsInSelections, removeDrawingReferences } from '../drawings';
describe('drawing build utils', () => {
it('removes drawing references in reverse order for a structural text deletion', () => {
const doc = new DocumentDataModel({
id: 'doc-remove-drawings',
body: {
dataStream: '\bA\bB\b\r\n',
customBlocks: [
{ startIndex: 0, blockId: 'drawing-1' },
{ startIndex: 2, blockId: 'drawing-2' },
{ startIndex: 4, blockId: 'stale-drawing' },
],
},
drawings: {
'drawing-1': {
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
docTransform: {
angle: 0,
positionH: { posOffset: 0, relativeFrom: 0 },
positionV: { posOffset: 0, relativeFrom: 0 },
size: { height: 40, width: 80 },
},
layoutType: PositionedObjectLayoutType.INLINE,
subUnitId: 'doc-remove-drawings',
unitId: 'doc-remove-drawings',
},
'drawing-2': {
drawingId: 'drawing-2',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
docTransform: {
angle: 0,
positionH: { posOffset: 0, relativeFrom: 0 },
positionV: { posOffset: 0, relativeFrom: 0 },
size: { height: 40, width: 80 },
},
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
subUnitId: 'doc-remove-drawings',
unitId: 'doc-remove-drawings',
},
},
drawingsOrder: ['drawing-1', 'stale-drawing', 'drawing-2'],
});
const actions = removeDrawingReferences(doc.getSnapshot(), [
{ startOffset: 0, endOffset: 5, collapsed: false },
{ startOffset: 2, endOffset: 5, collapsed: false },
]);
let composedActions = actions[0];
for (const action of actions.slice(1)) {
composedActions = JSONX.compose(composedActions, action);
}
if (!composedActions) throw new Error('Expected drawing removal actions');
doc.apply(composedActions);
expect(doc.getDrawings()).toEqual({});
expect(doc.getDrawingsOrder()).toEqual([]);
});
it('anchors a drawing in the current table-cell paragraph at the cell structural tail', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
@@ -15,7 +15,7 @@
*/
import type { ITextRange, ITextRangeParam } from '../../../../sheets/typedef';
import type { IDocumentBody, IDrawingParam } from '../../../../types/interfaces';
import type { IDocumentBody, IDocumentData, IDrawingParam } from '../../../../types/interfaces';
import type { DocumentDataModel } from '../../document-data-model';
import type { JSONXActions } from '../../json-x/json-x';
import { createParagraphId } from '../../../paragraph-id';
@@ -55,6 +55,43 @@ export function getCustomBlockIdsInSelections(body: IDocumentBody, selections: I
return customBlockIds;
}
export function removeDrawingReferences(
documentData: Pick<IDocumentData, 'body' | 'drawings' | 'drawingsOrder'>,
selections: ITextRange[],
body: IDocumentBody | undefined = documentData.body
): JSONXActions[] {
if (!body) {
return [];
}
const drawings = documentData.drawings ?? {};
const drawingOrder = documentData.drawingsOrder ?? [];
const blockIds = [...new Set(getCustomBlockIdsInSelections(body, selections))]
.sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
const jsonX = JSONX.getInstance();
const actions: JSONXActions[] = [];
for (const blockId of blockIds) {
const drawing = drawings[blockId];
if (drawing != null) {
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
if (removeDrawingAction) {
actions.push(removeDrawingAction);
}
}
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawingIndex >= 0) {
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
if (removeDrawingOrderAction) {
actions.push(removeDrawingOrderAction);
}
}
}
return actions;
}
// eslint-disable-next-line max-lines-per-function
export const addDrawing = (param: IAddDrawingParam) => {
const { selection, documentDataModel, drawings } = param;
@@ -16,7 +16,7 @@
import { addCustomDecorationTextX, deleteCustomDecorationTextX } from './custom-decoration';
import { copyCustomRange, getCustomRangesInterestsWithSelection, isIntersecting } from './custom-range';
import { addDrawing } from './drawings';
import { addDrawing, removeDrawingReferences } from './drawings';
import {
changeParagraphBulletNestLevel,
setParagraphBullet,
@@ -96,6 +96,7 @@ export class BuildTextUtils {
static drawing = {
add: addDrawing,
remove: removeDrawingReferences,
};
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DeleteDirection, getRichTextEditPath, ICommandService, JSONX, TextXActionType } from '@univerjs/core';
import { BlockType, DeleteDirection, DrawingTypeEnum, getRichTextEditPath, ICommandService, IUndoRedoService, JSONX, PositionedObjectLayoutType, TextXActionType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTestBed } from '../../../facade/__tests__/create-test-bed';
import { RichTextEditingMutation } from '../../mutations/core-editing.mutation';
@@ -48,6 +48,62 @@ describe('core editing commands', () => {
expect(testBed.univerAPI.getActiveDocument()?.save().body?.dataStream).toBe('Hello\r\n');
});
it('deletes a drawing with backspace and restores its references through history', () => {
testBed.univer.dispose();
testBed = createTestBed({
id: 'test',
body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-drawing' }],
customBlocks: [{ blockId: 'drawing-1', blockType: BlockType.DRAWING, startIndex: 0 }],
},
drawings: {
'drawing-1': {
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
docTransform: {
angle: 0,
positionH: { posOffset: 0, relativeFrom: 0 },
positionV: { posOffset: 0, relativeFrom: 0 },
size: { height: 40, width: 80 },
},
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
subUnitId: 'test',
unitId: 'test',
},
},
drawingsOrder: ['drawing-1'],
documentStyle: {},
});
commandService = testBed.get(ICommandService);
testBed.get(IUndoRedoService);
expect(commandService.syncExecuteCommand(DeleteTextCommand.id, {
unitId: 'test',
range: { startOffset: 1, endOffset: 1, collapsed: true },
direction: DeleteDirection.LEFT,
})).toBe(true);
expect(testBed.doc.getSnapshot()).toMatchObject({
body: { customBlocks: [] },
drawings: {},
drawingsOrder: [],
});
expect(testBed.univerAPI.getActiveDocument()?.undo()).toBe(true);
expect(testBed.doc.getSnapshot()).toMatchObject({
body: { customBlocks: [{ blockId: 'drawing-1', startIndex: 0 }] },
drawings: { 'drawing-1': { drawingId: 'drawing-1' } },
drawingsOrder: ['drawing-1'],
});
expect(testBed.univerAPI.getActiveDocument()?.redo()).toBe(true);
expect(testBed.doc.getSnapshot()).toMatchObject({
body: { customBlocks: [] },
drawings: {},
drawingsOrder: [],
});
});
it('does not delete the document minimum paragraph sentinel', () => {
const result = commandService.syncExecuteCommand(DeleteTextCommand.id, {
unitId: 'test',
@@ -14,15 +14,7 @@
* limitations under the License.
*/
import type {
DocumentDataModel,
ICommand,
IDocumentBody,
IDocumentData,
IMutationInfo,
ITextRange,
UpdateDocsAttributeType,
} from '@univerjs/core';
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, IMutationInfo, ITextRange, Nullable, UpdateDocsAttributeType } from '@univerjs/core';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
import {
@@ -46,6 +38,10 @@ export interface IInsertTextCommandParams {
range: ITextRange;
segmentId?: string;
cursorOffset?: number;
debounce?: boolean;
textRanges?: Nullable<ITextRangeWithStyle[]>;
noNeedSetTextRange?: boolean;
isEditing?: boolean;
}
/**
@@ -56,7 +52,17 @@ export const InsertTextCommand: ICommand<IInsertTextCommandParams> = {
type: CommandType.COMMAND,
handler: (accessor, params: IInsertTextCommandParams) => {
const commandService = accessor.get(ICommandService);
const { range, segmentId, body, unitId, cursorOffset } = params;
const {
range,
segmentId,
body,
unitId,
cursorOffset,
debounce = true,
textRanges,
noNeedSetTextRange,
isEditing,
} = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
@@ -67,7 +73,7 @@ export const InsertTextCommand: ICommand<IInsertTextCommandParams> = {
}
const activeRange = docSelectionManagerService.getActiveTextRange();
const rangeSegmentId = 'segmentId' in range ? (range as ITextRange & { segmentId?: string }).segmentId : undefined;
const rangeSegmentId = 'segmentId' in range && typeof range.segmentId === 'string' ? range.segmentId : undefined;
const targetSegmentId = segmentId ?? rangeSegmentId ?? activeRange?.segmentId ?? '';
const originBody = docDataModel.getSelfOrHeaderFooterModel(targetSegmentId)?.getBody();
@@ -77,54 +83,26 @@ export const InsertTextCommand: ICommand<IInsertTextCommandParams> = {
const { startOffset, collapsed } = range;
const cursorMove = cursorOffset ?? body.dataStream.length;
const textRanges = [
{
startOffset: startOffset + cursorMove,
endOffset: startOffset + cursorMove,
style: activeRange?.style,
collapsed,
},
];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
debounce: true,
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (collapsed) {
if (startOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: startOffset,
});
}
textX.push({
t: TextXActionType.INSERT,
body,
len: body.dataStream.length,
});
} else {
const dos = BuildTextUtils.selection.delete([range], originBody, 0, body);
textX.push(...dos);
}
doMutation.params.textRanges = [{
const mutationTextRanges = textRanges ?? [{
startOffset: startOffset + cursorMove,
endOffset: startOffset + cursorMove,
collapsed,
}];
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId: targetSegmentId,
actions: [],
textRanges: mutationTextRanges,
debounce,
noNeedSetTextRange,
isEditing,
},
};
doMutation.params.actions = buildInsertTextActions(docDataModel, originBody, range, body, targetSegmentId);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
@@ -135,6 +113,47 @@ export const InsertTextCommand: ICommand<IInsertTextCommandParams> = {
},
};
function buildInsertTextActions(
documentDataModel: DocumentDataModel,
originBody: IDocumentBody,
range: ITextRange,
insertBody: IDocumentBody,
segmentId?: string
): IRichTextEditingMutationParams['actions'] {
const { startOffset, endOffset, collapsed } = range;
const textX = new TextX();
if (collapsed) {
if (startOffset > 0) {
textX.push({ t: TextXActionType.RETAIN, len: startOffset });
}
textX.push({ t: TextXActionType.INSERT, body: insertBody, len: insertBody.dataStream.length });
} else {
textX.push(...BuildTextUtils.selection.delete([range], originBody, 0, insertBody));
}
const textActions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId));
return collapsed
? textActions
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset);
}
function appendRemovedDrawingActions(
textActions: IRichTextEditingMutationParams['actions'],
documentDataModel: DocumentDataModel,
body: IDocumentBody,
startOffset: number,
endOffset: number
): IRichTextEditingMutationParams['actions'] {
const drawingActions = BuildTextUtils.drawing.remove(documentDataModel.getSnapshot(), [{
startOffset,
endOffset,
collapsed: false,
}], body);
const rawActions = [textActions, ...drawingActions];
return rawActions.reduce((accumulator, action) => JSONX.compose(accumulator, action));
}
export interface IDeleteTextCommandParams {
unitId: string;
range: ITextRange;
@@ -175,6 +194,7 @@ export const DeleteTextCommand: ICommand<IDeleteTextCommandParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId,
actions: [],
textRanges: [{
startOffset: start,
@@ -196,7 +216,13 @@ export const DeleteTextCommand: ICommand<IDeleteTextCommandParams> = {
}], body));
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
doMutation.params.actions = appendRemovedDrawingActions(
jsonX.editOp(textX.serialize(), path),
docDataModel,
body,
start,
end + 1
);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
@@ -16,9 +16,9 @@
import type { IDocumentData, Univer } from '@univerjs/core';
import type { FDocument } from '../f-document';
import { ColumnSeparatorType, DataStreamTreeTokenType, DocumentFlavor, ICommandService, IResourceManagerService, IUndoRedoService, PageOrientType, SectionType, UniverInstanceType } from '@univerjs/core';
import { InsertTextCommand } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BlockType, ColumnSeparatorType, DataStreamTreeTokenType, DocumentFlavor, DrawingTypeEnum, ICommandService, IResourceManagerService, IUndoRedoService, PageOrientType, PositionedObjectLayoutType, SectionType, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, InsertTextCommand } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createDocumentData, createSimpleDocument, createTestBed } from './create-test-bed';
describe('FDocument', () => {
@@ -187,6 +187,90 @@ describe('FDocument', () => {
expect(document.getParagraphs()[0].getText()).toBe('Document title suffix');
});
it('deletes drawing references atomically without changing the interactive selection', async () => {
univer.dispose();
const data = createDocumentData('drawing-delete-doc', {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'para-drawing' }],
customBlocks: [{ blockId: 'drawing-1', blockType: BlockType.DRAWING, startIndex: 0 }],
});
data.drawings = { 'drawing-1': createDrawingData('drawing-delete-doc', 'drawing-1') };
data.drawingsOrder = ['drawing-1'];
createDocumentFacade(data);
get(IUndoRedoService);
const replaceDocRanges = vi.spyOn(get(DocSelectionManagerService), 'replaceDocRanges');
expect(document.deleteRange({ startOffset: 0, endOffset: 1 })).toBe(true);
await Promise.resolve();
expect(replaceDocRanges).not.toHaveBeenCalled();
expect(document.save()).toMatchObject({
body: { customBlocks: [] },
drawings: {},
drawingsOrder: [],
});
expect(document.undo()).toBe(true);
expect(document.save()).toMatchObject({
body: { customBlocks: [{ blockId: 'drawing-1', startIndex: 0 }] },
drawings: { 'drawing-1': { drawingId: 'drawing-1' } },
drawingsOrder: ['drawing-1'],
});
expect(document.redo()).toBe(true);
expect(document.save()).toMatchObject({
body: { customBlocks: [] },
drawings: {},
drawingsOrder: [],
});
});
it('deletes header drawing references in the header segment history', () => {
univer.dispose();
const data = createDocumentData('header-drawing-delete-doc', {
dataStream: 'Body\r\n',
paragraphs: [{ startIndex: 4, paragraphId: 'body-paragraph' }],
});
data.headers = {
'header-drawing': {
headerId: 'header-drawing',
body: {
dataStream: '\b\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'header-paragraph' }],
customBlocks: [{ blockId: 'header-shape', blockType: BlockType.DRAWING, startIndex: 0 }],
},
},
};
data.drawings = { 'header-shape': createDrawingData(data.id, 'header-shape') };
data.drawingsOrder = ['header-shape'];
createDocumentFacade(data);
get(IUndoRedoService);
expect(document.deleteRange({ startOffset: 0, endOffset: 1, segmentId: 'header-drawing' })).toBe(true);
expect(document.save()).toMatchObject({
headers: { 'header-drawing': { body: { customBlocks: [] } } },
drawings: {},
drawingsOrder: [],
});
expect(document.undo()).toBe(true);
expect(document.save()).toMatchObject({
headers: {
'header-drawing': {
body: { customBlocks: [{ blockId: 'header-shape', startIndex: 0 }] },
},
},
drawings: { 'header-shape': { drawingId: 'header-shape' } },
drawingsOrder: ['header-shape'],
});
expect(document.redo()).toBe(true);
expect(document.save()).toMatchObject({
headers: { 'header-drawing': { body: { customBlocks: [] } } },
drawings: {},
drawingsOrder: [],
});
});
it('ensures header and footer segments independently', () => {
univer.dispose();
const documentData = createDocumentData('classic-doc', {
@@ -542,3 +626,19 @@ describe('FDocument', () => {
expect(rule?.getInfo().paragraph.paragraphStyle?.spaceBelow).toEqual({ v: 10 });
});
});
function createDrawingData(unitId: string, drawingId: string) {
return {
drawingId,
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
docTransform: {
angle: 0,
positionH: { posOffset: 0, relativeFrom: 0 },
positionV: { posOffset: 0, relativeFrom: 0 },
size: { height: 40, width: 80 },
},
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
subUnitId: unitId,
unitId,
};
}
@@ -294,7 +294,7 @@ export class FDocumentParagraph extends FBaseInitialable {
},
buildPlainTextInsertBody(text),
this._document.getDocumentDataModel(),
this._injector
this._commandService
);
}
@@ -17,7 +17,7 @@
import type { Injector, ITextStyle } from '@univerjs/core';
import type { FDocument } from './f-document';
import type { IFDocumentTextRange } from './utils';
import { Tools, UpdateDocsAttributeType } from '@univerjs/core';
import { ICommandService, Tools, UpdateDocsAttributeType } from '@univerjs/core';
import { FBaseInitialable } from '@univerjs/core/facade';
import { buildPlainTextInsertBody, replaceBodyRange, retainBodyRange } from './utils';
@@ -54,7 +54,8 @@ export class FDocumentTextRange extends FBaseInitialable {
protected readonly _startOffset: number,
protected readonly _endOffset: number,
protected readonly _segmentId: string,
protected override readonly _injector: Injector
protected override readonly _injector: Injector,
@ICommandService private readonly _commandService: ICommandService
) {
super(_injector);
this._validateRange();
@@ -207,7 +208,7 @@ export class FDocumentTextRange extends FBaseInitialable {
this.getRange(),
buildPlainTextInsertBody(text),
this._document.getDocumentDataModel(),
this._injector
this._commandService
);
}
+4 -4
View File
@@ -384,7 +384,7 @@ export class FDocument extends FBaseInitialable {
},
buildPlainTextInsertBody(text),
this._documentDataModel,
this._injector
this._commandService
);
}
@@ -633,7 +633,7 @@ export class FDocument extends FBaseInitialable {
paragraphs,
},
this._documentDataModel,
this._injector
this._commandService
);
return success ? this.getParagraph(paragraphId, segmentId) : null;
@@ -766,7 +766,7 @@ export class FDocument extends FBaseInitialable {
},
buildPlainTextInsertBody(`${text}\r`),
this._documentDataModel,
this._injector
this._commandService
);
if (!result) {
@@ -828,7 +828,7 @@ export class FDocument extends FBaseInitialable {
dataStream: '',
},
this._documentDataModel,
this._injector
this._commandService
);
}
+23 -29
View File
@@ -16,8 +16,16 @@
import type { DocumentDataModel, IDocumentBody, Injector, IParagraphStyle, UpdateDocsAttributeType } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import { createParagraphId, DataStreamTreeTokenType, getRichTextEditPath, ICommandService, JSONX, TextX, TextXActionType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import {
createParagraphId,
DataStreamTreeTokenType,
getRichTextEditPath,
ICommandService,
JSONX,
TextX,
TextXActionType,
} from '@univerjs/core';
import { InsertTextCommand, RichTextEditingMutation } from '@univerjs/docs';
export interface IBuildPlainTextInsertBodyOptions {
paragraphStyle?: IParagraphStyle;
@@ -114,43 +122,29 @@ export function replaceBodyRange(
range: IFDocumentTextRange,
insertBody: IDocumentBody,
docDataModel: DocumentDataModel,
injector: Injector
commandService: ICommandService
): boolean {
const { startOffset, endOffset, segmentId } = range;
const textX = new TextX();
if (startOffset > 0) {
textX.push({ t: TextXActionType.RETAIN, len: startOffset });
}
if (endOffset > startOffset) {
textX.push({ t: TextXActionType.DELETE, len: endOffset - startOffset });
}
if (insertBody.dataStream.length > 0) {
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
}
const jsonX = JSONX.getInstance();
const actions = jsonX.editOp(textX.serialize(), getRichTextEditPath(docDataModel, segmentId));
const commandService = injector.get(ICommandService);
const result = commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
RichTextEditingMutation.id,
const result = commandService.syncExecuteCommand(
InsertTextCommand.id,
{
unitId: docDataModel.getUnitId(),
body: insertBody,
range: {
startOffset,
endOffset,
collapsed: startOffset === endOffset,
segmentId,
},
segmentId,
actions,
debounce: false,
textRanges: [],
noNeedSetTextRange: true,
isEditing: false,
}
);
return Boolean(result?.actions && result.actions.length > 0);
return Boolean(result);
}
export function retainBodyRange(