Skip to content

Commit

Permalink
Fix #2601 allow customization when convert from content model to plai…
Browse files Browse the repository at this point in the history
…n text (#2605)

* Fix #2601 allow customization when convert from content model to plain text

* Improve

* fix test

* fix build
  • Loading branch information
JiuqingSong authored May 6, 2024
1 parent 73e62ec commit a3a14a4
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 66 deletions.
7 changes: 6 additions & 1 deletion demo/scripts/controlsV2/demoButtons/exportContentButton.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { exportContent } from 'roosterjs-content-model-core';
import { ModelToTextCallbacks } from 'roosterjs-content-model-types';
import type { RibbonButton } from '../roosterjsReact/ribbon';

/**
Expand All @@ -9,6 +10,10 @@ export type ExportButtonStringKey =
| 'menuNameExportHTML'
| 'menuNameExportText';

const callbacks: ModelToTextCallbacks = {
onImage: () => '[Image]',
};

/**
* "Export content" button on the format ribbon
*/
Expand All @@ -30,7 +35,7 @@ export const exportContentButton: RibbonButton<ExportButtonStringKey> = {
if (key == 'menuNameExportHTML') {
html = exportContent(editor);
} else if (key == 'menuNameExportText') {
html = `<pre>${exportContent(editor, 'PlainText')}</pre>`;
html = `<pre>${exportContent(editor, 'PlainText', callbacks)}</pre>`;
}

win.document.write(editor.getTrustedHTMLHandler()(html));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,57 @@ 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();
} else {
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');
Expand All @@ -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*/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ModelToTextCallbacks> = {
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<ModelToTextCallbacks>
) {
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;
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions packages/roosterjs-content-model-types/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit a3a14a4

Please sign in to comment.