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

Allow bold, italic, strikethrough ranges to be splitted by emoji to stop formatting them #355

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
49 changes: 49 additions & 0 deletions parser/__tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -526,3 +526,52 @@ describe('report mentions', () => {
expect('reported #report-name!').toBeParsedAs([{type: 'mention-report', start: 9, length: 12}]);
});
});

describe('nested emojis', () => {
test('italic text with nested emoji', () => {
expect('_Hello 😎 world_').toBeParsedAs([
{type: 'syntax', start: 0, length: 1},
{type: 'italic', start: 1, length: 6},
{type: 'emoji', start: 7, length: 2},
{type: 'italic', start: 9, length: 6},
{type: 'syntax', start: 15, length: 1},
]);
});

test('emoji with nested bold text', () => {
expect('*Hello 😎 world*').toBeParsedAs([
{type: 'syntax', start: 0, length: 1},
{type: 'bold', start: 1, length: 6},
{type: 'emoji', start: 7, length: 2},
{type: 'bold', start: 9, length: 6},
{type: 'syntax', start: 15, length: 1},
]);
});

test('multiple emojis with nested text', () => {
expect('*Hello😎 😎 😎world*').toBeParsedAs([
{type: 'syntax', start: 0, length: 1},
{type: 'bold', start: 1, length: 5},
{type: 'emoji', start: 6, length: 2},
{type: 'bold', start: 8, length: 1},
{type: 'emoji', start: 9, length: 2},
{type: 'bold', start: 11, length: 1},
{type: 'emoji', start: 12, length: 2},
{type: 'bold', start: 14, length: 5},
{type: 'syntax', start: 19, length: 1},
]);
});
test('nested emoji in bold and italic', () => {
expect('*_hello 😎 world_*').toBeParsedAs([
{type: 'syntax', start: 0, length: 1},
{type: 'syntax', start: 1, length: 1},
{type: 'bold', start: 1, length: 7},
{type: 'italic', start: 2, length: 6},
{type: 'emoji', start: 8, length: 2},
{type: 'italic', start: 10, length: 6},
{type: 'bold', start: 10, length: 7},
{type: 'syntax', start: 16, length: 1},
{type: 'syntax', start: 17, length: 1},
]);
});
});
70 changes: 61 additions & 9 deletions parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {ExpensiMark} from 'expensify-common/lib/ExpensiMark';
import _ from 'underscore';

type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'mention-here' | 'mention-user' | 'mention-report' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax';
const styleChangingTypes: MarkdownType[] = ['bold', 'italic', 'strikethrough'];
type Range = {
type: MarkdownType;
start: number;
Expand Down Expand Up @@ -217,28 +218,79 @@ function sortRanges(ranges: Range[]) {
return ranges.sort((a, b) => a.start - b.start || b.length - a.length || getTagPriority(b.type) - getTagPriority(a.type) || 0);
}

function groupRanges(ranges: Range[]) {
function groupAndSplitRanges(ranges: Range[]) {
const lastVisibleRangeIndex: {[key in MarkdownType]?: number} = {};

return ranges.reduce((acc, range) => {
const start = range.start;
const end = range.start + range.length;

const rangeWithSameStyleIndex = lastVisibleRangeIndex[range.type];
const sameStyleRange = rangeWithSameStyleIndex !== undefined ? acc[rangeWithSameStyleIndex] : undefined;

if (sameStyleRange && sameStyleRange.start <= start && sameStyleRange.start + sameStyleRange.length >= end && range.length > 1) {
if (sameStyleRange && isInRange(sameStyleRange, range) && range.length > 1) {
// increment depth of overlapping range
sameStyleRange.depth = (sameStyleRange.depth || 1) + 1;
} else {
lastVisibleRangeIndex[range.type] = acc.length;
acc.push(range);
return acc;
}

if (range.type === 'emoji') {
const splitted: Range[] = [];
const indexesToRemove: Array<number | undefined> = [];

const TypesRange = styleChangingTypes.flatMap((type) => {
const index = lastVisibleRangeIndex[type];
if (index !== undefined) {
return acc[index]!;
}
return [];
});

sortRanges(TypesRange).forEach((r) => {
if (!r || !isInRange(r, range)) {
return;
}
const newRanges = splitRange(r, range);
acc.push(newRanges[0]);
splitted.push(newRanges[1]);

// update lastVisibleRangeIndex
indexesToRemove.push(lastVisibleRangeIndex[r.type]);
});

const filtered = acc.filter((__, i) => !indexesToRemove.includes(i));

lastVisibleRangeIndex.emoji = filtered.length;
filtered.push(range);
// add inverted splitted ranges to acc
splitted.reverse().forEach((r) => {
lastVisibleRangeIndex[r.type] = filtered.length;
filtered.push(r);
});
return filtered;
}

lastVisibleRangeIndex[range.type] = acc.length;
acc.push(range);

return acc;
}, [] as Range[]);
}

function isInRange(outer?: Range, range?: Range) {
if (!outer || !range) {
return false;
}
return outer.start <= range.start && outer.start + outer.length >= range.start + range.length;
}

function splitRange(rangeToSplit: Range, range: Range): [Range, Range] {
const index = range.start;
const splitLength = range.length;

return [
{...rangeToSplit, length: index - rangeToSplit.start},
{...rangeToSplit, start: index + splitLength, length: rangeToSplit.start + rangeToSplit.length - index - splitLength},
];
}

function parseExpensiMarkToRanges(markdown: string): Range[] {
try {
const html = parseMarkdownToHTML(markdown);
Expand All @@ -253,7 +305,7 @@ function parseExpensiMarkToRanges(markdown: string): Range[] {
);
}
const sortedRanges = sortRanges(ranges);
const groupedRanges = groupRanges(sortedRanges);
const groupedRanges = groupAndSplitRanges(sortedRanges);
return groupedRanges;
} catch (error) {
console.error(error);
Expand Down
Loading
Loading