From 139039280ca5480bfe2f44e7375886332c56918b Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Tue, 23 Jun 2026 14:16:37 -0700 Subject: [PATCH 1/2] Retain segments with undeletable links during model pruning Keep segments that have link.format.undeletable set to true in pruneUnselectedModel, even when they are not selected. This prevents undeletable link segments from being lost during selection-based pruning. Add unit tests covering the new behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../selection/pruneUnselectedModel.ts | 5 +- .../selection/pruneUnselectedModelTest.ts | 127 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts index 8d32f9474b29..aec10bc970e6 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts @@ -52,7 +52,10 @@ function pruneUnselectedModelInternal( if (segment.blocks.length > 0 || segment.isSelected) { newSegments.push(segment); } - } else if (segment.isSelected && segment.segmentType != 'SelectionMarker') { + } else if ( + (segment.isSelected && segment.segmentType != 'SelectionMarker') || + segment.link?.format.undeletable + ) { newSegments.push(segment); } } diff --git a/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts index f1370d81ea55..8f3072aefc05 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts @@ -1474,4 +1474,131 @@ describe('pruneUnselectedModel', () => { ], }); }); + + it('retains unselected segment with undeletable link', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text1 = createText('undeletable link'); + const text2 = createText('normal text'); + + text1.link = { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }; + + para.segments.push(text1); + para.segments.push(text2); + group.blocks.push(para); + + pruneUnselectedModel(group); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'undeletable link', + format: {}, + link: { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }, + }, + ], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('retains both selected segment and unselected undeletable link segment', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text1 = createText('selected'); + const text2 = createText('undeletable link'); + const text3 = createText('normal'); + + text1.isSelected = true; + text2.link = { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }; + + para.segments.push(text1); + para.segments.push(text2); + para.segments.push(text3); + group.blocks.push(para); + + pruneUnselectedModel(group); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'selected', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'undeletable link', + format: {}, + link: { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }, + }, + ], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('does not retain unselected segment with link that is not undeletable', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text1 = createText('selected'); + const text2 = createText('deletable link'); + + text1.isSelected = true; + text2.link = { + format: { href: 'http://example.com' }, + dataset: {}, + }; + + para.segments.push(text1); + para.segments.push(text2); + group.blocks.push(para); + + pruneUnselectedModel(group); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'selected', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: true, + }, + ], + }); + }); }); From a85dfe7b7d1ca363947301a255c4dab7ae34631e Mon Sep 17 00:00:00 2001 From: jiuqingsong Date: Wed, 24 Jun 2026 12:11:28 -0700 Subject: [PATCH 2/2] improve --- .../selection/pruneUnselectedModel.ts | 9 ++- .../selection/pruneUnselectedModelTest.ts | 81 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts index aec10bc970e6..7ebe3f4200f4 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/pruneUnselectedModel.ts @@ -47,14 +47,17 @@ function pruneUnselectedModelInternal( case 'Paragraph': const newSegments: ContentModelSegment[] = []; for (const segment of block.segments) { + const isSelectedOrUndeletable = + segment.isSelected || segment.link?.format.undeletable; + if (segment.segmentType == 'General') { pruneUnselectedModel(segment); - if (segment.blocks.length > 0 || segment.isSelected) { + if (segment.blocks.length > 0 || isSelectedOrUndeletable) { newSegments.push(segment); } } else if ( - (segment.isSelected && segment.segmentType != 'SelectionMarker') || - segment.link?.format.undeletable + isSelectedOrUndeletable && + segment.segmentType != 'SelectionMarker' ) { newSegments.push(segment); } diff --git a/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts index 8f3072aefc05..25a268736929 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/selection/pruneUnselectedModelTest.ts @@ -1601,4 +1601,85 @@ describe('pruneUnselectedModel', () => { ], }); }); + + it('retains unselected general segment with undeletable link', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const generalSpan = createGeneralSegment(document.createElement('span')); + const text = createText('normal text'); + + generalSpan.link = { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }; + + para.segments.push(generalSpan); + para.segments.push(text); + group.blocks.push(para); + + pruneUnselectedModel(group); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + segmentType: 'General', + format: {}, + blocks: [], + element: jasmine.anything(), + link: { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }, + }, + ], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('does not retain selection marker with undeletable link', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text = createText('selected'); + const marker = createSelectionMarker(); + + text.isSelected = true; + marker.link = { + format: { href: 'http://example.com', undeletable: true }, + dataset: {}, + }; + + para.segments.push(text); + para.segments.push(marker); + group.blocks.push(para); + + pruneUnselectedModel(group); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'selected', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: true, + }, + ], + }); + }); });