fix(docs): delete fully selected structures atomically (#7457)

This commit is contained in:
Univer
2026-08-10 03:23:50 +08:00
committed by GitHub
parent f178881969
commit 7bc5fc4218
37 changed files with 1932 additions and 221 deletions
@@ -176,6 +176,23 @@ describe('TextX column groups', () => {
expect(slice.columnGroups).toBeUndefined();
});
it('does not carry column-group metadata without a start token into undo bodies', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'left' }],
sectionBreaks: [{ sectionId: 'section_fixture_31b', startIndex: 6 }],
columnGroups: [
{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' },
{ startIndex: 5, endIndex: 5, columnGroupId: 'cg-1' },
],
};
const slice = getBodySliceForTextXAction(body, 0, 6, false);
expect(slice.columnGroups).toEqual([{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }]);
});
it('keeps a minimum paragraph when replacing all text in a column', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
@@ -21,6 +21,7 @@ import type { CustomRangeType, IDocumentBody, ITextRun } from '../../../../types
import type { DocumentDataModel } from '../../document-data-model';
import type { TextXAction } from '../action-types';
import type { TextXSelection } from '../text-x';
import type { IDocOperationalInterval } from './range-interval';
import fastDiff from 'fast-diff';
import { Tools, UpdateDocsAttributeType } from '../../../../shared';
import { DataStreamTreeTokenType } from '../../types';
@@ -28,7 +29,7 @@ import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
import { getBodySlice, getBodySliceForTextXAction, getTextRunSlice } from '../utils';
import { excludePointsFromRange, getIntersectingCustomRanges, getSelectionForAddCustomRange } from './custom-range';
import { getBlockRangeInterval } from './range-interval';
import { getBlockRangeInterval, getColumnGroupRangeInterval, getCustomRangeInterval } from './range-interval';
export interface IDeleteCustomRangeParam {
rangeId: string;
@@ -179,6 +180,130 @@ function isAtomicContainerDeleted(container: IStructuralTextContainer, selection
));
}
const IMPLICIT_TEXT_SELECTION_TOKENS = new Set<string>([
DataStreamTreeTokenType.PARAGRAPH,
DataStreamTreeTokenType.SECTION_BREAK,
]);
const IMPLICIT_COLUMN_SELECTION_TOKENS = new Set<string>([
...IMPLICIT_TEXT_SELECTION_TOKENS,
DataStreamTreeTokenType.COLUMN_GROUP_START,
DataStreamTreeTokenType.COLUMN_START,
DataStreamTreeTokenType.COLUMN_END,
DataStreamTreeTokenType.COLUMN_GROUP_END,
]);
function mergeSelections(selections: ITextRange[]): ITextRange[] {
const sortedSelections = selections
.filter((selection) => selection.endOffset > selection.startOffset)
.map((selection) => ({ ...selection, collapsed: false }))
.sort((left, right) => left.startOffset - right.startOffset || left.endOffset - right.endOffset);
const mergedSelections: ITextRange[] = [];
for (const selection of sortedSelections) {
const previous = mergedSelections[mergedSelections.length - 1];
if (previous && selection.startOffset <= previous.endOffset) {
previous.endOffset = Math.max(previous.endOffset, selection.endOffset);
continue;
}
mergedSelections.push(selection);
}
return mergedSelections;
}
function isIntervalCoveredBySelections(
interval: IDocOperationalInterval,
selections: ITextRange[],
body: IDocumentBody,
implicitTokens: Set<string>
): boolean {
let cursor = interval.startOffset;
for (const selection of selections) {
if (selection.endOffset <= cursor || selection.startOffset >= interval.endOffset) {
continue;
}
const selectionStart = Math.max(selection.startOffset, interval.startOffset);
const selectionEnd = Math.min(selection.endOffset, interval.endOffset);
for (let offset = cursor; offset < selectionStart; offset++) {
if (!implicitTokens.has(body.dataStream[offset])) {
return false;
}
}
cursor = Math.max(cursor, selectionEnd);
if (cursor >= interval.endOffset) {
return true;
}
}
for (let offset = cursor; offset < interval.endOffset; offset++) {
if (!implicitTokens.has(body.dataStream[offset])) {
return false;
}
}
return true;
}
function addCoveredIntervals(
selections: ITextRange[],
intervals: IDocOperationalInterval[],
body: IDocumentBody,
implicitTokens: Set<string>
): ITextRange[] {
const expanded = [...selections];
const selectionTemplate = selections[0];
for (const interval of intervals) {
if (isIntervalCoveredBySelections(interval, expanded, body, implicitTokens)) {
expanded.push({
...selectionTemplate,
...interval,
collapsed: false,
});
}
}
return mergeSelections(expanded);
}
function expandFullyCoveredStructuralSelections(selections: ITextRange[], body: IDocumentBody): ITextRange[] {
let expanded = mergeSelections(selections);
expanded = addCoveredIntervals(
expanded,
(body.blockRanges ?? []).map(getBlockRangeInterval),
body,
IMPLICIT_TEXT_SELECTION_TOKENS
);
expanded = addCoveredIntervals(
expanded,
(body.customRanges ?? []).map(getCustomRangeInterval),
body,
IMPLICIT_TEXT_SELECTION_TOKENS
);
expanded = addCoveredIntervals(
expanded,
(body.columnGroups ?? []).map(getColumnGroupRangeInterval),
body,
IMPLICIT_COLUMN_SELECTION_TOKENS
);
const editableRootEnd = Math.max(0, body.dataStream.length - 2);
if (editableRootEnd > 0) {
expanded = addCoveredIntervals(
expanded,
[{ startOffset: 0, endOffset: editableRootEnd }],
body,
IMPLICIT_TEXT_SELECTION_TOKENS
);
}
return expanded;
}
function isOffsetDeleted(offset: number, selections: ITextRange[]) {
return selections.some((selection) => offset >= selection.startOffset && offset < selection.endOffset);
}
@@ -195,6 +320,12 @@ function protectLastDeletedOffset(offsets: number[], selections: ITextRange[], p
function protectDeletedColumnBoundaryTokens(body: IDocumentBody, selections: ITextRange[], protectedOffsets: Set<number>) {
// Plain text selection edits may cross column edges, but structural column tokens must stay atomic.
const fullyDeletedColumnGroups = (body.columnGroups ?? [])
.map(getColumnGroupRangeInterval)
.filter((interval) => selections.some((selection) =>
selection.startOffset <= interval.startOffset && selection.endOffset >= interval.endOffset
));
for (let i = 0; i < body.dataStream.length; i++) {
const char = body.dataStream[i];
if (
@@ -204,7 +335,8 @@ function protectDeletedColumnBoundaryTokens(body: IDocumentBody, selections: ITe
char === DataStreamTreeTokenType.COLUMN_END ||
char === DataStreamTreeTokenType.COLUMN_GROUP_END
) &&
isOffsetDeleted(i, selections)
isOffsetDeleted(i, selections) &&
!fullyDeletedColumnGroups.some((interval) => i >= interval.startOffset && i < interval.endOffset)
) {
protectedOffsets.add(i);
}
@@ -299,6 +431,7 @@ function collectStructuralTextContainers(body: IDocumentBody): IStructuralTextCo
if (char === DataStreamTreeTokenType.COLUMN_START) {
columnStack.push({
atomicRange: { startOffset: i, endOffset: i + 1 },
startOffset: i + 1,
endOffset: i + 1,
paragraphs: [],
@@ -309,6 +442,9 @@ function collectStructuralTextContainers(body: IDocumentBody): IStructuralTextCo
const column = columnStack.pop();
if (column) {
column.endOffset = i;
if (column.atomicRange) {
column.atomicRange.endOffset = i + 1;
}
containers.push(column);
}
} else if (char === DataStreamTreeTokenType.TABLE_CELL_START) {
@@ -377,22 +513,26 @@ function normalizeSelectionsForStructuralSentinels(
return selections;
}
const insertOffset = selections[0].startOffset;
const structuralSelections = insertBody == null
? expandFullyCoveredStructuralSelections(selections, body)
: selections;
const insertOffset = structuralSelections[0].startOffset;
const protectedOffsets = new Set<number>();
// Plain text edits must not leave the document root, columns, or table cells without parser children.
collectStructuralTextContainers(body).forEach((container) => {
protectRequiredContainerChildren(container, selections, insertBody, insertOffset, protectedOffsets);
protectRequiredContainerChildren(container, structuralSelections, insertBody, insertOffset, protectedOffsets);
});
protectDeletedColumnBoundaryTokens(body, selections, protectedOffsets);
protectPartiallyDeletedBlockBoundaryTokens(body, selections, protectedOffsets);
protectDeletedColumnBoundaryTokens(body, structuralSelections, protectedOffsets);
protectPartiallyDeletedBlockBoundaryTokens(body, structuralSelections, protectedOffsets);
if (!protectedOffsets.size) {
return selections;
return structuralSelections;
}
const normalizedSelections: ITextRange[] = [];
selections.forEach((selection) => {
structuralSelections.forEach((selection) => {
let startOffset = selection.startOffset;
for (let offset = selection.startOffset; offset < selection.endOffset; offset++) {
@@ -425,8 +565,8 @@ function normalizeSelectionsForStructuralSentinels(
return normalizedSelections.length
? normalizedSelections
: [{
...selections[0],
endOffset: selections[0].startOffset,
...structuralSelections[0],
endOffset: structuralSelections[0].startOffset,
collapsed: true,
}];
}
@@ -438,16 +578,19 @@ export function deleteSelectionTextX(
insertBody: Nullable<IDocumentBody> = null,
keepBullet: boolean = true
): Array<TextXAction> {
selections.sort((a, b) => a.startOffset - b.startOffset);
selections = normalizeSelectionsForStructuralSentinels(selections, body, insertBody);
const normalizedSelections = normalizeSelectionsForStructuralSentinels(
[...selections].sort((a, b) => a.startOffset - b.startOffset),
body,
insertBody
);
const dos: Array<TextXAction> = [];
const { paragraphs = [] } = body;
const paragraphInRange = paragraphs?.find(
(p) => p.startIndex >= selections[0].startOffset && p.startIndex < selections[0].endOffset
(p) => p.startIndex >= normalizedSelections[0].startOffset && p.startIndex < normalizedSelections[0].endOffset
);
let cursor = memoryCursor;
selections.forEach((selection) => {
normalizedSelections.forEach((selection) => {
const { startOffset, endOffset } = selection;
if (startOffset > cursor) {
dos.push({
@@ -475,7 +618,7 @@ export function deleteSelectionTextX(
}
if (paragraphInRange?.bullet && keepBullet) {
const nextParagraph = paragraphs.find((p) => p.startIndex - memoryCursor >= (selections[selections.length - 1].endOffset - 1));
const nextParagraph = paragraphs.find((p) => p.startIndex - memoryCursor >= (normalizedSelections[normalizedSelections.length - 1].endOffset - 1));
if (nextParagraph) {
if (nextParagraph.startIndex > cursor) {
dos.push({
@@ -28,7 +28,7 @@ import {
RESTORE_INSERTED_PARAGRAPH_IDS,
} from './apply-utils/common';
import { transformBody } from './transform-utils';
import { composeBody, getBodySlice, isUselessRetainAction } from './utils';
import { composeBody, getBodySlice, getBodySliceForTextXAction, isUselessRetainAction } from './utils';
function onlyHasDataStream(body: IDocumentBody) {
return Object.keys(body).length === 1;
@@ -251,6 +251,7 @@ export class TextX {
if (action.body.paragraphs?.length) {
(action.body as IDocumentBody & Record<string, unknown>)[RESTORE_INSERTED_PARAGRAPH_IDS] = true;
Reflect.set(action.body, PRESERVE_INSERTED_PARAGRAPH_IDS, true);
}
invertedActions.push({
@@ -294,7 +295,7 @@ export class TextX {
}
if (action.t === TextXActionType.DELETE && (action.body == null || (action.body && action.body.dataStream.length !== action.len))) {
const body = getBodySlice(doc, index, index + action.len, false);
const body = getBodySliceForTextXAction(doc, index, index + action.len, false);
action.len = body.dataStream.length;
action.body = body;
}
@@ -30,6 +30,7 @@ import type { IRetainAction } from './action-types';
import { merge } from '../../../common/lodash';
import { UpdateDocsAttributeType } from '../../../shared/command-enum';
import { Tools } from '../../../shared/tools';
import { DataStreamTreeTokenType } from '../types';
import { PRESERVE_INSERTED_PARAGRAPH_IDS } from './action-types';
import { normalizeTextRuns } from './apply-utils/common';
import { coverTextRuns } from './apply-utils/update-apply';
@@ -188,6 +189,9 @@ export function getColumnGroupSlice(
for (const columnGroup of columnGroups) {
const clonedColumnGroup = Tools.deepClone(columnGroup);
const { startIndex, endIndex } = clonedColumnGroup;
if (body.dataStream[startIndex] !== DataStreamTreeTokenType.COLUMN_GROUP_START) {
continue;
}
if (hasStructuralRangeInSlice(startIndex, endIndex, startOffset, endOffset, mode)) {
newColumnGroups.push({
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDisposable, IDocumentData, Injector, IStyleBase, Univer } from '@univerjs/core';
import type { ICommand, ICommandInfo, IDisposable, IDocumentData, Injector, IStyleBase, JSONXActions, Univer } from '@univerjs/core';
import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocClipboardHook } from '../../../services/clipboard/clipboard.service';
import type { IInnerCutCommandParams, IInnerPasteCommandParams } from '../clipboard.inner.command';
@@ -24,12 +24,20 @@ import {
CustomRangeType,
DataStreamTreeTokenType,
DOC_RANGE_TYPE,
DocumentBlockRangeType,
DocumentDataModel,
DrawingTypeEnum,
EDITOR_ACTIVATED,
FOCUSING_DOC,
ICommandService,
IContextService,
IUniverInstanceService,
ObjectRelativeFromH,
ObjectRelativeFromV,
PositionedObjectLayoutType,
RedoCommand,
SliceBodyType,
Tools,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
@@ -176,6 +184,14 @@ describe('test cases in clipboard', () => {
return getDocumentModel()?.getSnapshot();
}
function getRequiredDocumentSnapshot(): IDocumentData {
const snapshot = getDocumentSnapshot();
if (!snapshot) {
throw new Error('Document snapshot not found');
}
return snapshot;
}
function registerInnerClipboardCommands() {
commandService.registerCommand(InnerPasteCommand);
commandService.registerCommand(CutContentCommand);
@@ -227,7 +243,9 @@ describe('test cases in clipboard', () => {
function createTableDocumentData(): IDocumentData {
const tableData = genEmptyTable(2, 2);
const tableSource = genTableSource(2, 2, 360);
const dataStream = `${tableData.dataStream}Tail\r\n`;
const prefix = 'Head\r';
const tableOffset = prefix.length;
const dataStream = `${prefix}${tableData.dataStream}Tail\r\n`;
return {
id: 'test-doc',
@@ -235,16 +253,23 @@ describe('test cases in clipboard', () => {
dataStream,
textRuns: [{ st: 0, ed: dataStream.length - 2, ts: {} }],
paragraphs: [
...tableData.paragraphs,
{ paragraphId: 'para_docs_ui_clipboard_table_head', startIndex: prefix.length - 1 },
...tableData.paragraphs.map((paragraph) => ({
...paragraph,
startIndex: paragraph.startIndex + tableOffset,
})),
{ paragraphId: 'para_docs_ui_clipboard_table_tail', startIndex: dataStream.length - 2 },
],
sectionBreaks: [
...tableData.sectionBreaks,
...tableData.sectionBreaks.map((sectionBreak) => ({
...sectionBreak,
startIndex: sectionBreak.startIndex + tableOffset,
})),
{ sectionId: 'section_fixture_203', startIndex: dataStream.length - 1 },
],
tables: [{
startIndex: 0,
endIndex: tableData.dataStream.length,
startIndex: tableOffset,
endIndex: tableOffset + tableData.dataStream.length,
tableId: 'table-1',
}],
customBlocks: [],
@@ -294,6 +319,151 @@ describe('test cases in clipboard', () => {
};
}
function getTableRowRanges(documentData: IDocumentData): Array<{ startOffset: number; endOffset: number }> {
const body = documentData.body;
const table = body?.tables?.[0];
if (!body || !table) {
throw new Error('Table body not found');
}
const ranges: Array<{ startOffset: number; endOffset: number }> = [];
let rowStart = -1;
for (let offset = table.startIndex; offset < table.endIndex; offset++) {
if (body.dataStream[offset] === DataStreamTreeTokenType.TABLE_ROW_START) {
rowStart = offset;
} else if (body.dataStream[offset] === DataStreamTreeTokenType.TABLE_ROW_END && rowStart >= 0) {
ranges.push({ startOffset: rowStart, endOffset: offset });
rowStart = -1;
}
}
return ranges;
}
function createStructuralDocumentData(includeListParagraph = false): IDocumentData {
const T = DataStreamTreeTokenType;
const dataStream = `P${T.PARAGRAPH}${T.BLOCK_START}A${T.PARAGRAPH}B${T.PARAGRAPH}${T.BLOCK_END}M${T.PARAGRAPH}${T.COLUMN_GROUP_START}${T.COLUMN_START}C${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}${T.CUSTOM_BLOCK}D${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}Z${T.PARAGRAPH}${T.SECTION_BREAK}`;
return {
id: 'test-doc',
body: {
dataStream,
paragraphs: [
{
paragraphId: 'root-before',
startIndex: 1,
bullet: includeListParagraph
? { listId: 'list-1', listType: 'BULLET_LIST', nestingLevel: 0 }
: undefined,
},
{ paragraphId: 'block-first', startIndex: 4 },
{ paragraphId: 'block-second', startIndex: 6 },
{ paragraphId: 'root-middle', startIndex: 9 },
{ paragraphId: 'column-first', startIndex: 13 },
{ paragraphId: 'column-second', startIndex: 18 },
{ paragraphId: 'root-after', startIndex: 22 },
],
sectionBreaks: [{ sectionId: 'structural-section', startIndex: 23 }],
blockRanges: [{
blockId: 'structural-block',
blockType: DocumentBlockRangeType.CALLOUT,
startIndex: 2,
endIndex: 7,
}],
columnGroups: [{ columnGroupId: 'structural-columns', startIndex: 10, endIndex: 20 }],
customRanges: [{
rangeId: 'structural-custom-range',
rangeType: CustomRangeType.CUSTOM,
startIndex: 3,
endIndex: 5,
}],
customBlocks: [{ blockId: 'structural-drawing', startIndex: 16 }],
customDecorations: [],
},
drawings: {
'structural-drawing': {
drawingId: 'structural-drawing',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
unitId: 'test-doc',
subUnitId: '',
layoutType: PositionedObjectLayoutType.INLINE,
docTransform: {
size: { width: 10, height: 10 },
positionH: { relativeFrom: ObjectRelativeFromH.CHARACTER, posOffset: 0 },
positionV: { relativeFrom: ObjectRelativeFromV.LINE, posOffset: 0 },
angle: 0,
},
},
},
drawingsOrder: ['structural-drawing'],
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createTerminalColumnGroupDocumentData(includeCollapsedDuplicate = false): IDocumentData {
const T = DataStreamTreeTokenType;
const dataStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`;
const columnGroup = {
columnGroupId: 'terminal-columns',
startIndex: 0,
endIndex: 9,
columns: [
{ columnId: 'terminal-column-1', widthRatio: 1 },
{ columnId: 'terminal-column-2', widthRatio: 1 },
],
};
return {
id: 'test-doc',
body: {
dataStream,
paragraphs: [
{ paragraphId: 'terminal-column-first', startIndex: 3 },
{ paragraphId: 'terminal-column-second', startIndex: 7 },
],
sectionBreaks: [{ sectionId: 'terminal-column-section', startIndex: 10 }],
columnGroups: includeCollapsedDuplicate
? [columnGroup, { ...columnGroup, startIndex: 9, endIndex: 9 }]
: [columnGroup],
},
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function expectStructuralSnapshotRestored(actual: IDocumentData | null | undefined, expected: IDocumentData): void {
expect(actual?.body?.dataStream).toBe(expected.body?.dataStream);
expect(actual?.body?.paragraphs).toEqual(expected.body?.paragraphs);
expect(actual?.body?.sectionBreaks).toEqual(expected.body?.sectionBreaks);
expect(actual?.body?.blockRanges ?? []).toEqual(expected.body?.blockRanges ?? []);
expect(actual?.body?.columnGroups ?? []).toEqual(expected.body?.columnGroups ?? []);
expect(actual?.body?.customRanges ?? []).toEqual(expected.body?.customRanges ?? []);
expect(actual?.body?.customBlocks ?? []).toEqual(expected.body?.customBlocks ?? []);
expect(actual?.body?.tables ?? []).toEqual(expected.body?.tables ?? []);
expect(actual?.drawings ?? {}).toEqual(expected.drawings ?? {});
expect(actual?.drawingsOrder ?? []).toEqual(expected.drawingsOrder ?? []);
expect(actual?.tableSource ?? {}).toEqual(expected.tableSource ?? {});
}
function getCollabActions(command: Readonly<ICommandInfo>): JSONXActions | null {
if (command.id !== RichTextEditingMutation.id || command.params == null || !('actions' in command.params)) {
return null;
}
return Array.isArray(command.params.actions) ? command.params.actions : null;
}
function createAnnotatedDocumentData(): IDocumentData {
const documentData = getDocumentData();
documentData.body!.customRanges = [{
@@ -564,6 +734,67 @@ describe('test cases in clipboard', () => {
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
});
it('replaces a mixed whole-document table selection once and restores it through history', async () => {
const originalData = createTableDocumentData();
replaceDocument(originalData);
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const body = original.body;
const table = body?.tables?.[0];
if (!body || !table) {
throw new Error('Table not found');
}
const selectionManager = get(DocSelectionManagerService);
const selectionInfo = selectionManager.getSelectionInfo();
if (!selectionInfo) {
throw new Error('Selection info not found');
}
const textRanges = [
{ startOffset: 0, endOffset: table.startIndex, collapsed: false, isActive: true, segmentId: '' },
{ startOffset: table.endIndex, endOffset: body.dataStream.length - 2, collapsed: false, segmentId: '' },
];
const rectRange: IRectRangeWithStyle = {
startOffset: table.startIndex,
endOffset: table.endIndex - 1,
collapsed: false,
rangeType: DOC_RANGE_TYPE.RECT,
tableId: table.tableId,
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
spanEntireRow: true,
spanEntireColumn: true,
spanEntireTable: true,
};
selectionManager.__replaceTextRangesWithNoRefresh({
...selectionInfo,
textRanges,
rectRanges: [rectRange],
options: { wholeDocument: true },
}, { unitId: 'test-doc', subUnitId: '' });
expect(await commandService.executeCommand(InnerPasteCommand.id, {
segmentId: '',
doc: { body: { dataStream: 'paste' } },
textRanges: [],
} satisfies IInnerPasteCommandParams)).toBe(true);
const replaced = Tools.deepClone(getRequiredDocumentSnapshot());
expect(replaced.body?.dataStream).toBe('paste\r\n');
expect(replaced.body?.tables).toEqual([]);
expect(replaced.tableSource ?? {}).toEqual({});
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDocumentSnapshot()?.body).toEqual(expect.objectContaining({
dataStream: 'paste\r\n',
tables: [],
}));
expect(getDocumentSnapshot()?.tableSource ?? {}).toEqual({});
});
});
describe('Test cut in multiple ranges', () => {
@@ -623,10 +854,19 @@ describe('test cases in clipboard', () => {
it('Should cut an entire selected table and remove the table source', async () => {
replaceDocument(createTableDocumentData());
const table = getDocumentSnapshot()?.body?.tables?.[0];
if (!table) {
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const body = original.body;
const table = body?.tables?.[0];
if (!body || !table) {
throw new Error('Table not found');
}
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
const rectRange: IRectRangeWithStyle = {
startOffset: table.startIndex,
endOffset: table.endIndex - 1,
@@ -649,12 +889,412 @@ describe('test cases in clipboard', () => {
rectRanges: [rectRange],
} satisfies IInnerCutCommandParams);
const snapshot = getDocumentSnapshot();
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(snapshot?.body?.dataStream.includes(DataStreamTreeTokenType.TABLE_START)).toBe(false);
expect(snapshot?.tableSource?.['table-1']).toBeUndefined();
expect(deleted.body?.dataStream.includes(DataStreamTreeTokenType.TABLE_START)).toBe(false);
expect(deleted.tableSource?.['table-1']).toBeUndefined();
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it.each([
['upper half across its start boundary', 0, 'above'],
['lower half across its end boundary', 1, 'below'],
])('keeps the table balanced when deleting its %s and replays the mutation for collaboration', async (_name, rowIndex, position) => {
replaceDocument(createTableDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const body = original.body;
const table = original.body?.tables?.[0];
const rowRange = getTableRowRanges(original)[rowIndex];
if (!body || !table || !rowRange) {
throw new Error('Table row not found');
}
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
const rectRange: IRectRangeWithStyle = {
...rowRange,
collapsed: false,
rangeType: DOC_RANGE_TYPE.RECT,
tableId: table.tableId,
startRow: rowIndex,
endRow: rowIndex,
startColumn: 0,
endColumn: 1,
spanEntireRow: true,
spanEntireColumn: false,
spanEntireTable: false,
};
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: position === 'above'
? [{ startOffset: 0, endOffset: table.startIndex, collapsed: false }]
: [{ startOffset: table.endIndex, endOffset: body.dataStream.length - 2, collapsed: false }],
rectRanges: [rectRange],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body?.tables).toHaveLength(1);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_START);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_END);
expect(deleted.tableSource?.[table.tableId]?.tableRows).toHaveLength(1);
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it.each(['above', 'below'])('keeps the table intact when deleting content immediately %s it', async (position) => {
replaceDocument(createTableDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const body = original.body;
const table = body?.tables?.[0];
if (!body || !table) {
throw new Error('Table not found');
}
const selection = position === 'above'
? { startOffset: 0, endOffset: table.startIndex, collapsed: false }
: { startOffset: table.endIndex, endOffset: body.dataStream.length - 2, collapsed: false };
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [selection],
rectRanges: [],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body?.tables).toHaveLength(1);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_START);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_END);
expect(deleted.tableSource?.[table.tableId]).toBeDefined();
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it('normalizes fragmented whole-body selection to an empty document and replays the same actions for collaboration', async () => {
replaceDocument(createStructuralDocumentData(true));
const original = Tools.deepClone(getRequiredDocumentSnapshot());
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [
{ startOffset: 0, endOffset: 1, collapsed: false },
{ startOffset: 2, endOffset: 4, collapsed: false },
{ startOffset: 5, endOffset: 9, collapsed: false },
{ startOffset: 12, endOffset: 13, collapsed: false },
{ startOffset: 16, endOffset: 18, collapsed: false },
{ startOffset: 21, endOffset: 22, collapsed: false },
],
rectRanges: [],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body).toEqual(expect.objectContaining({
dataStream: '\r\n',
paragraphs: [expect.objectContaining({ startIndex: 0 })],
sectionBreaks: [expect.objectContaining({ startIndex: 1 })],
blockRanges: [],
columnGroups: [],
customRanges: [],
customBlocks: [],
}));
expect(deleted.body?.paragraphs?.[0].bullet).toBeUndefined();
expect(deleted.drawings ?? {}).toEqual({});
expect(deleted.drawingsOrder ?? []).toEqual([]);
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getDocumentSnapshot()).toEqual(deleted);
collabListener.dispose();
});
it('normalizes a whole document ending in a column group to an empty document', async () => {
replaceDocument(createTerminalColumnGroupDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [
{ startOffset: 2, endOffset: 3, collapsed: false },
{ startOffset: 6, endOffset: 7, collapsed: false },
],
rectRanges: [],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body).toEqual(expect.objectContaining({
dataStream: '\r\n',
paragraphs: [expect.objectContaining({ startIndex: 0 })],
sectionBreaks: [expect.objectContaining({ startIndex: 1 })],
columnGroups: [],
}));
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it('deletes a whole collaborative document with a collapsed duplicate column-group range', async () => {
replaceDocument(createTerminalColumnGroupDocumentData(true));
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const normalizedOriginal = Tools.deepClone(original);
if (normalizedOriginal.body) {
normalizedOriginal.body.columnGroups = normalizedOriginal.body.columnGroups?.slice(0, 1);
}
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [
{ startOffset: 2, endOffset: 3, collapsed: false },
{ startOffset: 6, endOffset: 7, collapsed: false },
],
rectRanges: [],
wholeBodySelected: true,
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body).toEqual(expect.objectContaining({
dataStream: '\r\n',
columnGroups: [],
}));
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), normalizedOriginal);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it('deletes fragmented full block and column-group selections atomically with undo and redo', async () => {
replaceDocument(createStructuralDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [
{ startOffset: 2, endOffset: 4, collapsed: false },
{ startOffset: 5, endOffset: 8, collapsed: false },
{ startOffset: 12, endOffset: 13, collapsed: false },
{ startOffset: 16, endOffset: 18, collapsed: false },
],
rectRanges: [],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body?.blockRanges).toEqual([]);
expect(deleted.body?.columnGroups).toEqual([]);
expect(deleted.body?.customRanges).toEqual([]);
expect(deleted.body?.customBlocks).toEqual([]);
expect(deleted.drawings ?? {}).toEqual({});
expect(deleted.body?.dataStream).not.toContain(DataStreamTreeTokenType.BLOCK_START);
expect(deleted.body?.dataStream).not.toContain(DataStreamTreeTokenType.COLUMN_GROUP_START);
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
it.each([
['above block boundary', [{ startOffset: 0, endOffset: 5, collapsed: false }]],
['below block boundary', [{ startOffset: 5, endOffset: 10, collapsed: false }]],
['above column-group boundary', [{ startOffset: 8, endOffset: 13, collapsed: false }]],
['below column-group boundary', [{ startOffset: 17, endOffset: 23, collapsed: false }]],
])('keeps partially selected structures balanced when deleting %s', async (_name, selections) => {
replaceDocument(createStructuralDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections,
rectRanges: [],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body?.blockRanges).toHaveLength(1);
expect(deleted.body?.columnGroups).toHaveLength(1);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.BLOCK_START);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.BLOCK_END);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.COLUMN_GROUP_START);
expect(deleted.body?.dataStream).toContain(DataStreamTreeTokenType.COLUMN_GROUP_END);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getDocumentSnapshot()).toEqual(deleted);
});
it.each([
['the custom block itself', { startOffset: 16, endOffset: 17, removed: true }],
['the content immediately above it', { startOffset: 12, endOffset: 13, removed: false }],
['the content immediately below it', { startOffset: 17, endOffset: 18, removed: false }],
])('handles deleting %s without crossing the custom-block point boundary', async (_name, range) => {
replaceDocument(createStructuralDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [{ ...range, collapsed: false }],
rectRanges: [],
} satisfies IInnerCutCommandParams);
const snapshot = getDocumentSnapshot();
if (!snapshot) {
throw new Error('Document snapshot not found');
}
expect(snapshot?.body?.customBlocks ?? []).toHaveLength(range.removed ? 0 : 1);
expect(snapshot?.drawings?.['structural-drawing'] == null).toBe(range.removed);
const deleted = Tools.deepClone(snapshot);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getDocumentSnapshot()).toEqual(deleted);
});
it('normalizes whole-body selection containing an entire table and restores it through undo', async () => {
replaceDocument(createTableDocumentData());
const original = Tools.deepClone(getRequiredDocumentSnapshot());
const body = original.body;
const table = body?.tables?.[0];
if (!body || !table) {
throw new Error('Table not found');
}
let collabActions: JSONXActions = [];
const collabListener = commandService.onMutationExecutedForCollab((command) => {
const actions = getCollabActions(command);
if (actions) {
collabActions = actions;
}
});
const rectRange: IRectRangeWithStyle = {
startOffset: table.startIndex,
endOffset: table.endIndex - 1,
collapsed: false,
rangeType: DOC_RANGE_TYPE.RECT,
tableId: table.tableId,
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
spanEntireRow: true,
spanEntireColumn: true,
spanEntireTable: true,
};
await commandService.executeCommand(CutContentCommand.id, {
segmentId: '',
textRanges: [],
selections: [
{ startOffset: 0, endOffset: table.startIndex, collapsed: false },
{ startOffset: table.endIndex, endOffset: body.dataStream.length - 2, collapsed: false },
],
rectRanges: [rectRange],
} satisfies IInnerCutCommandParams);
const deleted = Tools.deepClone(getRequiredDocumentSnapshot());
expect(deleted.body?.dataStream).toBe('\r\n');
expect(deleted.body?.tables).toEqual([]);
expect(deleted.tableSource ?? {}).toEqual({});
const remote = new DocumentDataModel(Tools.deepClone(original));
remote.apply(collabActions);
expect(remote.getSnapshot()).toEqual(deleted);
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), original);
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expectStructuralSnapshotRestored(getDocumentSnapshot(), deleted);
collabListener.dispose();
});
});
@@ -765,6 +765,39 @@ describe('misc document commands', () => {
subscription.unsubscribe();
});
it('selects the whole document directly for a structural selection without an active text range', async () => {
({ univer, get } = createCommandTestBed(createColumnGroupDoc()));
commandService = get(ICommandService);
commandService.registerCommand(DocSelectAllCommand);
const selectionManager = get(DocSelectionManagerService);
const refreshEvents: Array<{
docRanges: Array<{ endOffset?: number; startOffset?: number }>;
options?: { [key: string]: boolean };
}> = [];
const subscription = selectionManager.refreshSelection$.subscribe((event) => event && refreshEvents.push(event));
const result = await commandService.executeCommand(DocSelectAllCommand.id, {
segmentId: '',
wholeDocument: true,
});
await awaitTime(0);
expect(result).toBe(true);
expect(refreshEvents.at(-1)).toEqual(expect.objectContaining({
docRanges: [
expect.objectContaining({ startOffset: 0, endOffset: 1 }),
expect.objectContaining({ startOffset: 4, endOffset: 5 }),
expect.objectContaining({ startOffset: 6, endOffset: 7 }),
expect.objectContaining({ startOffset: 10, endOffset: 11 }),
expect.objectContaining({ startOffset: 14, endOffset: 15 }),
],
options: { wholeDocument: true },
}));
subscription.unsubscribe();
});
it('selects the current paragraph first when tables are present', async () => {
({ univer, get } = createCommandTestBed(createTableDoc()));
commandService = get(ICommandService);
@@ -18,6 +18,8 @@ import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } fro
import { DataStreamTreeTokenType, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocIMEInputManagerService } from '../../../services/doc-ime-input-manager.service';
import { IMEInputCommand } from '../ime-input.command';
import {
buildReplaceSnapshotActions,
CoverContentCommand,
@@ -49,6 +51,39 @@ function getDocumentData() {
return TEST_DOCUMENT_DATA_EN;
}
function getTerminalColumnGroupDocumentData(): IDocumentData {
const T = DataStreamTreeTokenType;
const dataStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`;
return {
id: 'test-doc',
body: {
dataStream,
paragraphs: [
{ paragraphId: 'terminal-column-first', startIndex: 3 },
{ paragraphId: 'terminal-column-second', startIndex: 7 },
],
sectionBreaks: [{ sectionId: 'terminal-column-section', startIndex: 10 }],
columnGroups: [{
columnGroupId: 'terminal-columns',
startIndex: 0,
endIndex: 9,
columns: [
{ columnId: 'terminal-column-1', widthRatio: 1 },
{ columnId: 'terminal-column-2', widthRatio: 1 },
],
}],
},
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('replace or cover content of document', () => {
let univer: Univer;
let get: Injector['get'];
@@ -281,6 +316,101 @@ describe('replace or cover content of document', () => {
expect(getDataStream()).toBe('=AVG(A2:B4)\r\n');
});
it('replaces a fragmented whole-column-group selection once at the document start', async () => {
univer.dispose();
const original = getTerminalColumnGroupDocumentData();
const testBed = createCommandTestBed(original);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(ReplaceSelectionCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: 'test-doc', subUnitId: '' });
selectionManager.__TEST_ONLY_add([{
startOffset: 2,
endOffset: 3,
collapsed: false,
isActive: true,
segmentId: '',
}]);
const selectionInfo = selectionManager.getSelectionInfo();
if (!selectionInfo) {
throw new Error('Selection info not found');
}
selectionManager.__replaceTextRangesWithNoRefresh({
...selectionInfo,
textRanges: [
{ startOffset: 2, endOffset: 3, collapsed: false, isActive: true, segmentId: '' },
{ startOffset: 6, endOffset: 7, collapsed: false, segmentId: '' },
],
rectRanges: [],
options: { wholeDocument: true },
}, { unitId: 'test-doc', subUnitId: '' });
expect(await commandService.executeCommand(ReplaceSelectionCommand.id, {
unitId: 'test-doc',
body: { dataStream: 'x' },
})).toBeTruthy();
const replacedSnapshot = get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)
?.getSnapshot();
expect(replacedSnapshot?.body?.dataStream).toBe('x\r\n');
expect(replacedSnapshot?.body?.columnGroups).toEqual([]);
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDataStream()).toBe(original.body?.dataStream);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDataStream()).toBe('x\r\n');
});
it('replaces a fragmented whole-column-group selection once during IME composition', async () => {
univer.dispose();
const testBed = createCommandTestBed(getTerminalColumnGroupDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(IMEInputCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const imeManager = get(DocIMEInputManagerService);
imeManager.setActiveRange({
startOffset: 2,
endOffset: 3,
collapsed: false,
isActive: true,
segmentId: '',
});
imeManager.setPreviousDocRanges([
{ startOffset: 2, endOffset: 3, collapsed: false, isActive: true, segmentId: '' },
{ startOffset: 6, endOffset: 7, collapsed: false, segmentId: '' },
]);
imeManager.setPreviousSelectionOptions({ wholeDocument: true });
expect(await commandService.executeCommand(IMEInputCommand.id, {
unitId: 'test-doc',
newText: '中',
oldTextLen: 0,
isCompositionStart: true,
isCompositionEnd: true,
})).toBe(true);
expect(getDataStream()).toBe('中\r\n');
expect(imeManager.getCompositionRange()).toMatchObject({
startOffset: 0,
endOffset: 0,
collapsed: true,
});
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDataStream()).toBe(getTerminalColumnGroupDocumentData().body?.dataStream);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDataStream()).toBe('中\r\n');
});
it('replaces text runs without adding undo history', async () => {
await expect(commandService.executeCommand(ReplaceTextRunsCommand.id, {
unitId: 'test-doc',
@@ -14,16 +14,19 @@
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, ICustomTable, IDisposable, IDocumentData, IDrawingParam, IMutationInfo, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { DocumentDataModel, IAccessor, ICommand, ICustomTable, IDisposable, IDocumentBody, IDocumentData, IDrawingParam, IMutationInfo, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { DocumentViewModel, IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocClipboardPasteBlockRangeMapping, IDocClipboardPasteCustomBlockMapping, IDocClipboardPasteCustomRangeMapping } from '../../services/clipboard/doc-paste-mutation-adapter.service';
import {
BuildTextUtils,
CommandType,
createParagraphId,
DataStreamTreeTokenType,
generateRandomId,
getCustomBlockIdsInSelections,
getRichTextEditPath,
getTableRangeInterval,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
@@ -37,11 +40,13 @@ import {
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition } from '../../basics/paragraph';
import { IDocClipboardPasteAdapterService } from '../../services/clipboard/doc-paste-mutation-adapter.service';
import {
IDocClipboardPasteAdapterService,
} from '../../services/clipboard/doc-paste-mutation-adapter.service';
import { getCommandSkeleton } from '../util';
import { getDeleteRowContentActionParams, getDeleteRowsActionsParams, getDeleteTableActionParams } from './table/table';
function hasRangeInTable(ranges: ITextRangeWithStyle[]): boolean {
function hasRangeInTable(ranges: readonly ITextRangeWithStyle[]): boolean {
return ranges.some((range) => {
const { startNodePosition } = range;
@@ -75,10 +80,11 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const pasteAdapterService = getPasteAdapterService(accessor);
const selections = docSelectionManagerService.getTextRanges();
const rectRanges = docSelectionManagerService.getRectRanges();
const selections = docSelectionManagerService.getTextRanges() ?? [];
const rectRanges = docSelectionManagerService.getRectRanges() ?? [];
const selectionInfo = docSelectionManagerService.getSelectionInfo();
const { body, tableSource, drawings } = doc;
if (!Array.isArray(selections) || selections.length === 0 || body == null) {
if ((selections.length === 0 && rectRanges.length === 0) || body == null) {
return false;
}
@@ -96,6 +102,7 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
actions: [],
textRanges,
segmentId,
trigger: InnerPasteCommand.id,
},
};
@@ -121,17 +128,50 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
// TODO: @JOCS A feature that has not yet been implemented.
// Can not paste tables into table cell now.
if (hasTable && hasRangeInTable(selections)) {
if (hasTable && (hasRangeInTable(selections) || rectRanges.some((range) => !range.spanEntireTable))) {
return false;
}
// Can not paste content when doc selection has both text ranges and rect ranges.
if (selections.length && rectRanges?.length) {
return false;
const replacesComplexSelection = rectRanges.length > 0 || selectionInfo?.options?.wholeDocument === true;
let selectionCutActions: JSONXActions = [];
let pasteSelections = selections;
if (replacesComplexSelection) {
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
if (!docSkeletonManagerService) {
return false;
}
const wholeBodySelected = selectionInfo?.options?.wholeDocument === true || isWholeBodySelected(selections, rectRanges, originBody);
const insertOffset = wholeBodySelected ? 0 : getDocRangeInsertOffset(selections, rectRanges);
if (insertOffset == null) {
return false;
}
selectionCutActions = getCutActionsFromDocRanges(
selections,
rectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
segmentId,
wholeBodySelected
);
pasteSelections = [{
startOffset: insertOffset,
endOffset: insertOffset,
collapsed: true,
segmentId,
}];
doMutation.params.textRanges = [{
startOffset: insertOffset + body.dataStream.length,
endOffset: insertOffset + body.dataStream.length,
collapsed: true,
segmentId,
style: selections.find((range) => range.isActive)?.style,
}];
}
for (let i = 0; i < selections.length; i++) {
const selection = selections[i];
for (let i = 0; i < pasteSelections.length; i++) {
const selection = pasteSelections[i];
const { startOffset, endOffset, collapsed } = selection;
const len = startOffset - memoryCursor.cursor;
@@ -257,7 +297,7 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
len: body.dataStream.length,
});
} else {
const dos = BuildTextUtils.selection.delete([selection], body, memoryCursor.cursor, cloneBody, selections.length === 1);
const dos = BuildTextUtils.selection.delete([selection], body, memoryCursor.cursor, cloneBody, pasteSelections.length === 1);
textX.push(...dos);
}
@@ -269,9 +309,12 @@ export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
const pasteActions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
doMutation.params.actions = selectionCutActions && selectionCutActions.length > 0
? JSONX.compose(selectionCutActions, pasteActions)
: pasteActions;
if (!executeResourceMutationGroups(resourceMutationGroups, commandService)) {
return false;
@@ -377,27 +420,8 @@ function getCutActionsFromTextRanges(
}
const { tables = [] } = originBody;
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
for (let i = 0; i < selections.length; i++) {
const selection = adjustSelectionByTable(selections[i], tables);
const { startOffset, endOffset, collapsed } = selection;
const len = startOffset - memoryCursor.cursor;
if (collapsed) {
textX.push({
t: TextXActionType.RETAIN,
len,
});
} else {
textX.push(...BuildTextUtils.selection.delete([selection], originBody, memoryCursor.cursor, null, false));
}
memoryCursor.reset();
memoryCursor.moveCursor(endOffset);
}
const adjustedSelections = selections.map((selection) => adjustSelectionByTable(selection, tables));
textX.push(...BuildTextUtils.selection.delete(adjustedSelections, originBody, 0, null, false));
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
@@ -436,6 +460,125 @@ function getCutActionsFromTextRanges(
}, null as JSONXActions);
}
const IMPLICIT_WHOLE_BODY_SELECTION_TOKENS = new Set<string>([
DataStreamTreeTokenType.PARAGRAPH,
DataStreamTreeTokenType.SECTION_BREAK,
DataStreamTreeTokenType.BLOCK_START,
DataStreamTreeTokenType.BLOCK_END,
DataStreamTreeTokenType.COLUMN_GROUP_START,
DataStreamTreeTokenType.COLUMN_START,
DataStreamTreeTokenType.COLUMN_END,
DataStreamTreeTokenType.COLUMN_GROUP_END,
]);
function isWholeBodySelected(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
body: IDocumentBody
): boolean {
const intervals = (Array.isArray(textRanges) ? textRanges : [])
.filter((range) => !range.collapsed)
.map(({ startOffset, endOffset }) => ({ startOffset, endOffset }));
for (const rectRange of Array.isArray(rectRanges) ? rectRanges : []) {
if (!rectRange.spanEntireTable) {
continue;
}
const table = (body.tables ?? []).find((item) =>
(rectRange.tableId && item.tableId === rectRange.tableId) || item.startIndex === rectRange.startOffset
);
if (table) {
intervals.push(getTableRangeInterval(table));
}
}
intervals.sort((left, right) => left.startOffset - right.startOffset || left.endOffset - right.endOffset);
const editableEnd = Math.max(0, body.dataStream.length - 2);
let intervalIndex = 0;
for (let offset = 0; offset < editableEnd; offset++) {
while (intervals[intervalIndex]?.endOffset <= offset) {
intervalIndex++;
}
const interval = intervals[intervalIndex];
if (interval && interval.startOffset <= offset && offset < interval.endOffset) {
continue;
}
if (!IMPLICIT_WHOLE_BODY_SELECTION_TOKENS.has(body.dataStream[offset])) {
return false;
}
}
return editableEnd > 0;
}
function getWholeBodyCutActions(
selections: readonly ITextRangeWithStyle[],
docDataModel: DocumentDataModel,
segmentId: string
): JSONXActions {
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!body) {
return [];
}
const emptyBody: IDocumentBody = {
dataStream: DataStreamTreeTokenType.PARAGRAPH,
paragraphs: [{
paragraphId: createParagraphId(new Set((body.paragraphs ?? []).map((paragraph) => paragraph.paragraphId))),
startIndex: 0,
}],
};
const deleteLength = Math.max(0, body.dataStream.length - 1);
const textX = new TextX();
textX.push({ t: TextXActionType.INSERT, len: emptyBody.dataStream.length, body: emptyBody });
textX.push({ t: TextXActionType.DELETE, len: deleteLength });
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel, segmentId);
const rawActions: JSONXActions[] = [];
const editAction = jsonX.editOp(textX.serialize(), path);
if (editAction) {
rawActions.push(editAction);
}
const drawings = docDataModel.getDrawings() ?? {};
const drawingOrder = docDataModel.getDrawingsOrder() ?? [];
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [{
...(selections[0] ?? { collapsed: false }),
startOffset: 0,
endOffset: deleteLength,
collapsed: false,
}]).sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
for (const blockId of removedCustomBlockIds) {
const drawing = drawings[blockId];
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawing == null || drawingIndex < 0) {
continue;
}
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
if (removeDrawingAction) {
rawActions.push(removeDrawingAction);
}
if (removeDrawingOrderAction) {
rawActions.push(removeDrawingOrderAction);
}
}
for (const table of body.tables ?? []) {
const removeTableSourceAction = jsonX.removeOp(['tableSource', table.tableId]);
if (removeTableSourceAction) {
rawActions.push(removeTableSourceAction);
}
}
let actions: JSONXActions | null = null;
for (const action of rawActions) {
actions = actions == null ? action : JSONX.compose(actions, action);
}
return actions ?? [];
}
// eslint-disable-next-line max-lines-per-function
function getCutActionsFromRectRanges(
ranges: IRectRangeWithStyle[],
@@ -555,9 +698,20 @@ export function getCutActionsFromDocRanges(
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
docDataModel: DocumentDataModel,
viewModel: DocumentViewModel,
segmentId: string
segmentId: string,
wholeBodySelected = false
): JSONXActions {
let rawActions: JSONXActions = [];
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (
body &&
Array.isArray(textRanges) &&
Array.isArray(rectRanges) &&
(wholeBodySelected || isWholeBodySelected(textRanges, rectRanges, body))
) {
return getWholeBodyCutActions(textRanges, docDataModel, segmentId);
}
if (Array.isArray(textRanges) && textRanges?.length !== 0) {
rawActions = getCutActionsFromTextRanges(textRanges, docDataModel, segmentId);
@@ -577,11 +731,84 @@ export function getCutActionsFromDocRanges(
return rawActions;
}
export function getReplaceDocRangesActions(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
docDataModel: DocumentDataModel,
viewModel: DocumentViewModel,
segmentId: string,
insertBody: IDocumentBody,
wholeBodySelected = false
) {
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const insertOffset = wholeBodySelected || (body && isWholeBodySelected(textRanges, rectRanges, body))
? 0
: getDocRangeInsertOffset(textRanges, rectRanges);
if (insertOffset == null) {
return null;
}
const cutActions = getCutActionsFromDocRanges(
textRanges,
rectRanges,
docDataModel,
viewModel,
segmentId,
wholeBodySelected
);
const textX = new TextX();
if (insertOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: insertOffset,
});
}
if (insertBody.dataStream.length > 0) {
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
}
const insertAction = JSONX.getInstance().editOp(
textX.serialize(),
getRichTextEditPath(docDataModel, segmentId)
);
const actions = insertAction == null
? cutActions
: cutActions == null || cutActions.length === 0
? insertAction
: JSONX.compose(cutActions, insertAction);
return {
actions,
insertOffset,
};
}
export function getDocRangeInsertOffset(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>
): Nullable<number> {
const ranges = [
...(Array.isArray(textRanges) ? textRanges : []),
...(Array.isArray(rectRanges) ? rectRanges : []),
].filter((range) => range.startOffset != null && range.endOffset != null);
const insertOffset = ranges.reduce(
(offset, range) => Math.min(offset, range.startOffset),
Number.POSITIVE_INFINITY
);
return Number.isFinite(insertOffset) ? insertOffset : null;
}
export interface IInnerCutCommandParams {
segmentId: string;
textRanges: ITextRangeWithStyle[];
selections?: ITextRange[];
rectRanges?: IRectRangeWithStyle[];
wholeBodySelected?: boolean;
}
export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
@@ -594,7 +821,14 @@ export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { segmentId, textRanges, selections = docSelectionManagerService.getTextRanges(), rectRanges = docSelectionManagerService.getRectRanges() } = params;
const selectionInfo = docSelectionManagerService.getSelectionInfo();
const {
segmentId,
textRanges,
selections = docSelectionManagerService.getTextRanges(),
rectRanges = docSelectionManagerService.getRectRanges(),
wholeBodySelected = selectionInfo?.options?.wholeDocument === true,
} = params;
if (
(!Array.isArray(selections) || selections.length === 0)
@@ -623,10 +857,18 @@ export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
unitId,
actions: [],
textRanges,
trigger: CutContentCommand.id,
},
};
doMutation.params.actions = getCutActionsFromDocRanges(selections, rectRanges, docDataModel, viewModel, segmentId);
doMutation.params.actions = getCutActionsFromDocRanges(
selections,
rectRanges,
docDataModel,
viewModel,
segmentId,
wholeBodySelected
);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
@@ -19,22 +19,29 @@ import type { ISuccinctDocRangeParam } from '@univerjs/engine-render';
import { CommandType, DataStreamTreeTokenType, DOC_RANGE_TYPE, getParagraphContentStartOffsets, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
interface ISelectAllCommandParams { }
interface ISelectAllCommandParams {
segmentId?: string;
wholeDocument?: boolean;
}
export const DocSelectAllCommand: ICommand<ISelectAllCommandParams> = {
id: 'doc.command.select-all',
type: CommandType.COMMAND,
handler: async (accessor) => {
handler: async (accessor, params) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const docRanges = docSelectionManagerService.getDocRanges();
const activeRange = docRanges.find((range) => range.isActive) ?? docRanges[0];
if (docDataModel == null || activeRange == null) {
if (docDataModel == null) {
return false;
}
const segmentId = params?.segmentId ?? activeRange?.segmentId;
if (segmentId == null) {
return false;
}
const { segmentId } = activeRange;
const unitId = docDataModel.getUnitId();
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
@@ -46,14 +53,26 @@ export const DocSelectAllCommand: ICommand<ISelectAllCommandParams> = {
return true;
}
const scopes = getSelectAllScopes(body, activeRange);
const currentScopeIndex = scopes.findIndex((scope) => isSameRanges(docRanges, scope));
const textRanges = scopes[Math.min(currentScopeIndex + 1, scopes.length - 1)];
let textRanges: ISuccinctDocRangeParam[];
let wholeDocument: boolean;
if (params?.wholeDocument) {
textRanges = getWholeDocumentRanges(body);
wholeDocument = true;
} else {
if (activeRange == null) {
return false;
}
const scopes = getSelectAllScopes(body, activeRange);
const currentScopeIndex = scopes.findIndex((scope) => isSameRanges(docRanges, scope));
textRanges = scopes[Math.min(currentScopeIndex + 1, scopes.length - 1)];
wholeDocument = textRanges === scopes[scopes.length - 1];
}
docSelectionManagerService.replaceDocRanges(textRanges, {
unitId,
subUnitId: unitId,
}, false);
}, false, { wholeDocument });
return true;
},
@@ -16,13 +16,14 @@
import type { DocumentDataModel, ICommand, ICommandInfo } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import { BuildTextUtils, CommandType, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, SHEET_EDITOR_UNITS, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition, getTextRunAtPosition } from '../../basics/paragraph';
import { DocIMEInputManagerService } from '../../services/doc-ime-input-manager.service';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { getDocRangeInsertOffset, getReplaceDocRangesActions } from './clipboard.inner.command';
export interface IIMEInputCommandParams {
unitId: string;
@@ -52,7 +53,7 @@ export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
return false;
}
const previousActiveRange = imeInputManagerService.getActiveRange();
const previousActiveRange = imeInputManagerService.getCompositionRange();
if (previousActiveRange == null) {
return false;
}
@@ -67,15 +68,29 @@ export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
const insertRange = previousActiveRange;
Object.assign(previousActiveRange, insertRange);
const { startOffset, endOffset } = previousActiveRange;
const previousDocRanges = imeInputManagerService.getPreviousDocRanges();
const previousRectRanges = previousDocRanges.filter(isRectRange);
const previousTextRanges = previousDocRanges.filter((range) => !isRectRange(range));
const wholeBodySelected = imeInputManagerService.getPreviousSelectionOptions()?.wholeDocument === true;
const replacesComplexSelection = isCompositionStart && (wholeBodySelected || previousRectRanges.length > 0 || previousTextRanges.length > 1);
let replacementOffset = replacesComplexSelection
? wholeBodySelected
? 0
: getDocRangeInsertOffset(previousTextRanges, previousRectRanges)
: startOffset;
if (replacementOffset == null) {
return false;
}
const len = newText.length;
const textRanges: ITextRangeWithStyle[] = [
{
startOffset: startOffset + len,
endOffset: startOffset + len,
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
style,
segmentId,
},
];
@@ -85,31 +100,83 @@ export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
unitId,
actions: [],
textRanges,
segmentId,
trigger: IMEInputCommand.id,
},
};
const defaultTextStyle = docMenuStyleService.getDefaultStyle();
const styleCache = docMenuStyleService.getStyleCache();
const curCustomRange = getCustomRangeAtPosition(body.customRanges ?? [], startOffset + oldTextLen, SHEET_EDITOR_UNITS.includes(unitId));
const styleOffset = replacesComplexSelection ? replacementOffset : startOffset + oldTextLen;
const curCustomRange = getCustomRangeAtPosition(body.customRanges ?? [], styleOffset, SHEET_EDITOR_UNITS.includes(unitId));
const curTextRun = getTextRunAtPosition(
body,
isCompositionStart ? endOffset : startOffset + oldTextLen,
replacesComplexSelection ? replacementOffset : isCompositionStart ? endOffset : startOffset + oldTextLen,
defaultTextStyle,
styleCache,
SHEET_EDITOR_UNITS.includes(unitId)
);
const customDecorations = getCustomDecorationAtPosition(body.customDecorations ?? [], startOffset + oldTextLen);
const customDecorations = getCustomDecorationAtPosition(body.customDecorations ?? [], styleOffset);
const insertBody = {
dataStream: newText,
textRuns: curTextRun
? [{
...curTextRun,
st: 0,
ed: newText.length,
}]
: [],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: newText.length - 1,
}]
: [],
customDecorations: customDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: newText.length - 1,
})),
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (!previousActiveRange.collapsed && isCompositionStart) {
if (replacesComplexSelection) {
const docSkeletonManagerService = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
if (!docSkeletonManagerService) {
return false;
}
const replacement = getReplaceDocRangesActions(
previousTextRanges,
previousRectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
segmentId ?? '',
insertBody,
wholeBodySelected
);
if (!replacement) {
return false;
}
replacementOffset = replacement.insertOffset;
doMutation.params!.actions = replacement.actions;
doMutation.params!.textRanges = [{
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
style,
segmentId,
}];
} else if (!previousActiveRange.collapsed && isCompositionStart) {
const dos = BuildTextUtils.selection.delete([previousActiveRange], body, 0, null, false);
textX.push(...dos);
doMutation.params!.textRanges = [{
startOffset: startOffset + len,
endOffset: startOffset + len,
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
segmentId,
}];
} else {
textX.push({
@@ -118,42 +185,25 @@ export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
});
}
if (oldTextLen > 0) {
if (!replacesComplexSelection && oldTextLen > 0) {
textX.push({
t: TextXActionType.DELETE,
len: oldTextLen,
});
}
textX.push({
t: TextXActionType.INSERT,
body: {
dataStream: newText,
textRuns: curTextRun
? [{
...curTextRun,
st: 0,
ed: newText.length,
}]
: [],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: newText.length - 1,
}]
: [],
customDecorations: customDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: newText.length - 1,
})),
},
len: newText.length,
});
if (!replacesComplexSelection) {
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: newText.length,
});
}
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params!.actions = jsonX.editOp(textX.serialize(), path);
if (!replacesComplexSelection) {
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params!.actions = jsonX.editOp(textX.serialize(), path);
}
doMutation.params!.noHistory = !isCompositionEnd;
@@ -165,7 +215,19 @@ export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
>(doMutation.id, doMutation.params);
imeInputManagerService.pushUndoRedoMutationParams(result, doMutation.params!);
if (replacesComplexSelection) {
imeInputManagerService.setCompositionRange({
...previousActiveRange,
startOffset: replacementOffset,
endOffset: replacementOffset,
collapsed: true,
});
}
return Boolean(result);
},
};
function isRectRange(range: ITextRangeWithStyle): range is IRectRangeWithStyle {
return 'tableId' in range;
}
@@ -14,15 +14,7 @@
* limitations under the License.
*/
import type {
DocumentDataModel,
ICommand,
IDocumentBody,
IDocumentData,
IMutationInfo,
ITextRange,
JSONXActions,
} from '@univerjs/core';
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, IMutationInfo, ITextRange, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
@@ -40,6 +32,8 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCommandSkeleton } from '../util';
import { getReplaceDocRangesActions } from './clipboard.inner.command';
export interface IReplaceSnapshotCommandParams {
unitId: string;
@@ -280,6 +274,7 @@ export interface IReplaceSelectionCommandParams {
selection?: ITextRange;
body: IDocumentBody; // Do not contain `\r\n` at the end.
textRanges?: ITextRangeWithStyle[];
segmentId?: string;
}
export const ReplaceSelectionCommand: ICommand<IReplaceSelectionCommandParams> = {
@@ -290,7 +285,7 @@ export const ReplaceSelectionCommand: ICommand<IReplaceSelectionCommandParams> =
return false;
}
const commandService = accessor.get(ICommandService);
const { unitId, body: insertBody, textRanges } = params;
const { unitId, body: insertBody, textRanges, segmentId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
@@ -298,27 +293,69 @@ export const ReplaceSelectionCommand: ICommand<IReplaceSelectionCommandParams> =
return false;
}
const body = docDataModel.getBody();
const selection = params.selection ?? docSelectionManagerService.getActiveTextRange();
const targetSegmentId = segmentId ?? docSelectionManagerService.getActiveTextRange()?.segmentId ?? '';
const body = docDataModel.getSelfOrHeaderFooterModel(targetSegmentId)?.getBody();
if (!selection || !body) {
return false;
}
const selectionInfo = docSelectionManagerService.getSelectionInfo();
const selectedTextRanges = params.selection
? [params.selection]
: docSelectionManagerService.getTextRanges() ?? [];
const selectedRectRanges = params.selection
? []
: docSelectionManagerService.getRectRanges() ?? [];
const hasSelectedStructure = !selection.collapsed && (
Boolean(body.blockRanges?.length) ||
Boolean(body.columnGroups?.length) ||
Boolean(body.customBlocks?.length) ||
Boolean(body.tables?.length)
);
const hasComplexSelection = hasSelectedStructure || selectedRectRanges.length > 0 || selectedTextRanges.length > 1 || selectionInfo?.options?.wholeDocument === true;
const docSkeletonManagerService = hasComplexSelection ? getCommandSkeleton(accessor, unitId) : null;
const replacement = docSkeletonManagerService
? getReplaceDocRangesActions(
selectedTextRanges,
selectedRectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
targetSegmentId,
insertBody,
selectionInfo?.options?.wholeDocument === true
)
: null;
const insertOffset = replacement?.insertOffset ?? selection.startOffset;
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
textRanges: textRanges ?? [{
startOffset: insertOffset + insertBody.dataStream.length,
endOffset: insertOffset + insertBody.dataStream.length,
collapsed: true,
style: docSelectionManagerService.getActiveTextRange()?.style,
segmentId: targetSegmentId,
}],
segmentId: targetSegmentId,
debounce: true,
trigger: ReplaceSelectionCommand.id,
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
// delete
textX.push(...BuildTextUtils.selection.delete([selection], body, 0, insertBody));
doMutation.params.actions = jsonX.editOp(textX.serialize());
if (replacement) {
doMutation.params.actions = replacement.actions;
} else {
const textX = new TextX();
textX.push(...BuildTextUtils.selection.delete([selection], body, 0, insertBody));
doMutation.params.actions = JSONX.getInstance().editOp(
textX.serialize(),
getRichTextEditPath(docDataModel, targetSegmentId)
);
}
return commandService.syncExecuteCommand(doMutation.id, doMutation.params);
},
};
@@ -43,12 +43,17 @@ describe('doc ime input controller', () => {
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
};
const docImeInputManagerService = {
setActiveRange: vi.fn((range) => {
storedActiveRange = range;
}),
getActiveRange: vi.fn(() => storedActiveRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
};
const commandService = {
@@ -57,6 +62,9 @@ describe('doc ime input controller', () => {
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const docSelectionManagerService = {
getSelectionInfo: vi.fn(() => ({ options: null })),
};
new DocIMEInputController(
{
@@ -68,6 +76,7 @@ describe('doc ime input controller', () => {
getSkeleton: vi.fn(() => ({})),
} as never,
docStateEmitService as never,
docSelectionManagerService as never,
commandService as never
);
@@ -123,12 +132,17 @@ describe('doc ime input controller', () => {
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
};
const docImeInputManagerService = {
setActiveRange: vi.fn((range) => {
storedActiveRange = range;
}),
getActiveRange: vi.fn(() => storedActiveRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
};
const commandService = {
@@ -137,6 +151,9 @@ describe('doc ime input controller', () => {
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const docSelectionManagerService = {
getSelectionInfo: vi.fn(() => ({ options: null })),
};
new DocIMEInputController(
{
@@ -148,6 +165,7 @@ describe('doc ime input controller', () => {
getSkeleton: vi.fn(() => ({})),
} as never,
docStateEmitService as never,
docSelectionManagerService as never,
commandService as never
);
@@ -19,9 +19,9 @@
*/
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../../services/doc-embed-integration.service';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../../services/doc-embed-integration.service';
import { DocInputController } from '../doc-input.controller';
describe('DocInputController', () => {
@@ -44,7 +44,7 @@ describe('DocInputController', () => {
})),
},
} as never,
{ onInput$ } as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
@@ -89,7 +89,7 @@ describe('DocInputController', () => {
})),
},
} as never,
{ onInput$ } as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
@@ -138,7 +138,7 @@ describe('DocInputController', () => {
})),
},
} as never,
{ onInput$ } as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
@@ -193,7 +193,7 @@ describe('DocInputController', () => {
})),
},
} as never,
{ onInput$ } as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
@@ -247,7 +247,7 @@ describe('DocInputController', () => {
})),
},
} as never,
{ onInput$ } as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
@@ -275,4 +275,55 @@ describe('DocInputController', () => {
runtimeScope.dispose();
childEditor.remove();
});
it('routes input over mixed text and table ranges through structural replacement', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn(() => Promise.resolve(true));
const activeRange = {
segmentId: '',
startOffset: 0,
endOffset: 4,
collapsed: false,
isActive: true,
};
new DocInputController(
{
unitId: 'test-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: 'text\r\n' })),
})),
},
} as never,
{
onInput$,
getAllRectRanges: vi.fn(() => [{
tableId: 'table-1',
startOffset: 5,
endOffset: 10,
}]),
} as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never
);
onInput$.next({
event: { defaultPrevented: false, data: 'x' },
content: 'x',
activeRange,
rangeList: [activeRange],
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.replace-selection', expect.objectContaining({
unitId: 'test-doc',
segmentId: '',
body: expect.objectContaining({ dataStream: 'x' }),
}));
});
});
@@ -244,7 +244,7 @@ describe('doc paragraph placeholder render controller', () => {
const page = createPage([createLine(0, { fontSize: 13, fontFamily: 'Inter' })]);
const body = createBody('\r\n', [{ startIndex: 0, paragraphId: 'para_placeholder_normal' }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 0);
expect(placeholders).toMatchObject([{
text: '请输入文字或按"/"启用命令',
@@ -260,7 +260,7 @@ describe('doc paragraph placeholder render controller', () => {
const page = createPage([createLine(0, { fontSize: 18 })]);
const body = createBody('\r\n', [{ startIndex: 0, paragraphId: 'para_placeholder_large' }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 0);
expect(placeholders[0]).toMatchObject({
fontSize: 18,
@@ -277,7 +277,7 @@ describe('doc paragraph placeholder render controller', () => {
},
}]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 0);
expect(placeholders[0]).toMatchObject({
text: '标题1',
@@ -298,7 +298,7 @@ describe('doc paragraph placeholder render controller', () => {
},
}]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 0);
expect(placeholders[0]).toMatchObject({
text: '项目',
@@ -311,7 +311,7 @@ describe('doc paragraph placeholder render controller', () => {
const page = createPage([createLine(5, { st: 0 })]);
const body = createBody('Hello\r\n', [{ startIndex: 5, paragraphId: 'para_placeholder_text' }]);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale);
const placeholders = getParagraphPlaceholderLayouts(page, body, locale, 0, 0, 0);
expect(placeholders).toEqual([]);
});
@@ -24,7 +24,7 @@ import {
Inject,
Tools,
} from '@univerjs/core';
import { DocSkeletonManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { DocSelectionManagerService, DocSkeletonManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IMEInputCommand } from '../../commands/commands/ime-input.command';
import { DocIMEInputManagerService } from '../../services/doc-ime-input-manager.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
@@ -46,6 +46,7 @@ export class DocIMEInputController extends Disposable implements IRenderModule {
@Inject(DocIMEInputManagerService) private readonly _docImeInputManagerService: DocIMEInputManagerService,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocStateEmitService) private readonly _docStateEmitService: DocStateEmitService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
@@ -75,13 +76,20 @@ export class DocIMEInputController extends Disposable implements IRenderModule {
this._resetIME();
const { activeRange } = config;
const { activeRange, rangeList = [] } = config;
if (activeRange == null) {
return;
}
this._docImeInputManagerService.setActiveRange(Tools.deepClone(activeRange));
this._docImeInputManagerService.setPreviousDocRanges(Tools.deepClone([
...rangeList,
...this._docSelectionRenderService.getAllRectRanges(),
]));
this._docImeInputManagerService.setPreviousSelectionOptions(
Tools.deepClone(this._docSelectionManagerService.getSelectionInfo()?.options ?? null)
);
});
}
@@ -164,11 +172,16 @@ export class DocIMEInputController extends Disposable implements IRenderModule {
trigger: IMEInputCommand.id,
redoState: {
actions: [] as JSONXActions,
textRanges: [previousActiveRange],
textRanges: this._docImeInputManagerService.getPreviousDocRanges().length
? this._docImeInputManagerService.getPreviousDocRanges()
: [previousActiveRange],
},
undoState: {
actions: [] as JSONXActions,
textRanges: [previousActiveRange],
textRanges: this._docImeInputManagerService.getPreviousDocRanges().length
? this._docImeInputManagerService.getPreviousDocRanges()
: [previousActiveRange],
options: this._docImeInputManagerService.getPreviousSelectionOptions() ?? undefined,
},
isCompositionEnd: true,
});
@@ -182,5 +195,7 @@ export class DocIMEInputController extends Disposable implements IRenderModule {
this._docImeInputManagerService.clearUndoRedoMutationParamsCache();
this._docImeInputManagerService.setActiveRange(null);
this._docImeInputManagerService.setPreviousDocRanges([]);
this._docImeInputManagerService.setPreviousSelectionOptions(null);
}
}
@@ -22,8 +22,9 @@ import { Disposable, ICommandService, Inject, Optional, SHEET_EDITOR_UNITS } fro
import { DocSkeletonManagerService, InsertTextCommand } from '@univerjs/docs';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition, getTextRunAtPosition } from '../../basics/paragraph';
import { AfterSpaceCommand } from '../../commands/commands/auto-format.command';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { ReplaceSelectionCommand } from '../../commands/commands/replace-content.command';
import { IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../../services/doc-embed-integration.service';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
export class DocInputController extends Disposable implements IRenderModule {
@@ -61,7 +62,7 @@ export class DocInputController extends Disposable implements IRenderModule {
const { unitId } = this._context;
const { event, content = '', activeRange } = config;
const { event, content = '', activeRange, rangeList = [] } = config;
const e = event as InputEvent;
if (e.defaultPrevented) {
@@ -93,35 +94,52 @@ export class DocInputController extends Disposable implements IRenderModule {
const curTextRun = getTextRunAtPosition(originBody, activeRange.endOffset, defaultTextStyle, cacheStyle, SHEET_EDITOR_UNITS.includes(unitId));
const curCustomDecorations = getCustomDecorationAtPosition(originBody?.customDecorations ?? [], activeRange.endOffset);
await this._commandService.executeCommand<IInsertTextCommandParams>(InsertTextCommand.id, {
unitId,
body: {
dataStream: content,
textRuns: curTextRun
? [
{
...curTextRun,
st: 0,
ed: content.length,
},
]
: [],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: content.length - 1,
}]
: [],
customDecorations: curCustomDecorations.map((customDecoration) => ({
...customDecoration,
const insertBody = {
dataStream: content,
textRuns: curTextRun
? [
{
...curTextRun,
st: 0,
ed: content.length,
},
]
: [],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: content.length - 1,
})),
},
range: activeRange,
segmentId,
});
}]
: [],
customDecorations: curCustomDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: content.length - 1,
})),
};
const hasSelectedStructure = !activeRange.collapsed && (
Boolean(originBody.blockRanges?.length) ||
Boolean(originBody.columnGroups?.length) ||
Boolean(originBody.customBlocks?.length) ||
Boolean(originBody.tables?.length)
);
const hasComplexSelection = hasSelectedStructure || rangeList.length > 1 || this._docSelectionRenderService.getAllRectRanges().length > 0;
if (hasComplexSelection) {
await this._commandService.executeCommand(ReplaceSelectionCommand.id, {
unitId,
body: insertBody,
segmentId,
});
} else {
await this._commandService.executeCommand<IInsertTextCommandParams>(InsertTextCommand.id, {
unitId,
body: insertBody,
range: activeRange,
segmentId,
});
}
// Space
if (content === ' ') {
@@ -107,6 +107,11 @@ export class DocParagraphPlaceholderRenderController extends Disposable implemen
}
this.disposeWithMe(documents.pageRender$.subscribe((pageRenderConfig) => this._drawPagePlaceholders(pageRenderConfig)));
this.disposeWithMe(this._docSelectionManagerService.textSelection$.subscribe(({ unitId }) => {
if (unitId === this._context.unitId) {
documents.makeDirty(true);
}
}));
}
private _drawPagePlaceholders({ page, pageLeft, pageTop, ctx }: IPageRenderConfig): void {
@@ -119,6 +124,13 @@ export class DocParagraphPlaceholderRenderController extends Disposable implemen
if (!activeRange || (activeRange.segmentId ?? '') !== (page.segmentId ?? '')) {
return;
}
if (
activeRange.startOffset == null ||
activeRange.endOffset == null ||
activeRange.startOffset !== activeRange.endOffset
) {
return;
}
const placeholders = getParagraphPlaceholderLayouts(
page,
@@ -174,7 +186,7 @@ export function getParagraphPlaceholderLayouts(
locale: IParagraphPlaceholderLocale,
pageLeft = 0,
pageTop = 0,
activeOffset?: number,
activeOffset: number,
options?: IParagraphPlaceholderLayoutOptions
): IParagraphPlaceholderLayout[] {
const paragraphs = new Map((body.paragraphs ?? []).map((paragraph) => [paragraph.startIndex, paragraph]));
@@ -258,7 +270,7 @@ function visitColumn(
layouts: IParagraphPlaceholderLayout[],
originLeft: number,
originTop: number,
activeOffset?: number,
activeOffset: number,
clip?: IParagraphPlaceholderClip
): void {
for (const line of column.lines) {
@@ -268,7 +280,7 @@ function visitColumn(
const paragraphStart = line.st;
const paragraphEnd = line.paragraphIndex;
if (activeOffset != null && !isOffsetInParagraph(activeOffset, paragraphStart, paragraphEnd)) {
if (!isOffsetInParagraph(activeOffset, paragraphStart, paragraphEnd)) {
continue;
}
@@ -32,13 +32,24 @@ describe('DocIMEInputManagerService', () => {
service.pushUndoRedoMutationParams(
{ unitId: 'doc-1', actions: jsonX.insertOp(['settings'], { zoomRatio: 1 }), textRanges: [] } as never,
{ unitId: 'doc-1', actions: jsonX.replaceOp(['id'], 'doc-1', 'doc-2'), textRanges: [] } as never
{
unitId: 'doc-1',
actions: jsonX.replaceOp(['id'], 'doc-1', 'doc-2'),
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
segmentId: 'header-1',
options: { wholeDocument: false },
} as never
);
expect(service.fetchComposedUndoRedoMutationParams()).toMatchObject({
previousActiveRange: activeRange,
undoMutationParams: { unitId: 'doc-1' },
redoMutationParams: { unitId: 'doc-1' },
redoMutationParams: {
unitId: 'doc-1',
segmentId: 'header-1',
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
options: { wholeDocument: false },
},
});
});
@@ -47,7 +47,7 @@ describe('DocIMEStateChangeInterceptorService', () => {
{
unitId,
actions: jsonX.replaceOp(['id'], unitId, `${unitId}-editing`),
textRanges: [],
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
} as never
);
}
@@ -92,7 +92,10 @@ describe('DocIMEStateChangeInterceptorService', () => {
redoState: { actions: [] },
undoState: { actions: [], textRanges: [] },
} as never)).toMatchObject({
redoState: { actions: jsonX.replaceOp(['id'], 'doc-1', 'doc-1-editing') },
redoState: {
actions: jsonX.replaceOp(['id'], 'doc-1', 'doc-1-editing'),
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
},
undoState: {
actions: jsonX.insertOp(['settings'], { zoomRatio: 1 }),
textRanges: [{ startOffset: 1, endOffset: 2 }],
@@ -112,7 +115,10 @@ describe('DocIMEStateChangeInterceptorService', () => {
redoState: { actions: [] },
undoState: { actions: [], textRanges: [] },
} as never)).toMatchObject({
redoState: { actions: jsonX.replaceOp(['id'], 'doc-sync', 'doc-sync-editing') },
redoState: {
actions: jsonX.replaceOp(['id'], 'doc-sync', 'doc-sync-editing'),
textRanges: [{ startOffset: 3, endOffset: 3, collapsed: true }],
},
undoState: {
actions: jsonX.insertOp(['settings'], { zoomRatio: 1 }),
textRanges: [{ startOffset: 1, endOffset: 2 }],
@@ -120,6 +126,32 @@ describe('DocIMEStateChangeInterceptorService', () => {
});
});
it('restores every fragmented range and whole-document intent on IME undo', () => {
const imeInputManager = createImeInputManager('doc-1');
imeInputManager.setPreviousDocRanges([
{ startOffset: 0, endOffset: 2, collapsed: false } as never,
{ startOffset: 5, endOffset: 8, collapsed: false } as never,
]);
imeInputManager.setPreviousSelectionOptions({ wholeDocument: true });
registerImeRenderUnit('doc-1', imeInputManager);
expect(service.transformChangeStateInfo({
unitId: 'doc-1',
isCompositionEnd: true,
isSync: false,
redoState: { actions: [] },
undoState: { actions: [], textRanges: [] },
} as never)).toMatchObject({
undoState: {
textRanges: [
{ startOffset: 0, endOffset: 2, collapsed: false },
{ startOffset: 5, endOffset: 8, collapsed: false },
],
options: { wholeDocument: true },
},
});
});
it('drops composition state when the render unit no longer has an IME manager', () => {
TestRenderManagerService.renderUnits.delete('doc-1');
@@ -352,14 +352,16 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
});
const activeRange = this._docSelectionManagerService.getActiveTextRange();
const { segmentId, endOffset: activeEndOffset, style } = activeRange || {};
const ranges = this._docSelectionManagerService.getTextRanges();
const docRanges = this._docSelectionManagerService.getDocRanges();
const insertionAnchor = activeRange ?? docRanges.find((range) => range.isActive) ?? docRanges[0];
const { segmentId, endOffset: activeEndOffset, style } = insertionAnchor || {};
const ranges = this._docSelectionManagerService.getTextRanges() ?? [];
if (segmentId == null) {
this._logService.error('[DocClipboardController] segmentId does not exist!');
}
if (activeEndOffset == null || ranges == null) {
if (activeEndOffset == null) {
return false;
}
@@ -29,6 +29,12 @@ interface ICacheParams {
export class DocIMEInputManagerService extends RxDisposable implements IRenderModule {
private _previousActiveRange: Nullable<ITextRangeWithStyle> = null;
private _previousDocRanges: ITextRangeWithStyle[] = [];
private _compositionRange: Nullable<ITextRangeWithStyle> = null;
private _previousSelectionOptions: Nullable<{ [key: string]: boolean }> = null;
private _undoMutationParamsCache: IRichTextEditingMutationParams[] = [];
private _redoMutationParamsCache: IRichTextEditingMutationParams[] = [];
@@ -62,6 +68,31 @@ export class DocIMEInputManagerService extends RxDisposable implements IRenderMo
setActiveRange(range: Nullable<ITextRangeWithStyle>) {
this._previousActiveRange = range;
this._compositionRange = range;
}
getCompositionRange(): Nullable<ITextRangeWithStyle> {
return this._compositionRange;
}
setCompositionRange(range: Nullable<ITextRangeWithStyle>): void {
this._compositionRange = range;
}
getPreviousDocRanges(): ITextRangeWithStyle[] {
return this._previousDocRanges;
}
setPreviousDocRanges(ranges: ITextRangeWithStyle[]): void {
this._previousDocRanges = ranges;
}
getPreviousSelectionOptions(): Nullable<{ [key: string]: boolean }> {
return this._previousSelectionOptions;
}
setPreviousSelectionOptions(options: Nullable<{ [key: string]: boolean }>): void {
this._previousSelectionOptions = options;
}
pushUndoRedoMutationParams(undoParams: IRichTextEditingMutationParams, redoParams: IRichTextEditingMutationParams) {
@@ -75,6 +106,8 @@ export class DocIMEInputManagerService extends RxDisposable implements IRenderMo
}
const { unitId } = this._undoMutationParamsCache[0];
const firstUndoParams = this._undoMutationParamsCache[0];
const lastRedoParams = this._redoMutationParamsCache.at(-1);
const undoMutationParams: IRichTextEditingMutationParams = {
unitId,
@@ -82,6 +115,7 @@ export class DocIMEInputManagerService extends RxDisposable implements IRenderMo
return JSONX.compose(acc, cur.actions);
}, null as JSONXActions),
textRanges: [], // Add empty array, will never use, just fix type error
segmentId: firstUndoParams.segmentId,
};
const redoMutationParams: IRichTextEditingMutationParams = {
@@ -89,10 +123,19 @@ export class DocIMEInputManagerService extends RxDisposable implements IRenderMo
actions: this._redoMutationParamsCache.reduce((acc, cur) => {
return JSONX.compose(acc, cur.actions);
}, null as JSONXActions),
textRanges: [], // Add empty array, will never use, just fix type error
textRanges: lastRedoParams?.textRanges ?? [],
segmentId: lastRedoParams?.segmentId,
options: lastRedoParams?.options,
isEditing: lastRedoParams?.isEditing,
};
return { redoMutationParams, undoMutationParams, previousActiveRange: this._previousActiveRange };
return {
redoMutationParams,
undoMutationParams,
previousActiveRange: this._previousActiveRange,
previousDocRanges: this._previousDocRanges,
previousSelectionOptions: this._previousSelectionOptions,
};
}
override dispose(): void {
@@ -100,5 +143,8 @@ export class DocIMEInputManagerService extends RxDisposable implements IRenderMo
this._redoMutationParamsCache = [];
this._previousActiveRange = null;
this._previousDocRanges = [];
this._compositionRange = null;
this._previousSelectionOptions = null;
}
}
@@ -43,18 +43,22 @@ export class DocIMEStateChangeInterceptorService implements IDocStateChangeInter
throw new Error('historyParams is null in RichTextEditingMutation');
}
const { undoMutationParams, redoMutationParams, previousActiveRange } = historyParams;
const { undoMutationParams, redoMutationParams, previousActiveRange, previousDocRanges, previousSelectionOptions } = historyParams;
return {
...changeStateInfo,
redoState: {
...changeStateInfo.redoState,
actions: redoMutationParams.actions,
textRanges: redoMutationParams.textRanges,
options: redoMutationParams.options,
isEditing: redoMutationParams.isEditing,
},
undoState: {
...changeStateInfo.undoState,
actions: undoMutationParams.actions,
textRanges: [previousActiveRange],
textRanges: previousDocRanges.length ? previousDocRanges : [previousActiveRange],
options: previousSelectionOptions ?? undefined,
},
};
}
@@ -648,6 +648,29 @@ describe('doc selection render service internals', () => {
expect(service._rangeList).toEqual([cursorRange]);
});
it('deactivates structural carets when document ranges contain a visible selection', () => {
const { service } = createService();
const leadingCaret = createTextRange({ collapsed: true });
const expandedRange = createTextRange({ collapsed: false });
const trailingCaret = createTextRange({ collapsed: true });
cursorConvertToTextRangeMock
.mockReturnValueOnce(leadingCaret)
.mockReturnValueOnce(trailingCaret);
getTextRangeFromCharIndexMock.mockReturnValueOnce(expandedRange);
service.addDocRanges([
{ startOffset: 2, endOffset: 2, rangeType: DOC_RANGE_TYPE.TEXT },
{ startOffset: 4, endOffset: 8, rangeType: DOC_RANGE_TYPE.TEXT },
{ startOffset: 10, endOffset: 10, rangeType: DOC_RANGE_TYPE.TEXT },
], false, { shouldFocus: false });
expect(leadingCaret.deactivate).toHaveBeenCalled();
expect(trailingCaret.deactivate).toHaveBeenCalled();
expect(expandedRange.activate).toHaveBeenCalledTimes(2);
expect(service._rangeList).toEqual([leadingCaret, expandedRange, trailingCaret]);
});
it('sets the cursor manually from the resolved paragraph node and emits selection state', () => {
const { service } = createService();
const position = { glyph: 3 };
@@ -25,6 +25,9 @@ function getGetter<T extends object>(target: T, key: keyof T) {
interface IFakeShape {
dispose: ReturnType<typeof vi.fn>;
hide?: ReturnType<typeof vi.fn>;
setProps?: ReturnType<typeof vi.fn>;
show?: ReturnType<typeof vi.fn>;
}
interface IFakeTextRange {
@@ -36,7 +39,7 @@ interface IFakeTextRange {
_anchorShape: IFakeShape;
_docSkeleton: Record<string, unknown>;
anchorNodePosition: Record<string, unknown>;
focusNodePosition: Record<string, unknown>;
focusNodePosition: Record<string, unknown> | null;
}
interface IFakeRectRange {
@@ -223,7 +226,7 @@ describe('selection range state', () => {
_segmentPage: -1,
_current: false,
_rangeShape: { dispose: vi.fn() },
_anchorShape: { dispose: vi.fn() },
_anchorShape: { dispose: vi.fn(), hide: vi.fn(), show: vi.fn(), setProps: vi.fn() },
_docSkeleton: {
getViewModel: () => ({
getDataModel: () => ({
@@ -245,10 +248,13 @@ describe('selection range state', () => {
expect(getGetter(TextRange.prototype, 'direction').call(fakeRange)).toBe('none');
expect(TextRange.prototype.isActive.call(fakeRange)).toBe(false);
fakeRange.focusNodePosition = null;
TextRange.prototype.activate.call(fakeRange);
expect(TextRange.prototype.isActive.call(fakeRange)).toBe(true);
expect(fakeRange._anchorShape.show).toHaveBeenCalledTimes(1);
TextRange.prototype.deactivate.call(fakeRange);
expect(TextRange.prototype.isActive.call(fakeRange)).toBe(false);
expect(fakeRange._anchorShape.hide).toHaveBeenCalledTimes(1);
const textRangeShape = fakeRange._rangeShape;
const textAnchorShape = fakeRange._anchorShape;
TextRange.prototype.dispose.call(fakeRange);
@@ -15,27 +15,20 @@
*/
import type { DocumentDataModel, Nullable } from '@univerjs/core';
import type {
Documents,
Engine,
IDocSelectionInnerParam,
IFindNodeRestrictions,
IMouseEvent,
INodeInfo,
INodePosition,
IPointerEvent,
IRenderContext,
IRenderModule,
IScrollObserverParam,
ISuccinctDocRangeParam,
ITextRangeWithStyle,
ITextSelectionStyle,
} from '@univerjs/engine-render';
import type { Documents, Engine, IDocSelectionInnerParam, IFindNodeRestrictions, IMouseEvent, INodeInfo, INodePosition, IPointerEvent, IRenderContext, IRenderModule, IScrollObserverParam, ISuccinctDocRangeParam, ITextRangeWithStyle, ITextSelectionStyle } from '@univerjs/engine-render';
import type { Subscription } from 'rxjs';
import type { RectRange } from './rect-range';
import { DataStreamTreeTokenType, DOC_RANGE_TYPE, ILogService, Inject, isInternalEditorID, IUniverInstanceService, Optional, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { CURSOR_TYPE, getSystemHighlightColor, GlyphType, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, PageLayoutType, ScrollTimer, Vector2 } from '@univerjs/engine-render';
import {
CURSOR_TYPE,
getSystemHighlightColor,
GlyphType,
NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
PageLayoutType,
ScrollTimer,
Vector2,
} from '@univerjs/engine-render';
import { ILayoutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, filter, fromEvent, merge, Subject, takeUntil } from 'rxjs';
import { DOC_EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, IDocEmbedInteractionBoundaryService, IDocEmbedRuntimeFocusCoordinator } from '../doc-embed-integration.service';
@@ -343,6 +336,8 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
}
}
this._hideCollapsedCaretsForVisibleSelection();
this._textSelectionInner$.next({
textRanges: this._getAllTextRanges(),
rectRanges: this._getAllRectRanges(),
@@ -921,6 +916,16 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
this._rangeList = newRanges;
}
private _hideCollapsedCaretsForVisibleSelection() {
const expandedTextRanges = this._rangeList.filter((range) => !range.collapsed);
if (expandedTextRanges.length === 0 && this._rectRangeList.length === 0) {
return;
}
this._deactivateAllTextRanges();
expandedTextRanges[expandedTextRanges.length - 1]?.activate();
}
private _deactivateAllTextRanges() {
this._rangeList.forEach((range) => {
range.deactivate();
@@ -1259,11 +1264,11 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
if (this._shouldSuppressHostHiddenEditorEvent(e)) {
return;
}
// Prevent input when there is any rect ranges.
// A mixed text and rect selection can replace the selected document ranges.
if ((e as InputEvent).inputType === 'historyUndo' || (e as InputEvent).inputType === 'historyRedo') {
return;
}
if (this._rectRangeList.length > 0) {
if (this._rectRangeList.length > 0 && this._getActiveRange() == null) {
e.stopPropagation();
return e.preventDefault();
}
@@ -1284,8 +1289,8 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
if (this._shouldSuppressHostHiddenEditorEvent(e)) {
return;
}
// Prevent input when there is any rect ranges.
if (this._rectRangeList.length > 0) {
// Mixed select-all ranges have a text insertion anchor; table-only rect selections do not.
if (this._rectRangeList.length > 0 && this._getActiveRange() == null) {
e.stopPropagation();
return e.preventDefault();
}
@@ -317,10 +317,19 @@ export class TextRange implements IDocRange {
activate() {
this._current = true;
if (this._isCollapsed()) {
this._anchorShape?.show();
this.activeStatic();
}
}
deactivate() {
this._current = false;
if (this._isCollapsed()) {
this._anchorShape?.hide();
}
}
dispose() {
@@ -117,6 +117,8 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const docRanges = docSelectionManagerService.getDocRanges() ?? [];
// Capture selection intent before applying actions so undo can restore structural selections.
const selectionInfo = docSelectionManagerService.getSelectionInfo();
// TODO: `disabled` is only used for read only demo, and will be removed in the future.
const disabled = !!documentDataModel.getSnapshot().disabled;
@@ -162,10 +164,14 @@ export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams,
redoState: {
actions,
textRanges,
options: params.options,
isEditing,
},
undoState: {
actions: undoActions,
textRanges: prevTextRanges ?? docRanges,
options: selectionInfo?.options,
isEditing: selectionInfo?.isEditing,
},
isCompositionEnd,
isSync,
@@ -125,7 +125,7 @@ describe('DocStateChangeManagerService', () => {
it('debounces history and collaboration flushing for composed edits', () => {
vi.useFakeTimers();
const { service, emitter, undoRedoService } = createService();
const { service, emitter } = createService();
const changes: unknown[] = [];
const sub = service.docStateChange$.subscribe((value) => changes.push(value));
@@ -140,6 +140,66 @@ describe('DocStateChangeManagerService', () => {
sub.unsubscribe();
});
it('preserves segment and whole-document selection state in undo and redo mutations', () => {
const { emitter, undoRedoService, univerInstanceService } = createService();
univerInstanceService.__addUnit(new DocumentDataModel({ id: 'doc-1' }));
univerInstanceService.focusUnit('doc-1');
emitter.emitStateChangeInfo(createChange({
segmentId: 'header-1',
redoState: {
actions: null as never,
textRanges: [{ startOffset: 2, endOffset: 2, collapsed: true }],
options: { wholeDocument: false },
isEditing: true,
},
undoState: {
actions: null as never,
textRanges: [{ startOffset: 0, endOffset: 4, collapsed: false }],
options: { wholeDocument: true },
isEditing: false,
},
}));
expect(undoRedoService.pitchTopUndoElement()).toMatchObject({
undoMutations: [{
params: {
segmentId: 'header-1',
options: { wholeDocument: true },
isEditing: false,
},
}],
redoMutations: [{
params: {
segmentId: 'header-1',
options: { wholeDocument: false },
isEditing: true,
},
}],
});
});
it('flushes debounced history before switching document segments', () => {
vi.useFakeTimers();
const { service, emitter, undoRedoService, univerInstanceService } = createService();
univerInstanceService.__addUnit(new DocumentDataModel({ id: 'doc-1' }));
univerInstanceService.focusUnit('doc-1');
emitter.emitStateChangeInfo(createChange({ segmentId: 'header-1', debounce: true }));
emitter.emitStateChangeInfo(createChange({ segmentId: '', debounce: true }));
expect(undoRedoService.pitchTopUndoElement()).toMatchObject({
undoMutations: [{ params: { segmentId: 'header-1' } }],
redoMutations: [{ params: { segmentId: 'header-1' } }],
});
expect(service.getStateCache('doc-1').history).toEqual([
expect.objectContaining({ segmentId: '' }),
]);
vi.advanceTimersByTime(300);
expect(service.getStateCache('doc-1')).toEqual({ history: [], collaboration: [] });
});
it('can restore pre-existing state caches for a document', () => {
const { service } = createService();
const state = createChange();
@@ -137,6 +137,12 @@ export class DocStateChangeManagerService extends RxDisposable {
? this._pushHistory.bind(this)
: this._emitChangeState.bind(this);
const pendingStates = stateCache.get(unitId);
// Switching segments ends the current debounce group so body, header, and footer edits keep separate history entries.
if (pendingStates?.length && pendingStates[pendingStates.length - 1].segmentId !== changeState.segmentId) {
cb(unitId);
}
if (stateCache.has(unitId)) {
const cacheStates = stateCache.get(unitId);
@@ -187,6 +193,9 @@ export class DocStateChangeManagerService extends RxDisposable {
unitId,
actions: cacheStates.reduce((acc, cur) => JSONX.compose(acc, cur.redoState.actions), null as JSONXActions),
textRanges: lastState.redoState.textRanges,
segmentId: lastState.segmentId,
options: lastState.redoState.options,
isEditing: lastState.redoState.isEditing,
};
const undoParams: IRichTextEditingMutationParams = {
@@ -194,6 +203,9 @@ export class DocStateChangeManagerService extends RxDisposable {
// Always need to put undoParams after redoParams, because `reverse` will change the `cacheStates` order.
actions: cacheStates.reverse().reduce((acc, cur) => JSONX.compose(acc, cur.undoState.actions), null as JSONXActions),
textRanges: firstState.undoState.textRanges,
segmentId: firstState.segmentId,
options: firstState.undoState.options,
isEditing: firstState.undoState.isEditing,
};
undoRedoService.pushUndoRedo({
@@ -224,6 +236,9 @@ export class DocStateChangeManagerService extends RxDisposable {
unitId,
actions: cacheStates.reduce((acc, cur) => JSONX.compose(acc, cur.redoState.actions), null as JSONXActions),
textRanges: lastState.redoState.textRanges,
segmentId: lastState.segmentId,
options: lastState.redoState.options,
isEditing: lastState.redoState.isEditing,
};
const undoState: IRichTextEditingMutationParams = {
@@ -231,6 +246,9 @@ export class DocStateChangeManagerService extends RxDisposable {
// Always need to put undoParams after redoParams, because `reverse` will change the `cacheStates` order.
actions: cacheStates.reverse().reduce((acc, cur) => JSONX.compose(acc, cur.undoState.actions), null as JSONXActions),
textRanges: firstState.undoState.textRanges,
segmentId: firstState.segmentId,
options: firstState.undoState.options,
isEditing: firstState.undoState.isEditing,
};
const changeState: IDocStateChangeParams = {
@@ -22,6 +22,8 @@ import { BehaviorSubject } from 'rxjs';
interface IDocChangeState {
actions: JSONXActions;
textRanges: Nullable<ITextRangeWithStyle[]>;
options?: { [key: string]: boolean };
isEditing?: boolean;
}
export interface IDocStateChangeParams {
@@ -106,6 +106,8 @@ export interface IDocumentSkeletonPage {
breakType: BreakType; // type of page break
st: number; // startIndex
ed: number; // endIndex
/** Whether this cell page is only a layout placeholder covered by a merged cell. */
isMergedCellCovered?: boolean;
skeDrawings: Map<string, IDocumentSkeletonDrawing>;
skeTables: Map<string, IDocumentSkeletonTable>; // table skeletons in the page
skeColumnGroups: Map<string, IDocumentSkeletonColumnGroup>; // column group skeletons in the page
@@ -169,10 +169,14 @@ describe('doc skeleton', () => {
const header = createPage(DocumentSkeletonPageType.HEADER, 200);
const footer = createPage(DocumentSkeletonPageType.FOOTER, 300);
const cell = createPage(DocumentSkeletonPageType.CELL, 100, 'table-1');
const coveredCell = createPage(DocumentSkeletonPageType.CELL, 0, 'table-1');
coveredCell.page.ed = 0;
coveredCell.page.isMergedCellCovered = true;
const row = { cells: [cell.page] } as any;
const row = { cells: [coveredCell.page, cell.page] } as any;
const table = { rows: [row], tableId: 'table-1' } as any;
row.parent = table;
coveredCell.page.parent = row;
cell.page.parent = row;
table.parent = body.page;
body.page.skeTables = new Map([['table-1', table]]);
@@ -212,7 +216,7 @@ describe('doc skeleton', () => {
const cellPos = skeleton.findPositionByGlyph(cell.glyphs.glyphA as any, 0);
expect(cellPos?.pageType).toBe(DocumentSkeletonPageType.CELL);
expect(cellPos?.path).toEqual(['pages', 0, 'skeTables', 'table-1', 'rows', 0, 'cells', 0]);
expect(cellPos?.path).toEqual(['pages', 0, 'skeTables', 'table-1', 'rows', 0, 'cells', 1]);
const byBodyCoord = skeleton.findGlyphByPosition({
pageType: DocumentSkeletonPageType.BODY,
@@ -308,6 +312,7 @@ describe('doc skeleton', () => {
const nodePosBody = skeleton.findNodePositionByCharIndex(2, true);
expect(nodePosBody?.pageType).toBe(DocumentSkeletonPageType.BODY);
expect(skeleton.findNodePositionByCharIndex(0)?.pageType).toBe(DocumentSkeletonPageType.BODY);
const nodePosHeader = skeleton.findNodePositionByCharIndex(201, false, 'header-seg', 0);
expect(nodePosHeader?.pageType).toBe(DocumentSkeletonPageType.HEADER);
const nodePosFooter = skeleton.findNodePositionByCharIndex(301, false, 'footer-seg', 0);
@@ -316,6 +321,7 @@ describe('doc skeleton', () => {
expect(nodePosCell?.pageType).toBe(DocumentSkeletonPageType.CELL);
expect(skeleton.findNodeByCharIndex(2)).toBe(body.glyphs.glyphB);
expect(skeleton.findNodeByCharIndex(0)).toBe(body.glyphs.listGlyph);
expect(skeleton.findNodeByCharIndex(201, 'header-seg', 0)).toBe(header.glyphs.glyphA);
expect(skeleton.findNodeByCharIndex(999)).toBeUndefined();
@@ -463,6 +463,12 @@ describe('docs layout tools extra', () => {
marginLeft: 30,
marginRight: 40,
renderConfig: { isRenderStyle: BooleanNumber.TRUE },
defaultHeaderId: 'section-header',
defaultFooterId: 'section-footer',
evenPageHeaderId: 'section-even-header',
evenPageFooterId: 'section-even-footer',
firstPageHeaderId: 'section-first-header',
firstPageFooterId: 'section-first-footer',
};
}
return { sectionType: SectionType.CONTINUOUS };
@@ -477,15 +483,26 @@ describe('docs layout tools extra', () => {
marginTop: 20,
marginBottom: 20,
renderConfig: {},
defaultHeaderId: 'document-header',
defaultFooterId: 'document-footer',
},
},
};
const sectionConfig = prepareSectionBreakConfig(ctx as any, 0);
expect(sectionConfig.pageSize?.width).toBeGreaterThan(0);
expect(sectionConfig.headerIds).toEqual(expect.objectContaining({
defaultHeaderId: expect.any(String),
}));
expect(sectionConfig.headerIds).toEqual({
defaultHeaderId: '',
evenPageHeaderId: '',
firstPageHeaderId: '',
});
expect(sectionConfig.footerIds).toEqual({
defaultFooterId: '',
evenPageFooterId: '',
firstPageFooterId: '',
});
expect(sectionConfig.evenAndOddHeaders).toBe(BooleanNumber.FALSE);
expect(sectionConfig.useFirstPageHeaderFooter).toBe(BooleanNumber.FALSE);
const dirtyCtx = {
isDirty: true,
@@ -202,6 +202,13 @@ describe('section', () => {
],
});
expect(columnGroup.height).toBeGreaterThan(0);
expect(columnGroup.columns.map(({ page: columnPage }) => ({
marginLeft: columnPage.marginLeft,
marginRight: columnPage.marginRight,
}))).toEqual([
{ marginLeft: 8, marginRight: 8 },
{ marginLeft: 8, marginRight: 8 },
]);
expect(columnGroup.columns[0].page.sections[0].columns[0].lines.length).toBeGreaterThan(0);
expect(columnGroup.columns[0].page.sections[0].columns[0].lines[0].borderBottom).toMatchObject({
color: { rgb: '#336699' },
@@ -42,6 +42,7 @@ interface IColumnGroupLayout {
}
const EMPTY_COLUMN_GROUP_MIN_HEIGHT = 72;
const COLUMN_GROUP_HORIZONTAL_PADDING = 8;
export function createColumnGroupSkeleton(
ctx: ILayoutContext,
@@ -137,8 +138,8 @@ function createColumnContentPage(
},
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
marginLeft: COLUMN_GROUP_HORIZONTAL_PADDING,
marginRight: COLUMN_GROUP_HORIZONTAL_PADDING,
columnProperties: [],
};
const page = createSkeletonPage(ctx, columnSectionBreakConfig, ctx.skeletonResourceReference);
@@ -235,6 +235,10 @@ function resolveMostSpecificPageByCharIndex(page: IDocumentSkeletonPage, charInd
for (const table of page.skeTables?.values() ?? []) {
for (const row of table.rows) {
for (const cell of row.cells) {
if (cell.isMergedCellCovered) {
continue;
}
const { st, ed } = cell;
if (charIndex >= st && charIndex <= ed) {
@@ -1721,6 +1721,14 @@ const DEFAULT_MODERN_SECTION_BREAK: Partial<ISectionBreak> = {
columnProperties: [],
columnSeparatorType: ColumnSeparatorType.NONE,
sectionType: SectionType.SECTION_TYPE_UNSPECIFIED,
defaultHeaderId: '',
defaultFooterId: '',
evenPageHeaderId: '',
evenPageFooterId: '',
firstPageHeaderId: '',
firstPageFooterId: '',
evenAndOddHeaders: BooleanNumber.FALSE,
useFirstPageHeaderFooter: BooleanNumber.FALSE,
};
export function prepareSectionBreakConfig(ctx: ILayoutContext, nodeIndex: number) {