Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ export function applySegmentFormatting(
if (segment.type === 'link') {
applyLink(formattedSegment, segment.text, segment.url);
}
adjustHeading(formattedSegment, decorator);
const formattedSegments = applyTextFormatting(formattedSegment);
paragraph.segments.push(...formattedSegments);
const segmentWithAdjustedHeading = adjustHeading(formattedSegment, decorator);
if (segmentWithAdjustedHeading) {
const formattedSegments = applyTextFormatting(formattedSegment);
paragraph.segments.push(...formattedSegments);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,187 @@ import type {
ContentModelText,
} from 'roosterjs-content-model-types';

const SPLIT_PATTERN = /(\*\*\*.*?\*\*\*|\*\*.*?\*\*|\*.*?\*|\~\~.*?\~\~)/;
interface FormattingState {
bold: boolean;
italic: boolean;
strikethrough: boolean;
}

interface FormatMarker {
type: 'bold' | 'italic' | 'strikethrough';
length: number;
}

/**
* @internal
*/
export function applyTextFormatting(textSegment: ContentModelText) {
const texts = splitSegments(textSegment.text);
const text = textSegment.text;

// Quick check: if the text contains only formatting markers, return original
if (isOnlyFormattingMarkers(text)) {
return [textSegment];
}

const textSegments: ContentModelText[] = [];
for (const text of texts) {
textSegments.push(createFormattedSegment(text, textSegment.format, textSegment.link));
const currentState: FormattingState = { bold: false, italic: false, strikethrough: false };

let currentText = '';
let i = 0;

while (i < text.length) {
const marker = parseMarkerAt(text, i);

if (marker) {
// Check if this marker should be treated as formatting or as literal text
if (shouldToggleFormatting(text, i, marker, currentState)) {
// If we have accumulated text, create a segment for it
if (currentText.length > 0) {
textSegments.push(
createFormattedSegment(
currentText,
textSegment.format,
currentState,
textSegment.link
)
);
currentText = '';
}

// Toggle the formatting state
toggleFormatting(currentState, marker.type);

// Skip the marker characters
i += marker.length;
} else {
// Treat as regular text if marker is not valid in this context
currentText += text[i];
i++;
}
} else {
// Regular character, add to current text
currentText += text[i];
i++;
}
}

// Add any remaining text as a final segment
if (currentText.length > 0) {
textSegments.push(
createFormattedSegment(currentText, textSegment.format, currentState, textSegment.link)
);
}

// If no meaningful formatting was applied, return the original segment
if (
textSegments.length === 0 ||
(textSegments.length === 1 && textSegments[0].text === textSegment.text)
) {
return [textSegment];
}

return textSegments;
}

function splitSegments(text: string): string[] {
return text.split(SPLIT_PATTERN).filter(s => s.trim().length > 0);
function isOnlyFormattingMarkers(text: string): boolean {
// Remove all potential formatting markers and see if anything remains
let remaining = text;
remaining = remaining.replace(/\*\*/g, ''); // Remove **
remaining = remaining.replace(/~~/g, ''); // Remove ~~
remaining = remaining.replace(/\*/g, ''); // Remove *

// If nothing remains after removing all markers, it was only markers
return remaining.length === 0;
}

function parseMarkerAt(text: string, index: number): FormatMarker | null {
const remaining = text.substring(index);

if (remaining.startsWith('~~')) {
return { type: 'strikethrough', length: 2 };
}

if (remaining.startsWith('**')) {
return { type: 'bold', length: 2 };
}

if (remaining.startsWith('*')) {
return { type: 'italic', length: 1 };
}

return null;
}

function shouldToggleFormatting(
text: string,
index: number,
marker: FormatMarker,
currentState: FormattingState
): boolean {
const nextChar = index + marker.length < text.length ? text.charAt(index + marker.length) : '';

const isCurrentlyActive = getCurrentFormatState(currentState, marker.type);

if (isCurrentlyActive) {
// We're currently in this format, so any marker can close it
return true;
} else {
// We're not in this format, so this marker would open it
// Opening markers must be followed by non-whitespace
return nextChar.length > 0 && !isWhitespace(nextChar);
}
}

function isWhitespace(char: string): boolean {
return /\s/.test(char);
}

function toggleFormatting(state: FormattingState, type: 'bold' | 'italic' | 'strikethrough'): void {
switch (type) {
case 'bold':
state.bold = !state.bold;
break;
case 'italic':
state.italic = !state.italic;
break;
case 'strikethrough':
state.strikethrough = !state.strikethrough;
break;
}
}

function getCurrentFormatState(
state: FormattingState,
type: 'bold' | 'italic' | 'strikethrough'
): boolean {
switch (type) {
case 'bold':
return state.bold;
case 'italic':
return state.italic;
case 'strikethrough':
return state.strikethrough;
}
}

function createFormattedSegment(
text: string,
format: ContentModelSegmentFormat,
baseFormat: ContentModelSegmentFormat,
state: FormattingState,
link?: ContentModelLink
): ContentModelText {
if (text.startsWith('***') && text.endsWith('***')) {
format = { ...format, fontWeight: 'bold', italic: true };
text = text.replace(/\*\*\*/g, '');
text = text + ' ';
} else if (text.startsWith('**') && text.endsWith('**')) {
format = { ...format, fontWeight: 'bold' };
text = text.replace(/\*\*/g, '');
text = text + ' ';
} else if (text.startsWith('*') && text.endsWith('*')) {
format = { ...format, italic: true };
text = text.replace(/\*/g, '');
text = text + ' ';
} else if (text.startsWith('~~') && text.endsWith('~~')) {
format = { ...format, strikethrough: true };
text = text.replace(/\~\~/g, '');
const format: ContentModelSegmentFormat = { ...baseFormat };

if (state.bold) {
format.fontWeight = 'bold';
}

if (state.italic) {
format.italic = true;
}

if (state.strikethrough) {
format.strikethrough = true;
}

return createText(text, format, link);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import type {
export function adjustHeading(
textSegment: ContentModelText,
decorator?: ContentModelParagraphDecorator
): ContentModelText {
): ContentModelText | null {
const markdownToBeRemoved = MarkdownHeadings[decorator?.tagName || ''];
if (markdownToBeRemoved) {
textSegment.text = textSegment.text.replace(markdownToBeRemoved, '');
if (textSegment.text.length === 0) {
// If the text becomes empty after removing the heading markdown, we can remove the segment
return null;
}
}
return textSegment;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe('applySegmentFormatting', () => {
it('Bold', () => {
const paragraph = createParagraph();
const segment = createText('text in ');
const bold = createText('bold ', { fontWeight: 'bold' });
const bold = createText('bold', { fontWeight: 'bold' });
paragraph.segments.push(segment);
paragraph.segments.push(bold);
runTest('text in **bold**', paragraph);
Expand All @@ -40,7 +40,7 @@ describe('applySegmentFormatting', () => {
it('Italic', () => {
const paragraph = createParagraph();
const segment = createText('text in ');
const italic = createText('italic ', { italic: true });
const italic = createText('italic', { italic: true });
paragraph.segments.push(segment);
paragraph.segments.push(italic);
runTest('text in *italic*', paragraph);
Expand All @@ -49,7 +49,7 @@ describe('applySegmentFormatting', () => {
it('Bold and Italic', () => {
const paragraph = createParagraph();
const segment = createText('text in ');
const boldItalic = createText('bold and italic ', { fontWeight: 'bold', italic: true });
const boldItalic = createText('bold and italic', { fontWeight: 'bold', italic: true });
paragraph.segments.push(segment);
paragraph.segments.push(boldItalic);
runTest('text in ***bold and italic***', paragraph);
Expand All @@ -74,11 +74,11 @@ describe('applySegmentFormatting', () => {
it('Multiple Bold and Italic', () => {
const paragraph = createParagraph();
const segment1 = createText('text in ');
const boldItalic = createText('bold and italic ', { fontWeight: 'bold', italic: true });
const boldItalic = createText('bold and italic', { fontWeight: 'bold', italic: true });
const segment2 = createText(' and ');
const bold = createText('bold ', { fontWeight: 'bold' });
const bold = createText('bold', { fontWeight: 'bold' });
const segment3 = createText(' and ');
const italic = createText('italic ', { italic: true });
const italic = createText('italic', { italic: true });
paragraph.segments.push(segment1);
paragraph.segments.push(boldItalic);
paragraph.segments.push(segment2);
Expand Down Expand Up @@ -113,11 +113,11 @@ describe('applySegmentFormatting', () => {
},
};
const segment3 = createText(' and ');
const boldItalic = createText('bold and italic ', { fontWeight: 'bold', italic: true });
const boldItalic = createText('bold and italic', { fontWeight: 'bold', italic: true });
const segment4 = createText(' and ');
const bold = createText('bold ', { fontWeight: 'bold' });
const bold = createText('bold', { fontWeight: 'bold' });
const segment5 = createText(' and ');
const italic = createText('italic ', { italic: true });
const italic = createText('italic', { italic: true });
paragraph.segments.push(segment1);
paragraph.segments.push(image);
paragraph.segments.push(segment2);
Expand Down
Loading
Loading