Skip to content

Commit 0996a11

Browse files
Markdown Package - Export model to markdown (#2953)
* markdown * WIP: TEST * markdown-processor * fix test * adjust unstable test * fix firefox tests * markdown support -part 2 * refactor * build fixes * fix blockquote * add and fix test * conflicts * fixes * fix string * WIP * fixes --------- Co-authored-by: Jiuqing Song <jisong@microsoft.com>
1 parent 7c6c3fa commit 0996a11

23 files changed

+1751
-269
lines changed

demo/scripts/controlsV2/sidePane/MarkdownPane/MarkdownPane.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as React from 'react';
2-
import { convertMarkdownToContentModel } from 'roosterjs-content-model-markdown';
32
import { MarkdownPaneProps } from './MarkdownPanePlugin';
3+
import {
4+
convertMarkdownToContentModel,
5+
convertContentModelToMarkdown,
6+
} from 'roosterjs-content-model-markdown';
47
import {
58
createBr,
69
createParagraph,
@@ -44,6 +47,14 @@ export default class MarkdownPane extends React.Component<MarkdownPaneProps> {
4447
});
4548
};
4649

50+
private generateMarkdown = () => {
51+
const model = this.props.getEditor().getContentModelCopy('disconnected');
52+
if (model) {
53+
const content = convertContentModelToMarkdown(model);
54+
this.html.current.value = content;
55+
}
56+
};
57+
4758
render() {
4859
return (
4960
<div className={styles.container}>
@@ -62,6 +73,9 @@ export default class MarkdownPane extends React.Component<MarkdownPaneProps> {
6273
<button type="button" onClick={this.convert}>
6374
Convert
6475
</button>
76+
<button type="button" onClick={this.generateMarkdown}>
77+
Create Markdown from editor content
78+
</button>
6579
</div>
6680
</div>
6781
);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
2+
3+
const HeaderFontSizes: Record<HeadingLevelTags, string> = {
4+
h1: '2em',
5+
h2: '1.5em',
6+
h3: '1.17em',
7+
h4: '1em',
8+
h5: '0.83em',
9+
h6: '0.67em',
10+
};
11+
12+
/**
13+
* @internal
14+
*/
15+
export const headingLevels = [
16+
{ prefix: '# ', tagName: 'h1', fontSize: HeaderFontSizes.h1 },
17+
{ prefix: '## ', tagName: 'h2', fontSize: HeaderFontSizes.h2 },
18+
{ prefix: '### ', tagName: 'h3', fontSize: HeaderFontSizes.h3 },
19+
{ prefix: '#### ', tagName: 'h4', fontSize: HeaderFontSizes.h4 },
20+
{ prefix: '##### ', tagName: 'h5', fontSize: HeaderFontSizes.h5 },
21+
{ prefix: '###### ', tagName: 'h6', fontSize: HeaderFontSizes.h6 },
22+
];
23+
24+
/**
25+
* @internal
26+
*/
27+
export const MarkdownHeadings: Record<string, string> = {
28+
h1: '# ',
29+
h2: '## ',
30+
h3: '### ',
31+
h4: '#### ',
32+
h5: '##### ',
33+
h6: '###### ',
34+
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* The characters to add line breaks and new lines
3+
*/
4+
export interface MarkdownLineBreaks {
5+
newLine: string;
6+
lineBreak: string;
7+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { convertMarkdownToContentModel } from './markdownToModel/convertMarkdownToContentModel';
2+
export { convertContentModelToMarkdown } from './modelToMarkdown/convertContentModelToMarkdown';
3+
export { MarkdownLineBreaks } from '../lib/constants/markdownLineBreaks';

packages/roosterjs-content-model-markdown/lib/markdownToModel/creators/createListFromMarkdown.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,20 @@ import type { ContentModelListItem } from 'roosterjs-content-model-types';
77
*/
88
export function createListFromMarkdown(text: string, listType: 'OL' | 'UL'): ContentModelListItem {
99
const marker = text.trim().split(' ')[0];
10-
const itemText = text.trim().substring(marker.length);
10+
const isDummy = isDummyListItem(marker);
11+
const itemText = isDummy ? text : text.trim().substring(marker.length);
1112
const paragraph = createParagraphFromMarkdown(itemText.trim());
12-
const levels = createLevels(text, listType);
13+
const levels = createLevels(text, listType, isDummy);
1314
const listModel = createListItem(levels);
1415
listModel.blocks.push(paragraph);
1516
return listModel;
1617
}
1718

18-
function createLevels(text: string, listType: 'OL' | 'UL') {
19+
function createLevels(text: string, listType: 'OL' | 'UL', isDummy: boolean) {
1920
const level = createListLevel(listType);
21+
if (isDummy) {
22+
level.format.displayForDummyItem = 'block';
23+
}
2024
const levels = [level];
2125
if (isSubListItem(text)) {
2226
levels.push(level);
@@ -27,3 +31,7 @@ function createLevels(text: string, listType: 'OL' | 'UL') {
2731
function isSubListItem(item: string): boolean {
2832
return item.startsWith(' ');
2933
}
34+
35+
const isDummyListItem = (item: string) => {
36+
return item != '-' && item != '+' && item != '*' && !item.endsWith('.');
37+
};

packages/roosterjs-content-model-markdown/lib/markdownToModel/creators/createTableFromMarkdown.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@ export function createTableFromMarkdown(tableLines: string[]): ContentModelTable
2424
}
2525

2626
function createTableModel(markdown: string, table: ContentModelTable, tableDivider: string[]) {
27-
const contents = markdown.split('|').filter(content => content.trim() !== '');
27+
const contents = markdown.split('|');
28+
if (contents[0].trim() === '') {
29+
contents.shift();
30+
}
31+
if (contents[contents.length - 1].trim() === '') {
32+
contents.pop();
33+
}
34+
2835
addTableRow(table, contents, tableDivider);
2936
}
3037

packages/roosterjs-content-model-markdown/lib/markdownToModel/processor/markdownProcessor.ts

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,24 @@ import type {
88
ContentModelBlockType,
99
ContentModelDocument,
1010
ContentModelFormatContainer,
11+
ContentModelListItem,
1112
ShallowMutableContentModelDocument,
1213
} from 'roosterjs-content-model-types';
1314

15+
interface MarkdownContext {
16+
lastQuote?: ContentModelFormatContainer;
17+
lastList?: ContentModelListItem;
18+
tableLines: string[];
19+
}
20+
1421
const MarkdownPattern: Record<string, RegExp> = {
1522
heading: /^#{1,6} .*/,
1623
horizontal_line: /^---$/,
1724
table: /^\|.*\|\s*$/,
1825
blockquote: /^>\s.*$/,
1926
unordered_list: /^\s*[\*\-\+] .*/,
2027
ordered_list: /^\s*\d+\. .*/,
28+
space: /^\s*$/,
2129
paragraph: /^[^#\-\*\d\|].*/,
2230
};
2331

@@ -29,6 +37,7 @@ const MarkdownBlockType: Record<string, ContentModelBlockType> = {
2937
ordered_list: 'BlockGroup',
3038
table: 'Table',
3139
blockquote: 'BlockGroup',
40+
space: 'Paragraph',
3241
};
3342

3443
/**
@@ -43,7 +52,7 @@ export function markdownProcessor(
4352
text: string,
4453
splitLinesPattern: string | RegExp
4554
): ContentModelDocument {
46-
const markdownText = text.split(splitLinesPattern).filter(line => line.trim().length > 0);
55+
const markdownText = text.split(splitLinesPattern);
4756
markdownText.push(''); // Add an empty line to make sure the last block is processed
4857
const doc = createContentModelDocument();
4958
return convertMarkdownText(doc, markdownText);
@@ -54,27 +63,44 @@ function addMarkdownBlockToModel(
5463
blockType: ContentModelBlockType,
5564
markdown: string,
5665
patternName: string,
57-
table: string[],
58-
quote: {
59-
lastQuote?: ContentModelFormatContainer;
60-
}
66+
markdownContext: MarkdownContext
6167
) {
62-
if (blockType !== 'Table' && table && table.length > 0) {
68+
if (
69+
blockType !== 'Table' &&
70+
markdownContext.tableLines &&
71+
markdownContext.tableLines.length > 0
72+
) {
6373
if (
64-
table.length > 2 &&
65-
table[1].trim().length > 0 &&
66-
isMarkdownTable(table[1]) &&
67-
table.length > 1
74+
markdownContext.tableLines.length > 2 &&
75+
markdownContext.tableLines[1].trim().length > 0 &&
76+
isMarkdownTable(markdownContext.tableLines[1]) &&
77+
markdownContext.tableLines.length > 1
6878
) {
69-
const tableModel = createTableFromMarkdown(table);
79+
const tableModel = createTableFromMarkdown(markdownContext.tableLines);
7080
model.blocks.push(tableModel);
7181
} else {
72-
for (const line of table) {
82+
for (const line of markdownContext.tableLines) {
7383
const paragraph = createParagraphFromMarkdown(line);
7484
model.blocks.push(paragraph);
7585
}
7686
}
77-
table.length = 0;
87+
markdownContext.tableLines.length = 0;
88+
}
89+
90+
if (patternName == 'space') {
91+
markdownContext.tableLines = [];
92+
markdownContext.lastQuote = undefined;
93+
markdownContext.lastList = undefined;
94+
return;
95+
}
96+
97+
if (blockType == 'Paragraph' && (markdownContext.lastList || markdownContext.lastQuote)) {
98+
blockType = 'BlockGroup';
99+
patternName = markdownContext.lastList
100+
? markdownContext.lastList.levels[0].listType == 'OL'
101+
? 'ordered_list'
102+
: 'unordered_list'
103+
: 'blockquote';
78104
}
79105

80106
switch (blockType) {
@@ -87,30 +113,36 @@ function addMarkdownBlockToModel(
87113
model.blocks.push(divider);
88114
break;
89115
case 'BlockGroup':
90-
const blockGroup = createBlockGroupFromMarkdown(markdown, patternName, quote.lastQuote);
91-
if (!quote.lastQuote) {
116+
const blockGroup = createBlockGroupFromMarkdown(
117+
markdown,
118+
patternName,
119+
markdownContext.lastQuote
120+
);
121+
if (!markdownContext.lastQuote) {
92122
model.blocks.push(blockGroup);
93123
}
94-
quote.lastQuote =
95-
blockGroup.blockGroupType === 'FormatContainer' ? blockGroup : undefined;
124+
markdownContext.lastQuote =
125+
blockGroup.blockGroupType == 'FormatContainer' ? blockGroup : undefined;
126+
markdownContext.lastList =
127+
blockGroup.blockGroupType == 'ListItem' ? blockGroup : undefined;
96128
break;
97129
case 'Table':
98-
table = table || [];
99-
table.push(markdown);
130+
markdownContext.tableLines = markdownContext.tableLines || [];
131+
markdownContext.tableLines.push(markdown);
100132
break;
101133
}
102134

103135
if (blockType !== 'BlockGroup') {
104-
quote.lastQuote = undefined;
136+
markdownContext.lastQuote = undefined;
137+
markdownContext.lastList = undefined;
105138
}
106139
}
107140

108141
function convertMarkdownText(model: ContentModelDocument, lines: string[]): ContentModelDocument {
109-
const tableLines: string[] = [];
110-
const quoteModel: {
111-
lastQuote?: ContentModelFormatContainer;
112-
} = {
142+
const markdownContext: MarkdownContext = {
113143
lastQuote: undefined,
144+
lastList: undefined,
145+
tableLines: [],
114146
};
115147
for (const line of lines) {
116148
let matched = false;
@@ -123,8 +155,7 @@ function convertMarkdownText(model: ContentModelDocument, lines: string[]): Cont
123155
MarkdownBlockType[patternName],
124156
line,
125157
patternName,
126-
tableLines,
127-
quoteModel
158+
markdownContext
128159
);
129160
matched = true;
130161
break;
@@ -133,7 +164,7 @@ function convertMarkdownText(model: ContentModelDocument, lines: string[]): Cont
133164
}
134165

135166
if (!matched) {
136-
addMarkdownBlockToModel(model, 'Paragraph', line, 'paragraph', tableLines, quoteModel);
167+
addMarkdownBlockToModel(model, 'Paragraph', line, 'paragraph', markdownContext);
137168
}
138169
}
139170
return model;

packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/adjustHeading.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1+
import { MarkdownHeadings } from '../../constants/headings';
12
import type {
23
ContentModelParagraphDecorator,
34
ContentModelText,
45
} from 'roosterjs-content-model-types';
56

6-
const MarkdownHeadings: Record<string, string> = {
7-
h1: '# ',
8-
h2: '## ',
9-
h3: '### ',
10-
h4: '#### ',
11-
h5: '##### ',
12-
h6: '###### ',
13-
};
14-
157
/**
168
* @internal
179
*/

packages/roosterjs-content-model-markdown/lib/markdownToModel/utils/getHeadingDecorator.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,6 @@
1+
import { headingLevels } from '../../constants/headings';
12
import type { ContentModelParagraphDecorator } from 'roosterjs-content-model-types';
23

3-
type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
4-
5-
const HeaderFontSizes: Record<HeadingLevelTags, string> = {
6-
h1: '2em',
7-
h2: '1.5em',
8-
h3: '1.17em',
9-
h4: '1em',
10-
h5: '0.83em',
11-
h6: '0.67em',
12-
};
13-
14-
const headingLevels = [
15-
{ prefix: '# ', tagName: 'h1', fontSize: HeaderFontSizes.h1 },
16-
{ prefix: '## ', tagName: 'h2', fontSize: HeaderFontSizes.h2 },
17-
{ prefix: '### ', tagName: 'h3', fontSize: HeaderFontSizes.h3 },
18-
{ prefix: '#### ', tagName: 'h4', fontSize: HeaderFontSizes.h4 },
19-
{ prefix: '##### ', tagName: 'h5', fontSize: HeaderFontSizes.h5 },
20-
{ prefix: '###### ', tagName: 'h6', fontSize: HeaderFontSizes.h6 },
21-
];
22-
234
/**
245
* @internal
256
*/
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { modelProcessor } from './processor/modelProcessor';
2+
import type { MarkdownLineBreaks } from '../constants/markdownLineBreaks';
3+
import type { ContentModelDocument } from 'roosterjs-content-model-types';
4+
5+
/**
6+
* Export content model document to markdown
7+
* @param selection The editor selection
8+
* @param newLine The new line string to use. Default is '\n\n'
9+
* @returns The markdown string
10+
*/
11+
export function convertContentModelToMarkdown(
12+
model: ContentModelDocument,
13+
newLine?: MarkdownLineBreaks
14+
): string {
15+
return modelProcessor(model, newLine);
16+
}

0 commit comments

Comments
 (0)