From a3a14a4a2fdf7b0540cac3b1996a3d6b5074c185 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 May 2024 11:19:08 -0700 Subject: [PATCH] Fix #2601 allow customization when convert from content model to plain text (#2605) * Fix #2601 allow customization when convert from content model to plain text * Improve * fix test * fix build --- .../demoButtons/exportContentButton.ts | 7 +- .../command/exportContent/exportContent.ts | 49 +++++-- .../exportContent/exportContentTest.ts | 24 +++- .../lib/modelToText/contentModelToText.ts | 128 +++++++++++------- .../modelToText/contentModelToTextTest.ts | 41 ++++++ .../lib/index.ts | 5 + .../lib/parameter/ModelToTextCallbacks.ts | 70 ++++++++++ .../lib/editor/EditorAdapter.ts | 21 ++- 8 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/parameter/ModelToTextCallbacks.ts diff --git a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts index 84057a5e470..729d336fcee 100644 --- a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts +++ b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts @@ -1,4 +1,5 @@ import { exportContent } from 'roosterjs-content-model-core'; +import { ModelToTextCallbacks } from 'roosterjs-content-model-types'; import type { RibbonButton } from '../roosterjsReact/ribbon'; /** @@ -9,6 +10,10 @@ export type ExportButtonStringKey = | 'menuNameExportHTML' | 'menuNameExportText'; +const callbacks: ModelToTextCallbacks = { + onImage: () => '[Image]', +}; + /** * "Export content" button on the format ribbon */ @@ -30,7 +35,7 @@ export const exportContentButton: RibbonButton = { if (key == 'menuNameExportHTML') { html = exportContent(editor); } else if (key == 'menuNameExportText') { - html = `
${exportContent(editor, 'PlainText')}
`; + html = `
${exportContent(editor, 'PlainText', callbacks)}
`; } win.document.write(editor.getTrustedHTMLHandler()(html)); diff --git a/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts b/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts index 3ddf0e27006..428079c129b 100644 --- a/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/exportContent/exportContent.ts @@ -3,21 +3,45 @@ import { contentModelToText, createModelToDomContext, } from 'roosterjs-content-model-dom'; -import type { ExportContentMode, IEditor, ModelToDomOption } from 'roosterjs-content-model-types'; +import type { + ExportContentMode, + IEditor, + ModelToDomOption, + ModelToTextCallbacks, +} from 'roosterjs-content-model-types'; /** - * Export string content of editor + * Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity * @param editor The editor to get content from - * @param mode Mode of content to export. It supports: - * - HTML: Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity - * - PlainText: Export plain text content - * - PlainTextFast: Export plain text using editor's textContent property directly + * @param mode Specify HTML to get plain text result. This is the default option * @param options @optional Options for Model to DOM conversion */ +export function exportContent(editor: IEditor, mode?: 'HTML', options?: ModelToDomOption): string; + +/** + * Export plain text content + * @param editor The editor to get content from + * @param mode Specify PlainText to get plain text result + * @param callbacks @optional Callbacks to customize conversion behavior + */ +export function exportContent( + editor: IEditor, + mode: 'PlainText', + callbacks?: ModelToTextCallbacks +): string; + +/** + * Export plain text using editor's textContent property directly + * @param editor The editor to get content from + * @param mode Specify PlainTextFast to get plain text result using textContent property + * @param options @optional Options for Model to DOM conversion + */ +export function exportContent(editor: IEditor, mode: 'PlainTextFast'): string; + export function exportContent( editor: IEditor, mode: ExportContentMode = 'HTML', - options?: ModelToDomOption + optionsOrCallbacks?: ModelToDomOption | ModelToTextCallbacks ): string { if (mode == 'PlainTextFast') { return editor.getDOMHelper().getTextContent(); @@ -25,7 +49,11 @@ export function exportContent( const model = editor.getContentModelCopy('clean'); if (mode == 'PlainText') { - return contentModelToText(model); + return contentModelToText( + model, + undefined /*separator*/, + optionsOrCallbacks as ModelToTextCallbacks + ); } else { const doc = editor.getDocument(); const div = doc.createElement('div'); @@ -34,7 +62,10 @@ export function exportContent( doc, div, model, - createModelToDomContext(undefined /*editorContext*/, options) + createModelToDomContext( + undefined /*editorContext*/, + optionsOrCallbacks as ModelToDomOption + ) ); editor.triggerEvent('extractContentWithDom', { clonedRoot: div }, true /*broadcast*/); diff --git a/packages/roosterjs-content-model-core/test/command/exportContent/exportContentTest.ts b/packages/roosterjs-content-model-core/test/command/exportContent/exportContentTest.ts index 8eec6d30c54..a39b4431cda 100644 --- a/packages/roosterjs-content-model-core/test/command/exportContent/exportContentTest.ts +++ b/packages/roosterjs-content-model-core/test/command/exportContent/exportContentTest.ts @@ -40,7 +40,29 @@ describe('exportContent', () => { expect(text).toBe(mockedText); expect(getContentModelCopySpy).toHaveBeenCalledWith('clean'); - expect(contentModelToTextSpy).toHaveBeenCalledWith(mockedModel); + expect(contentModelToTextSpy).toHaveBeenCalledWith(mockedModel, undefined, undefined); + }); + + it('PlainText with callback', () => { + const mockedModel = 'MODEL' as any; + const getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(mockedModel); + const editor: IEditor = { + getContentModelCopy: getContentModelCopySpy, + } as any; + const mockedText = 'TEXT'; + const contentModelToTextSpy = spyOn( + contentModelToText, + 'contentModelToText' + ).and.returnValue(mockedText); + const mockedCallbacks = 'CALLBACKS' as any; + + const text = exportContent(editor, 'PlainText', mockedCallbacks); + + expect(text).toBe(mockedText); + expect(getContentModelCopySpy).toHaveBeenCalledWith('clean'); + expect(contentModelToTextSpy).toHaveBeenCalledWith(mockedModel, undefined, mockedCallbacks); }); it('HTML', () => { diff --git a/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts b/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts index 75b8a10a7e6..7332c47b3ca 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts @@ -1,78 +1,106 @@ -import type { ContentModelBlockGroup, ContentModelDocument } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockGroup, + ContentModelDocument, + ModelToTextCallbacks, +} from 'roosterjs-content-model-types'; const TextForHR = '________________________________________'; +const defaultCallbacks: Required = { + onDivider: divider => (divider.tagName == 'hr' ? TextForHR : ''), + onEntityBlock: () => '', + onEntitySegment: entity => entity.wrapper.textContent ?? '', + onGeneralSegment: segment => segment.element.textContent ?? '', + onImage: () => ' ', + onText: text => text.text, + onParagraph: () => true, + onTable: () => true, + onBlockGroup: () => true, +}; /** * Convert Content Model to plain text * @param model The source Content Model * @param [separator='\r\n'] The separator string used for connect lines + * @param callbacks Callbacks to customize the behavior of contentModelToText function */ export function contentModelToText( model: ContentModelDocument, - separator: string = '\r\n' + separator: string = '\r\n', + callbacks?: ModelToTextCallbacks ): string { const textArray: string[] = []; + const fullCallbacks = Object.assign({}, defaultCallbacks, callbacks); - contentModelToTextArray(model, textArray); + contentModelToTextArray(model, textArray, fullCallbacks); return textArray.join(separator); } -function contentModelToTextArray(group: ContentModelBlockGroup, textArray: string[]) { - group.blocks.forEach(block => { - switch (block.blockType) { - case 'Paragraph': - let text = ''; +function contentModelToTextArray( + group: ContentModelBlockGroup, + textArray: string[], + callbacks: Required +) { + if (callbacks.onBlockGroup(group)) { + group.blocks.forEach(block => { + switch (block.blockType) { + case 'Paragraph': + if (callbacks.onParagraph(block)) { + let text = ''; - block.segments.forEach(segment => { - switch (segment.segmentType) { - case 'Br': - textArray.push(text); - text = ''; - break; + block.segments.forEach(segment => { + switch (segment.segmentType) { + case 'Br': + textArray.push(text); + text = ''; + break; - case 'Entity': - text += segment.wrapper.textContent || ''; - break; + case 'Entity': + text += callbacks.onEntitySegment(segment); + break; - case 'General': - text += segment.element.textContent || ''; - break; + case 'General': + text += callbacks.onGeneralSegment(segment); + break; - case 'Text': - text += segment.text; - break; + case 'Text': + text += callbacks.onText(segment); + break; - case 'Image': - text += ' '; - break; - } - }); + case 'Image': + text += callbacks.onImage(segment); + break; + } + }); - if (text) { - textArray.push(text); - } + if (text) { + textArray.push(text); + } + } - break; + break; - case 'Divider': - textArray.push(block.tagName == 'hr' ? TextForHR : ''); - break; - case 'Entity': - textArray.push(''); - break; + case 'Divider': + textArray.push(callbacks.onDivider(block)); + break; + case 'Entity': + textArray.push(callbacks.onEntityBlock(block)); + break; - case 'Table': - block.rows.forEach(row => - row.cells.forEach(cell => { - contentModelToTextArray(cell, textArray); - }) - ); - break; + case 'Table': + if (callbacks.onTable(block)) { + block.rows.forEach(row => + row.cells.forEach(cell => { + contentModelToTextArray(cell, textArray, callbacks); + }) + ); + } + break; - case 'BlockGroup': - contentModelToTextArray(block, textArray); - break; - } - }); + case 'BlockGroup': + contentModelToTextArray(block, textArray, callbacks); + break; + } + }); + } } diff --git a/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts b/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts index 8400b40cc49..24b71a53c85 100644 --- a/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts @@ -3,6 +3,7 @@ import { createBr } from '../../lib/modelApi/creators/createBr'; import { createContentModelDocument } from '../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../lib/modelApi/creators/createDivider'; import { createEntity } from '../../lib/modelApi/creators/createEntity'; +import { createGeneralSegment } from '../../lib/modelApi/creators/createGeneralSegment'; import { createImage } from '../../lib/modelApi/creators/createImage'; import { createListItem } from '../../lib/modelApi/creators/createListItem'; import { createListLevel } from '../../lib/modelApi/creators/createListLevel'; @@ -189,4 +190,44 @@ describe('modelToText', () => { expect(text).toBe('text1test entitytext2'); }); + + it('With callbacks', () => { + const onDivider = jasmine.createSpy('onDivider').and.returnValue('divider'); + const onEntitySegment = jasmine + .createSpy('onEntitySegment') + .and.returnValue('entity segment'); + const onEntityBlock = jasmine.createSpy('onEntityBlock').and.returnValue('entity block'); + const onGeneralSegment = jasmine.createSpy('onGeneralSegment').and.returnValue('general'); + const onImage = jasmine.createSpy('onImage').and.returnValue('image'); + const onText = jasmine.createSpy('onText').and.returnValue('text'); + + const doc = createContentModelDocument(); + const para = createParagraph(); + const entitySegment = createEntity(null!); + const entityBlock = createEntity(null!); + const generalSegment = createGeneralSegment(null!); + const divider = createDivider('div'); + const image = createImage('src'); + const text = createText('test'); + + para.segments.push(entitySegment, generalSegment, image, text); + doc.blocks.push(para, entityBlock, divider); + + const result = contentModelToText(doc, '/', { + onDivider, + onEntityBlock, + onEntitySegment, + onGeneralSegment, + onImage, + onText, + }); + + expect(result).toBe('entity segmentgeneralimagetext/entity block/divider'); + expect(onDivider).toHaveBeenCalledWith(divider); + expect(onEntityBlock).toHaveBeenCalledWith(entityBlock); + expect(onEntitySegment).toHaveBeenCalledWith(entitySegment); + expect(onGeneralSegment).toHaveBeenCalledWith(generalSegment); + expect(onImage).toHaveBeenCalledWith(image); + expect(onText).toHaveBeenCalledWith(text); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 48463955673..a6b748c4c68 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -310,6 +310,11 @@ export { NodeTypeMap } from './parameter/NodeTypeMap'; export { TypeOfBlockGroup } from './parameter/TypeOfBlockGroup'; export { OperationalBlocks } from './parameter/OperationalBlocks'; export { ParsedTable, ParsedTableCell } from './parameter/ParsedTable'; +export { + ModelToTextCallback, + ModelToTextCallbacks, + ModelToTextChecker, +} from './parameter/ModelToTextCallbacks'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/ModelToTextCallbacks.ts b/packages/roosterjs-content-model-types/lib/parameter/ModelToTextCallbacks.ts new file mode 100644 index 00000000000..3ea41e57b31 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/ModelToTextCallbacks.ts @@ -0,0 +1,70 @@ +import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { ContentModelDivider } from '../contentModel/block/ContentModelDivider'; +import type { ContentModelEntity } from '../contentModel/entity/ContentModelEntity'; +import type { ContentModelGeneralSegment } from '../contentModel/segment/ContentModelGeneralSegment'; +import type { ContentModelImage } from '../contentModel/segment/ContentModelImage'; +import type { ContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ContentModelTable } from '../contentModel/block/ContentModelTable'; +import type { ContentModelText } from '../contentModel/segment/ContentModelText'; + +/** + * Callback function type for converting a given Content Model object to plain text + * @param model The source model object to be converted to plain text + */ +export type ModelToTextCallback = (model: T) => string; + +/** + * Callback function type for checking if we should convert to text for the given content model object + * @param model The source model to check if we should convert it to plain text + */ +export type ModelToTextChecker = (model: T) => boolean; + +/** + * Callbacks to customize the behavior of contentModelToText function + */ +export interface ModelToTextCallbacks { + /** + * Customize the behavior of converting entity segment to plain text + */ + onEntitySegment?: ModelToTextCallback; + + /** + * Customize the behavior of converting entity block to plain text + */ + onEntityBlock?: ModelToTextCallback; + + /** + * Customize the behavior of converting general segment to plain text + */ + onGeneralSegment?: ModelToTextCallback; + + /** + * Customize the behavior of converting text model to plain text + */ + onText?: ModelToTextCallback; + + /** + * Customize the behavior of converting image model to plain text + */ + onImage?: ModelToTextCallback; + + /** + * Customize the behavior of converting divider model to plain text + */ + onDivider?: ModelToTextCallback; + + /** + * Customize the check if we should convert a paragraph model to plain text + */ + onParagraph?: ModelToTextChecker; + + /** + * Customize the check if we should convert a table model to plain text + */ + onTable?: ModelToTextChecker; + + /** + * Customize the check if we should convert a block group model to plain text + */ + onBlockGroup?: ModelToTextChecker; +} diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index d6ac3946185..cc9f977ce6a 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -350,11 +350,22 @@ export class EditorAdapter extends Editor implements ILegacyEditor { * @returns HTML string representing current editor content */ getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { - return exportContent( - this, - GetContentModeMap[mode], - this.getCore().environment.modelToDomSettings.customized - ); + const exportMode = GetContentModeMap[mode] ?? 'HTML'; + + switch (exportMode) { + case 'HTML': + return exportContent( + this, + 'HTML', + this.getCore().environment.modelToDomSettings.customized + ); + + case 'PlainText': + return exportContent(this, 'PlainText'); + + case 'PlainTextFast': + return exportContent(this, 'PlainTextFast'); + } } /**