Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #2862 Keep format when replace a selection #2886

Merged
merged 2 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom';
import { iterateSelections } from 'roosterjs-content-model-dom';
import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types';

/**
Expand All @@ -8,55 +8,59 @@ import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model
* @param defaultFormat The default segment format to apply
*/
export function applyDefaultFormat(editor: IEditor, defaultFormat: ContentModelSegmentFormat) {
editor.formatContentModel((model, context) => {
const result = deleteSelection(model, [], context);
const selection = editor.getDOMSelection();

if (result.deleteResult == 'range') {
normalizeContentModel(model);
if (selection?.type == 'range' && selection.range.collapsed) {
editor.formatContentModel((model, context) => {
iterateSelections(model, (path, _, paragraph, segments) => {
const marker = segments?.[0];
if (
paragraph?.blockType == 'Paragraph' &&
marker?.segmentType == 'SelectionMarker'
) {
const blocks = path[0].blocks;
const blockCount = blocks.length;
const blockIndex = blocks.indexOf(paragraph);

editor.takeSnapshot();
if (
paragraph.isImplicit &&
paragraph.segments.length == 1 &&
paragraph.segments[0] == marker &&
blockCount > 0 &&
blockIndex == blockCount - 1
) {
// Focus is in the last paragraph which is implicit and there is not other segments.
// This can happen when focus is moved after all other content under current block group.
// We need to check if browser will merge focus into previous paragraph by checking if
// previous block is block. If previous block is paragraph, browser will most likely merge
// the input into previous paragraph, then nothing need to do here. Otherwise we need to
// apply pending format since this input event will start a new real paragraph.
const previousBlock = blocks[blockIndex - 1];

return true;
} else if (result.deleteResult == 'notDeleted' && result.insertPoint) {
const { paragraph, path, marker } = result.insertPoint;
const blocks = path[0].blocks;
const blockCount = blocks.length;
const blockIndex = blocks.indexOf(paragraph);

if (
paragraph.isImplicit &&
paragraph.segments.length == 1 &&
paragraph.segments[0] == marker &&
blockCount > 0 &&
blockIndex == blockCount - 1
) {
// Focus is in the last paragraph which is implicit and there is not other segments.
// This can happen when focus is moved after all other content under current block group.
// We need to check if browser will merge focus into previous paragraph by checking if
// previous block is block. If previous block is paragraph, browser will most likely merge
// the input into previous paragraph, then nothing need to do here. Otherwise we need to
// apply pending format since this input event will start a new real paragraph.
const previousBlock = blocks[blockIndex - 1];

if (previousBlock?.blockType != 'Paragraph') {
context.newPendingFormat = getNewPendingFormat(
editor,
defaultFormat,
marker.format
);
if (previousBlock?.blockType != 'Paragraph') {
context.newPendingFormat = getNewPendingFormat(
editor,
defaultFormat,
marker.format
);
}
} else if (paragraph.segments.every(x => x.segmentType != 'Text')) {
context.newPendingFormat = getNewPendingFormat(
editor,
defaultFormat,
marker.format
);
}
}
} else if (paragraph.segments.every(x => x.segmentType != 'Text')) {
context.newPendingFormat = getNewPendingFormat(
editor,
defaultFormat,
marker.format
);
}
}

// We didn't do any change but just apply default format to pending format, so no need to write back
return false;
});
// Stop searching more selection
return true;
});

// We didn't do any change but just apply default format to pending format, so no need to write back
return false;
});
}
}

function getNewPendingFormat(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,12 @@ describe('FormatPlugin for default format', () => {
let getDOMSelection: jasmine.Spy;
let getPendingFormatSpy: jasmine.Spy;
let cacheContentModelSpy: jasmine.Spy;
let takeSnapshotSpy: jasmine.Spy;
let formatContentModelSpy: jasmine.Spy;

beforeEach(() => {
getPendingFormatSpy = jasmine.createSpy('getPendingFormat');
getDOMSelection = jasmine.createSpy('getDOMSelection');
cacheContentModelSpy = jasmine.createSpy('cacheContentModel');
takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
formatContentModelSpy = jasmine.createSpy('formatContentModelSpy');
contentDiv = document.createElement('div');

Expand All @@ -252,7 +250,6 @@ describe('FormatPlugin for default format', () => {
getDOMSelection,
getPendingFormat: getPendingFormatSpy,
cacheContentModel: cacheContentModelSpy,
takeSnapshot: takeSnapshotSpy,
formatContentModel: formatContentModelSpy,
getEnvironment: () => ({}),
} as any) as IEditor;
Expand Down Expand Up @@ -364,7 +361,6 @@ describe('FormatPlugin for default format', () => {
});

expect(context).toEqual({});
expect(takeSnapshotSpy).toHaveBeenCalledTimes(1);
});

it('Collapsed range, IME input, under editor directly', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as deleteSelection from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection';
import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel';
import { applyDefaultFormat } from '../../../lib/corePlugin/format/applyDefaultFormat';
import {
ContentModelDocument,
Expand All @@ -24,8 +23,6 @@ describe('applyDefaultFormat', () => {
let getDOMSelectionSpy: jasmine.Spy;
let formatContentModelSpy: jasmine.Spy;
let deleteSelectionSpy: jasmine.Spy;
let normalizeContentModelSpy: jasmine.Spy;
let takeSnapshotSpy: jasmine.Spy;
let getPendingFormatSpy: jasmine.Spy;
let isNodeInEditorSpy: jasmine.Spy;

Expand All @@ -46,8 +43,6 @@ describe('applyDefaultFormat', () => {

getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy');
deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection');
normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel');
takeSnapshotSpy = jasmine.createSpy('takeSnapshot');
getPendingFormatSpy = jasmine.createSpy('getPendingFormat');
isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor');

Expand All @@ -71,7 +66,6 @@ describe('applyDefaultFormat', () => {
}),
getDOMSelection: getDOMSelectionSpy,
formatContentModel: formatContentModelSpy,
takeSnapshot: takeSnapshotSpy,
getPendingFormat: getPendingFormatSpy,
} as any;
});
Expand All @@ -82,7 +76,7 @@ describe('applyDefaultFormat', () => {

applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalled();
expect(formatContentModelSpy).not.toHaveBeenCalled();
});

it('Selection already has style', () => {
Expand All @@ -99,6 +93,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});
deleteSelectionSpy.and.returnValue({
Expand All @@ -124,6 +119,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: text,
startOffset: 0,
collapsed: true,
},
});
deleteSelectionSpy.and.returnValue({
Expand All @@ -143,6 +139,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -154,9 +151,7 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).toHaveBeenCalledWith(model);
expect(takeSnapshotSpy).toHaveBeenCalledTimes(1);
expect(formatResult).toBeTrue();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
newEntities: [],
Expand All @@ -174,6 +169,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -185,8 +181,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalledWith();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand All @@ -204,6 +198,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -215,8 +210,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalledWith();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down Expand Up @@ -246,6 +239,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -257,8 +251,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalled();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down Expand Up @@ -288,6 +280,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -299,8 +292,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalled();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down Expand Up @@ -331,6 +322,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -342,8 +334,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalled();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down Expand Up @@ -373,6 +363,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -384,8 +375,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalled();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down Expand Up @@ -419,6 +408,7 @@ describe('applyDefaultFormat', () => {
range: {
startContainer: node,
startOffset: 0,
collapsed: true,
},
});

Expand All @@ -435,8 +425,6 @@ describe('applyDefaultFormat', () => {
applyDefaultFormat(editor, defaultFormat);

expect(formatContentModelSpy).toHaveBeenCalledTimes(1);
expect(normalizeContentModelSpy).not.toHaveBeenCalled();
expect(takeSnapshotSpy).not.toHaveBeenCalled();
expect(formatResult).toBeFalse();
expect(context).toEqual({
deletedEntities: [],
Expand Down
23 changes: 21 additions & 2 deletions packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
import {
ChangeSource,
createText,
deleteSelection,
isModifierKey,
normalizeContentModel,
} from 'roosterjs-content-model-dom';
import type { DOMSelection, IEditor } from 'roosterjs-content-model-types';
import type { DeleteSelectionStep, DOMSelection, IEditor } from 'roosterjs-content-model-types';

// Insert a ZeroWidthSpace(ZWS) segment with selection before selection marker
// so that later browser will replace this selection with inputted text and keep format
const ZWS = '\u200B';
const insertZWS: DeleteSelectionStep = context => {
if (context.deleteResult == 'range') {
const { marker, paragraph } = context.insertPoint;
const index = paragraph.segments.indexOf(marker);

if (index >= 0) {
const text = createText(ZWS, marker.format, marker.link, marker.code);

text.isSelected = true;

paragraph.segments.splice(index, 0, text);
}
}
};

/**
* @internal
Expand All @@ -17,7 +36,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) {

editor.formatContentModel(
(model, context) => {
const result = deleteSelection(model, [], context);
const result = deleteSelection(model, [insertZWS], context);

// Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation
context.skipUndoSnapshot = true;
Expand Down
Loading
Loading