Skip to content

Commit 26c4616

Browse files
authored
Toggle blockquote in Format Container (#3126)
FormatContainer can be used to other elements other than blockquotes. When toggling blockquote in format containers that are not blockquotes, search for the selected elements inside the container and only apply blockquote to theses selected elements.
1 parent 8d8f0fe commit 26c4616

File tree

4 files changed

+200
-7
lines changed

4 files changed

+200
-7
lines changed

packages/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export function toggleModelBlockQuote(
3333

3434
const paragraphOfQuote = getOperationalBlocks<
3535
ContentModelFormatContainer | ContentModelListItem
36-
>(model, ['FormatContainer', 'ListItem'], ['TableCell'], true /*deepFirst*/);
36+
>(model, ['FormatContainer', 'ListItem'], ['TableCell'], true /*deepFirst*/, block => {
37+
return block.blockGroupType == 'FormatContainer' ? block.tagName == 'blockquote' : true;
38+
});
3739

3840
if (areAllBlockQuotes(paragraphOfQuote)) {
3941
// All selections are already in quote, we need to unquote them

packages/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,4 +797,180 @@ describe('toggleModelBlockQuote', () => {
797797
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
798798
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(doc);
799799
});
800+
801+
it('Quote only selected segments in a FormatContainer', () => {
802+
const doc = createContentModelDocument();
803+
const para1 = createParagraph();
804+
const text1 = createText('test1');
805+
const para2 = createParagraph();
806+
const text2 = createText('test2');
807+
const container = createFormatContainer('div', {
808+
id: 'TestId',
809+
});
810+
811+
para1.segments.push(text1);
812+
para2.segments.push(text2);
813+
container.blocks.push(para1);
814+
container.blocks.push(para2);
815+
doc.blocks.push(container);
816+
817+
text2.isSelected = true;
818+
819+
toggleModelBlockQuote(doc, {}, {});
820+
821+
expect(doc).toEqual({
822+
blockGroupType: 'Document',
823+
blocks: [
824+
{
825+
tagName: 'div',
826+
blockType: 'BlockGroup',
827+
format: {
828+
id: 'TestId',
829+
},
830+
blockGroupType: 'FormatContainer',
831+
blocks: [
832+
{
833+
segments: [
834+
{
835+
text: 'test1',
836+
segmentType: 'Text',
837+
format: {},
838+
},
839+
],
840+
blockType: 'Paragraph',
841+
format: {},
842+
},
843+
{
844+
tagName: 'blockquote',
845+
blockType: 'BlockGroup',
846+
format: {},
847+
blockGroupType: 'FormatContainer',
848+
blocks: [
849+
{
850+
segments: [
851+
{
852+
text: 'test2',
853+
segmentType: 'Text',
854+
format: {},
855+
isSelected: true,
856+
},
857+
],
858+
859+
blockType: 'Paragraph',
860+
format: {},
861+
},
862+
],
863+
},
864+
],
865+
},
866+
],
867+
});
868+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
869+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(doc);
870+
});
871+
872+
it('Quote only selected segments in a FormatContainer - multi selected segments', () => {
873+
const doc = createContentModelDocument();
874+
const para1 = createParagraph();
875+
const text1 = createText('test1');
876+
const para2 = createParagraph();
877+
const text2 = createText('test2');
878+
const para3 = createParagraph();
879+
const text3 = createText('test3');
880+
const para4 = createParagraph();
881+
const text4 = createText('test4');
882+
883+
const container = createFormatContainer('div', {
884+
id: 'TestId',
885+
});
886+
887+
para1.segments.push(text1);
888+
para2.segments.push(text2);
889+
para3.segments.push(text3);
890+
para4.segments.push(text4);
891+
container.blocks.push(para1);
892+
container.blocks.push(para2);
893+
container.blocks.push(para3);
894+
container.blocks.push(para4);
895+
doc.blocks.push(container);
896+
897+
text2.isSelected = true;
898+
text3.isSelected = true;
899+
text4.isSelected = true;
900+
901+
toggleModelBlockQuote(doc, {}, {});
902+
903+
expect(doc).toEqual({
904+
blockGroupType: 'Document',
905+
blocks: [
906+
{
907+
tagName: 'div',
908+
blockType: 'BlockGroup',
909+
format: {
910+
id: 'TestId',
911+
},
912+
blockGroupType: 'FormatContainer',
913+
blocks: [
914+
{
915+
segments: [
916+
{
917+
text: 'test1',
918+
segmentType: 'Text',
919+
format: {},
920+
},
921+
],
922+
blockType: 'Paragraph',
923+
format: {},
924+
},
925+
{
926+
tagName: 'blockquote',
927+
blockType: 'BlockGroup',
928+
format: {},
929+
blockGroupType: 'FormatContainer',
930+
blocks: [
931+
{
932+
segments: [
933+
{
934+
text: 'test2',
935+
segmentType: 'Text',
936+
format: {},
937+
isSelected: true,
938+
},
939+
],
940+
blockType: 'Paragraph',
941+
format: {},
942+
},
943+
{
944+
segments: [
945+
{
946+
text: 'test3',
947+
segmentType: 'Text',
948+
format: {},
949+
isSelected: true,
950+
},
951+
],
952+
blockType: 'Paragraph',
953+
format: {},
954+
},
955+
{
956+
segments: [
957+
{
958+
text: 'test4',
959+
segmentType: 'Text',
960+
format: {},
961+
isSelected: true,
962+
},
963+
],
964+
blockType: 'Paragraph',
965+
format: {},
966+
},
967+
],
968+
},
969+
],
970+
},
971+
],
972+
});
973+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
974+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(doc);
975+
});
800976
});

packages/roosterjs-content-model-dom/lib/modelApi/editing/getClosestAncestorBlockGroupIndex.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@ import type {
1010
* @param path The block group path, from the closest one to root
1111
* @param blockGroupTypes The expected block group types
1212
* @param stopTypes @optional Block group types that will cause stop searching
13+
* @param isValidTarget @optional An additional callback to validate whether a matching block group is a valid target
1314
*/
1415
export function getClosestAncestorBlockGroupIndex<T extends ContentModelBlockGroup>(
1516
path: ReadonlyContentModelBlockGroup[],
1617
blockGroupTypes: TypeOfBlockGroup<T>[],
17-
stopTypes: ContentModelBlockGroupType[] = []
18+
stopTypes: ContentModelBlockGroupType[] = [],
19+
isValidTarget?: (block: ReadonlyContentModelBlockGroup) => boolean
1820
): number {
1921
for (let i = 0; i < path.length; i++) {
2022
const group = path[i];
2123

22-
if ((blockGroupTypes as string[]).indexOf(group.blockGroupType) >= 0) {
24+
if (
25+
(blockGroupTypes as string[]).indexOf(group.blockGroupType) >= 0 &&
26+
(!isValidTarget || isValidTarget(group))
27+
) {
2328
return i;
2429
} else if (stopTypes.indexOf(group.blockGroupType) >= 0) {
2530
// Do not go across boundary specified by stopTypes.

packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,14 @@ export function getSelectedParagraphs(
237237
* @param blockGroupTypes The expected block group types
238238
* @param stopTypes Block group types that will stop searching when hit
239239
* @param deepFirst True means search in deep first, otherwise wide first
240+
* @param isValidTarget @optional An additional callback to validate whether a matching block group is a valid target
240241
*/
241242
export function getOperationalBlocks<T extends ContentModelBlockGroup>(
242243
group: ContentModelBlockGroup,
243244
blockGroupTypes: TypeOfBlockGroup<T>[],
244245
stopTypes: ContentModelBlockGroupType[],
245-
deepFirst?: boolean
246+
deepFirst?: boolean,
247+
isValidTarget?: (block: ReadonlyContentModelBlockGroup) => boolean
246248
): OperationalBlocks<T>[];
247249

248250
/**
@@ -251,19 +253,22 @@ export function getOperationalBlocks<T extends ContentModelBlockGroup>(
251253
* @param blockGroupTypes The expected block group types
252254
* @param stopTypes Block group types that will stop searching when hit
253255
* @param deepFirst True means search in deep first, otherwise wide first
256+
* @param isValidTarget @optional An additional callback to validate whether a matching block group is a valid target
254257
*/
255258
export function getOperationalBlocks<T extends ReadonlyContentModelBlockGroup>(
256259
group: ReadonlyContentModelBlockGroup,
257260
blockGroupTypes: TypeOfBlockGroup<T>[],
258261
stopTypes: ContentModelBlockGroupType[],
259-
deepFirst?: boolean
262+
deepFirst?: boolean,
263+
isValidTarget?: (block: ReadonlyContentModelBlockGroup) => boolean
260264
): ReadonlyOperationalBlocks<T>[];
261265

262266
export function getOperationalBlocks<T extends ContentModelBlockGroup>(
263267
group: ReadonlyContentModelBlockGroup,
264268
blockGroupTypes: TypeOfBlockGroup<T>[],
265269
stopTypes: ContentModelBlockGroupType[],
266-
deepFirst?: boolean
270+
deepFirst?: boolean,
271+
isValidTarget?: (block: ReadonlyContentModelBlockGroup) => boolean
267272
): ReadonlyOperationalBlocks<T>[] {
268273
const result: ReadonlyOperationalBlocks<T>[] = [];
269274
const findSequence = deepFirst ? blockGroupTypes.map(type => [type]) : [blockGroupTypes];
@@ -276,7 +281,12 @@ export function getOperationalBlocks<T extends ContentModelBlockGroup>(
276281

277282
selections.forEach(({ path, block }) => {
278283
for (let i = 0; i < findSequence.length; i++) {
279-
const groupIndex = getClosestAncestorBlockGroupIndex(path, findSequence[i], stopTypes);
284+
const groupIndex = getClosestAncestorBlockGroupIndex(
285+
path,
286+
findSequence[i],
287+
stopTypes,
288+
isValidTarget
289+
);
280290

281291
if (groupIndex >= 0) {
282292
if (result.filter(x => x.block == path[groupIndex]).length <= 0) {

0 commit comments

Comments
 (0)