mirror of
https://github.com/dream-num/univer.git
synced 2026-09-01 15:29:43 +08:00
fix(docs): delete fully selected structures atomically (#7457)
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
|
||||
+57
-6
@@ -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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -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 === ' ') {
|
||||
|
||||
+15
-3
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+35
-3
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+23
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user