diff --git a/demo/scripts/controlsV2/demoButtons/cutButton.ts b/demo/scripts/controlsV2/demoButtons/cutButton.ts new file mode 100644 index 00000000000..9c118a4a9b1 --- /dev/null +++ b/demo/scripts/controlsV2/demoButtons/cutButton.ts @@ -0,0 +1,22 @@ +import type { RibbonButton } from 'roosterjs-react'; + +/** + * Key of localized strings of Cut button + */ +export type CutButtonStringKey = 'buttonNameCut'; + +/** + * "Cut" button on the format ribbon + */ +export const cutButton: RibbonButton = { + key: 'buttonNameCut', + unlocalizedText: ' Cut', + iconName: 'ClearNight', + onClick: editor => { + const selection = editor.getDOMSelection(); + if (selection) { + document.execCommand('cut'); + } + return true; + }, +}; diff --git a/packages/roosterjs-content-model-api/lib/index.ts b/packages/roosterjs-content-model-api/lib/index.ts index 2f1558ac367..88d60893bc5 100644 --- a/packages/roosterjs-content-model-api/lib/index.ts +++ b/packages/roosterjs-content-model-api/lib/index.ts @@ -60,4 +60,7 @@ export { setModelIndentation } from './modelApi/block/setModelIndentation'; export { matchLink } from './modelApi/link/matchLink'; export { promoteLink } from './modelApi/link/promoteLink'; export { getListAnnounceData } from './modelApi/list/getListAnnounceData'; -export { queryContentModel, QueryContentModelOptions } from './modelApi/common/queryContentModel'; +export { + queryContentModelBlocks, + QueryContentModelOptions, +} from './modelApi/common/queryContentModelBlocks'; diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts deleted file mode 100644 index e89a351f656..00000000000 --- a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModel.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { - ContentModelBlockType, - ContentModelSegmentType, - ReadonlyContentModelBlock, - ReadonlyContentModelBlockGroup, - ReadonlyContentModelParagraph, - ReadonlyContentModelSegment, - ReadonlyContentModelTable, -} from 'roosterjs-content-model-types'; - -/** - * Options for queryContentModel - */ -export interface QueryContentModelOptions { - /** - * The type of block to query @default 'Paragraph' - */ - type?: ContentModelBlockType; - - /** - * The type of segment to query - */ - segmentType?: ContentModelSegmentType; - - /** - * Optional selector to filter the blocks/segments - */ - selector?: (element: T) => boolean; - - /** - * True to return the first block only, false to return all blocks - */ - findFirstOnly?: boolean; -} - -/** - * Query content model blocks or segments - * @param group The block group to query - * @param options The query option - */ -export function queryContentModel< - T extends ReadonlyContentModelBlock | ReadonlyContentModelSegment ->(group: ReadonlyContentModelBlockGroup, options: QueryContentModelOptions): T[] { - const elements: T[] = []; - const searchOptions = options.type ? options : { ...options, type: 'Paragraph' }; - const { type, segmentType, selector, findFirstOnly } = searchOptions; - - for (let i = 0; i < group.blocks.length; i++) { - if (findFirstOnly && elements.length > 0) { - return elements; - } - const block = group.blocks[i]; - switch (block.blockType) { - case 'BlockGroup': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - const blockGroupsResults = queryContentModel(block, options); - elements.push(...(blockGroupsResults as T[])); - break; - case 'Table': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - const tableResults = searchInTables(block, options); - elements.push(...(tableResults as T[])); - break; - case 'Divider': - case 'Entity': - if (type == block.blockType && (!selector || selector(block as T))) { - elements.push(block as T); - } - break; - case 'Paragraph': - if (type == block.blockType) { - if (!segmentType && (!selector || selector(block as T))) { - elements.push(block as T); - } else if (segmentType) { - const segments = searchInParagraphs(block, segmentType, selector); - elements.push(...(segments as T[])); - } - } - break; - } - } - - return elements; -} - -function searchInTables( - table: ReadonlyContentModelTable, - options: QueryContentModelOptions -): T[] { - const blocks: T[] = []; - for (const row of table.rows) { - for (const cell of row.cells) { - const items = queryContentModel(cell, options); - blocks.push(...items); - } - } - return blocks; -} - -function searchInParagraphs

( - block: ReadonlyContentModelParagraph, - segmentType: ContentModelSegmentType, - selector?: (element: P) => boolean -): P[] { - const segments: P[] = []; - for (const segment of block.segments) { - if (segment.segmentType == segmentType && (!selector || selector(segment as P))) { - segments.push(segment as P); - } - } - return segments; -} diff --git a/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts new file mode 100644 index 00000000000..afceced01b5 --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/queryContentModelBlocks.ts @@ -0,0 +1,108 @@ +import type { + ContentModelBlockType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * Options for queryContentModel + */ +export interface QueryContentModelOptions { + /** + * The type of block to query @default 'Paragraph' + */ + blockType?: ContentModelBlockType; + + /** + * Optional selector to filter the blocks + */ + filter?: (element: T) => element is T; + + /** + * True to return the first block only, false to return all blocks + */ + findFirstOnly?: boolean; +} + +/** + * Query content model blocks + * @param group The block group to query + * @param options The query option + */ +export function queryContentModelBlocks( + group: ReadonlyContentModelBlockGroup, + options: QueryContentModelOptions +): T[] { + const { blockType, filter, findFirstOnly } = options; + const type = blockType || 'Paragraph'; + + return queryContentModelBlocksInternal(group, type, filter, findFirstOnly); +} + +function queryContentModelBlocksInternal( + group: ReadonlyContentModelBlockGroup, + type: ContentModelBlockType, + filter?: (element: T) => element is T, + findFirstOnly?: boolean +): T[] { + const elements: T[] = []; + for (let i = 0; i < group.blocks.length; i++) { + if (findFirstOnly && elements.length > 0) { + return elements; + } + const block = group.blocks[i]; + switch (block.blockType) { + case 'BlockGroup': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + const blockGroupsResults = queryContentModelBlocksInternal( + block, + type, + filter, + findFirstOnly + ); + elements.push(...blockGroupsResults); + break; + case 'Table': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + const tableResults = searchInTables(block, type, filter, findFirstOnly); + elements.push(...tableResults); + break; + case 'Divider': + case 'Entity': + case 'Paragraph': + if (isBlockType(block, type) && (!filter || filter(block))) { + elements.push(block); + } + break; + } + } + return elements; +} + +function isBlockType( + block: ReadonlyContentModelBlock, + type: string +): block is T { + return block.blockType == type; +} + +function searchInTables( + table: ReadonlyContentModelTable, + type: ContentModelBlockType, + filter?: (element: T) => element is T, + findFirstOnly?: boolean +): T[] { + const blocks: T[] = []; + for (const row of table.rows) { + for (const cell of row.cells) { + const items = queryContentModelBlocksInternal(cell, type, filter, findFirstOnly); + blocks.push(...items); + } + } + return blocks; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts similarity index 78% rename from packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts rename to packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts index 9306f1ead08..34c64d45c49 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/queryContentModelBlocksTest.ts @@ -1,13 +1,12 @@ -import { queryContentModel } from '../../../lib/modelApi/common/queryContentModel'; +import { queryContentModelBlocks } from '../../../lib/modelApi/common/queryContentModelBlocks'; import { ReadonlyContentModelBlockGroup, - ReadonlyContentModelImage, ReadonlyContentModelListItem, ReadonlyContentModelParagraph, ReadonlyContentModelTable, } from 'roosterjs-content-model-types'; -describe('queryContentModel', () => { +describe('queryContentModelBlocksBlocks', () => { it('should return empty array if no blocks', () => { // Arrange const group: ReadonlyContentModelBlockGroup = { @@ -16,7 +15,7 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, {}); + const result = queryContentModelBlocks(group, {}); // Assert expect(result).toEqual([]); @@ -37,7 +36,7 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, { type: 'Table' }); + const result = queryContentModelBlocks(group, { blockType: 'Table' }); // Assert expect(result).toEqual([]); @@ -319,7 +318,9 @@ describe('queryContentModel', () => { ]; // Act - const result = queryContentModel(group, { type: 'Table' }); + const result = queryContentModelBlocks(group, { + blockType: 'Table', + }); // Assert expect(result).toEqual(expected); @@ -376,126 +377,15 @@ describe('queryContentModel', () => { }; // Act - const result = queryContentModel(group, { - type: 'Paragraph', - - selector: block => block.segments.length == 2, + const result = queryContentModelBlocks(group, { + blockType: 'Paragraph', + filter: (block): block is ReadonlyContentModelParagraph => block.segments.length == 2, }); // Assert expect(result).toEqual([paragraph]); }); - it('should return first segment that match the type and selector', () => { - const image: ReadonlyContentModelImage = { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - isSelectedAsImageSelection: true, - segmentType: 'Image', - isSelected: true, - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'rgb(0, 0, 0)', - id: 'image_0', - maxWidth: '1492px', - }, - dataset: { - isEditing: 'true', - }, - }; - const model: ReadonlyContentModelBlockGroup = { - blockGroupType: 'Document', - blocks: [ - { - widths: [120, 153], - rows: [ - { - height: 157, - cells: [ - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [image], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - ], - format: {}, - }, - ], - blockType: 'Table', - format: { - useBorderBox: true, - borderCollapse: true, - }, - dataset: { - editingInfo: - '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', - }, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }; - const result = queryContentModel(model, { - segmentType: 'Image', - selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, - findFirstOnly: true, - }); - expect(result).toEqual([image]); - }); - it('should return all tables that match the type and selector', () => { const model: ReadonlyContentModelBlockGroup = { blockGroupType: 'Document', @@ -875,7 +765,9 @@ describe('queryContentModel', () => { }, }, ]; - const result = queryContentModel(model, { type: 'Table' }); + const result = queryContentModelBlocks(model, { + blockType: 'Table', + }); expect(result).toEqual(expected); }); @@ -1013,7 +905,9 @@ describe('queryContentModel', () => { }; const expected: ReadonlyContentModelTable[] = [table]; - const result = queryContentModel(model, { type: 'Table' }); + const result = queryContentModelBlocks(model, { + blockType: 'Table', + }); expect(result).toEqual(expected); }); @@ -1273,274 +1167,14 @@ describe('queryContentModel', () => { }, ]; - const result = queryContentModel(model, { - type: 'BlockGroup', - selector: block => block.blockGroupType == 'ListItem', + const result = queryContentModelBlocks(model, { + blockType: 'BlockGroup', + filter: (block): block is ReadonlyContentModelListItem => + block.blockGroupType == 'ListItem', }); expect(result).toEqual(listExpected); }); - it('should return all images', () => { - const model: ReadonlyContentModelBlockGroup = { - blockGroupType: 'Document', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - startNumberOverride: 1, - listStyleType: 'decimal', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - text: 'test', - segmentType: 'Text', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - listStyleType: 'decimal', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - widths: [153], - rows: [ - { - height: 157, - cells: [ - { - spanAbove: false, - spanLeft: false, - isHeader: false, - blockGroupType: 'TableCell', - blocks: [ - { - segments: [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - dataset: {}, - }, - ], - format: {}, - }, - ], - blockType: 'Table', - format: {}, - dataset: { - editingInfo: - '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":"top"}', - }, - }, - { - isImplicit: true, - segments: [ - { - isSelected: true, - segmentType: 'SelectionMarker', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - { - formatHolder: { - isSelected: false, - segmentType: 'SelectionMarker', - format: {}, - }, - levels: [ - { - listType: 'OL', - format: { - listStyleType: 'decimal', - displayForDummyItem: 'block', - }, - dataset: { - editingInfo: - '{"applyListStyleFromLevel":false,"orderedStyleType":1}', - }, - }, - ], - blockType: 'BlockGroup', - format: {}, - blockGroupType: 'ListItem', - blocks: [ - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - }, - { - segments: [ - { - segmentType: 'Br', - format: {}, - }, - ], - segmentFormat: {}, - blockType: 'Paragraph', - format: {}, - }, - ], - format: {}, - }; - - const expected: ReadonlyContentModelImage[] = [ - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - segmentType: 'Image', - format: {}, - dataset: {}, - }, - ]; - - const result = queryContentModel(model, { - segmentType: 'Image', - }); - expect(result).toEqual(expected); - }); - it('should return image from a word online table', () => { const model: ReadonlyContentModelBlockGroup = { blockGroupType: 'Document', @@ -1765,32 +1399,76 @@ describe('queryContentModel', () => { textColor: '#000000', }, }; - const image: ReadonlyContentModelImage = { - src: - 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', - isSelectedAsImageSelection: true, - segmentType: 'Image', - isSelected: true, - format: { - fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', - fontSize: '12pt', - textColor: 'rgb(0, 0, 0)', + const imageAndParagraph: ReadonlyContentModelParagraph = { + segments: [ + { + text: ' ', + segmentType: 'Text', + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + }, + }, + { + src: + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAsJCQcJCQcJCQkJCwkJCQkJCQsJCwsMCwsLDA0QDB...', + isSelectedAsImageSelection: true, + segmentType: 'Image', + isSelected: true, + format: { + fontFamily: 'Aptos, Aptos_EmbeddedFont, Aptos_MSFontService, sans-serif', + fontSize: '12pt', + textColor: 'rgb(0, 0, 0)', + italic: false, + fontWeight: 'normal', + lineHeight: '18px', + backgroundColor: '', + maxWidth: '1492px', + id: 'image_0', + }, + dataset: { + isEditing: 'true', + }, + }, + ], + segmentFormat: { italic: false, fontWeight: 'normal', - lineHeight: '18px', - backgroundColor: '', - maxWidth: '1492px', - id: 'image_0', + textColor: 'rgb(0, 0, 0)', }, - dataset: { - isEditing: 'true', + blockType: 'Paragraph', + format: { + textAlign: 'start', + direction: 'ltr', + marginLeft: '0px', + marginRight: '0px', + textIndent: '0px', + whiteSpace: 'pre-wrap', + marginTop: '0px', + marginBottom: '0px', + }, + decorator: { + tagName: 'p', + format: {}, }, }; - const result = queryContentModel(model, { - segmentType: 'Image', + const result = queryContentModelBlocks(model, { findFirstOnly: true, - selector: (segment: ReadonlyContentModelImage) => !!segment.dataset.isEditing, + filter: ( + block: ReadonlyContentModelParagraph + ): block is ReadonlyContentModelParagraph => { + for (const segment of block.segments) { + if (segment.segmentType == 'Image' && segment.dataset.isEditing) { + return true; + } + } + return false; + }, }); - expect(result).toEqual([image]); + expect(result).toEqual([imageAndParagraph]); }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts index 35bc9f8140c..8c20467c371 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/findEditingImage.ts @@ -1,4 +1,4 @@ -import { queryContentModel } from 'roosterjs-content-model-api'; +import { queryContentModelBlocks } from 'roosterjs-content-model-api'; import type { ReadonlyContentModelBlockGroup, ReadonlyContentModelParagraph, @@ -13,8 +13,10 @@ export function findEditingImage( imageId?: string ): ImageAndParagraph | null { let imageAndParagraph: ImageAndParagraph | null = null; - queryContentModel(group, { - selector: (paragraph: ReadonlyContentModelParagraph) => { + queryContentModelBlocks(group, { + filter: ( + paragraph: ReadonlyContentModelParagraph + ): paragraph is ReadonlyContentModelParagraph => { for (const segment of paragraph.segments) { if ( segment.segmentType == 'Image' &&