Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 7 commits into from
May 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading