Skip to content

Commit 1e96f6e

Browse files
juliaroldiCopilot
andcommitted
[Image Edit] Skip DOM selection during image edit to prevent editor jump
Add a skipDOMSelection option to FormatContentModelOptions and honor it in formatContentModel so callers can avoid resetting the DOM selection after writing the DOM tree. ImageEditPlugin passes skipDOMSelection: true when entering edit mode, replacing the previous approach of pinning the shadow host width/height to the original image footprint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 02ace9a1-26cc-4655-9ecc-a56c32a2bbf0
1 parent 4616869 commit 1e96f6e

6 files changed

Lines changed: 57 additions & 77 deletions

File tree

packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@ export const formatContentModel: FormatContentModel = (
2525
options,
2626
domToModelOptions
2727
) => {
28-
const { onNodeCreated, rawEvent, selectionOverride, scrollCaretIntoView: scroll } =
29-
options || {};
28+
const {
29+
onNodeCreated,
30+
rawEvent,
31+
selectionOverride,
32+
scrollCaretIntoView: scroll,
33+
skipDOMSelection,
34+
} = options || {};
3035
const model = core.api.createContentModel(core, domToModelOptions, selectionOverride);
3136
const context: FormatContentModelContext = {
3237
newEntities: [],
@@ -62,7 +67,7 @@ export const formatContentModel: FormatContentModel = (
6267
core.api.setContentModel(
6368
core,
6469
model,
65-
hasFocus ? undefined : { ignoreSelection: true }, // If editor did not have focus before format, do not set focus after format
70+
hasFocus && !skipDOMSelection ? undefined : { ignoreSelection: true }, // If editor did not have focus before format, do not set focus after format
6671
onNodeCreated
6772
) ?? undefined;
6873

packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,49 @@ describe('formatContentModel', () => {
793793
});
794794
});
795795

796+
describe('skipDOMSelection', () => {
797+
it('skipDOMSelection is true, do not set DOM selection even when editor has focus', () => {
798+
hasFocus.and.returnValue(true);
799+
800+
formatContentModel(
801+
core,
802+
(model, context) => {
803+
return true;
804+
},
805+
{
806+
apiName,
807+
skipDOMSelection: true,
808+
}
809+
);
810+
811+
expect(setContentModel).toHaveBeenCalledWith(
812+
core,
813+
mockedModel,
814+
{
815+
ignoreSelection: true,
816+
},
817+
undefined
818+
);
819+
});
820+
821+
it('skipDOMSelection is false, set DOM selection when editor has focus', () => {
822+
hasFocus.and.returnValue(true);
823+
824+
formatContentModel(
825+
core,
826+
(model, context) => {
827+
return true;
828+
},
829+
{
830+
apiName,
831+
skipDOMSelection: false,
832+
}
833+
);
834+
835+
expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined);
836+
});
837+
});
838+
796839
describe('Pending format', () => {
797840
const mockedStartContainer1 = 'CONTAINER1' as any;
798841
const mockedStartOffset1 = 'OFFSET1' as any;

packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin {
452452
}
453453
},
454454
apiName: IMAGE_EDIT_FORMAT_EVENT,
455+
skipDOMSelection: true,
455456
},
456457
{
457458
tryGetFromCache: true,

packages/roosterjs-content-model-plugins/lib/imageEdit/utils/createImageWrapper.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,8 @@ const createShadowSpan = (
8282
});
8383
imageSpan.id = IMAGE_EDIT_SHADOW_ROOT;
8484

85-
// Pin the shadow host to the original image's box so that wrapping the image does not grow the
86-
// surrounding line box. Without this, the taller edit wrapper enlarges the line and the browser
87-
// scrolls the selection back into view, making the editor jump. The wrapper and handles still
88-
// overflow the host visually because overflow is left visible.
8985
if (imageWidth > 0 && imageHeight > 0) {
9086
imageSpan.style.display = 'inline-block';
91-
imageSpan.style.width = `${imageWidth}px`;
92-
imageSpan.style.height = `${imageHeight}px`;
9387
imageSpan.style.verticalAlign = 'bottom';
9488
imageSpan.style.overflow = 'visible';
9589
}

packages/roosterjs-content-model-plugins/test/imageEdit/utils/createImageWrapperTest.ts

Lines changed: 0 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -175,74 +175,6 @@ describe('createImageWrapper', () => {
175175
});
176176
imageSpan.remove();
177177
});
178-
179-
function createImageWithSize(width: number, height: number) {
180-
const image = document.createElement('img');
181-
const imageSpan = document.createElement('span');
182-
imageSpan.append(image);
183-
document.body.appendChild(imageSpan);
184-
Object.defineProperty(image, 'offsetWidth', { value: width, configurable: true });
185-
Object.defineProperty(image, 'offsetHeight', { value: height, configurable: true });
186-
return { image, imageSpan };
187-
}
188-
189-
const options: ImageEditOptions = {
190-
borderColor: '#DB626C',
191-
minWidth: 10,
192-
minHeight: 10,
193-
preserveRatio: true,
194-
disableRotate: false,
195-
disableSideResize: false,
196-
onSelectState: ['resize'],
197-
};
198-
const editInfo = {
199-
src: 'test',
200-
widthPx: 20,
201-
heightPx: 20,
202-
naturalWidth: 10,
203-
naturalHeight: 10,
204-
leftPercent: 0,
205-
rightPercent: 0,
206-
topPercent: 0.1,
207-
bottomPercent: 0,
208-
angleRad: 0,
209-
};
210-
const htmlOptions = {
211-
borderColor: '#DB626C',
212-
rotateHandleBackColor: 'white',
213-
isSmallImage: false,
214-
disableSideResize: false,
215-
};
216-
217-
it('pins the shadow host to the original image footprint', () => {
218-
const { image, imageSpan } = createImageWithSize(120, 80);
219-
220-
const result = createImageWrapper(editor, image, options, editInfo, htmlOptions, [
221-
'resize',
222-
]);
223-
224-
expect(result.shadowSpan.style.display).toBe('inline-block');
225-
expect(result.shadowSpan.style.width).toBe('120px');
226-
expect(result.shadowSpan.style.height).toBe('80px');
227-
expect(result.shadowSpan.style.verticalAlign).toBe('bottom');
228-
expect(result.shadowSpan.style.overflow).toBe('visible');
229-
imageSpan.remove();
230-
});
231-
232-
it('does not pin the shadow host when the image has no footprint', () => {
233-
const { image, imageSpan } = createImageWithSize(0, 0);
234-
235-
const result = createImageWrapper(editor, image, options, editInfo, htmlOptions, [
236-
'resize',
237-
]);
238-
239-
expect(result.shadowSpan.style.display).toBe('');
240-
expect(result.shadowSpan.style.width).toBe('');
241-
expect(result.shadowSpan.style.height).toBe('');
242-
expect(result.shadowSpan.style.verticalAlign).toBe('');
243-
expect(result.shadowSpan.style.overflow).toBe('');
244-
imageSpan.remove();
245-
});
246178
});
247179

248180
const cloneImage = (image: HTMLImageElement, editInfo: ImageMetadataFormat) => {

packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ export interface FormatContentModelOptions {
4343
* When pass to true, scroll the editing caret into view after write DOM tree if need
4444
*/
4545
scrollCaretIntoView?: boolean;
46+
47+
/**
48+
* When pass to true, skip setting DOM selection after write DOM tree. @default false
49+
*/
50+
skipDOMSelection?: boolean;
4651
}
4752

4853
/**

0 commit comments

Comments
 (0)