From 4231d3b3a40c6836514860fcfd294a4c5f03ec95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 18 Sep 2025 17:17:49 +0200 Subject: [PATCH 1/3] Split ranges on inline code syntaxes --- src/parseExpensiMark.ts | 31 +++++++++++++++++++++++-------- src/rangeUtils.ts | 31 ++++++++++++++++++------------- src/web/utils/blockUtils.ts | 3 --- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 6e14e25b..5da6d0d4 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -6,7 +6,7 @@ import {unescapeText} from 'expensify-common/dist/utils'; import {decode} from 'html-entities'; import type {WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; import type {MarkdownType, MarkdownRange} from './commonTypes'; -import {groupRanges, sortRanges, splitRangesOnEmojis} from './rangeUtils'; +import {groupRanges, sortRanges, excludeRangeTypesFromFormatting} from './rangeUtils'; function isWeb() { return Platform.OS === 'web'; @@ -238,7 +238,23 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] { return [text, ranges]; } -const isNative = Platform.OS === 'android' || Platform.OS === 'ios'; +/** + * Creates a list of ranges that should not be formatted by certain markdown types (italic, strikethrough). + * This includes emojis and syntaxes of inline code blocks. + */ +function getRangesToExcludeFormatting(ranges: MarkdownRange[]) { + let closingSyntaxPosition: number | null = null; + return ranges.filter((range, index) => { + const nextRange = ranges[index + 1]; + if (nextRange && nextRange.type === 'code' && range.type === 'syntax') { + closingSyntaxPosition = nextRange.start + nextRange?.length; + } else if (closingSyntaxPosition !== null && range.type === 'syntax' && range.start <= closingSyntaxPosition) { + closingSyntaxPosition = null; + return true; + } + return range.type === 'emoji' || (ranges[index + 1]?.type === 'code' && range.type === 'syntax'); + }); +} function parseExpensiMark(markdown: string): MarkdownRange[] { if (markdown.length > MAX_PARSABLE_LENGTH) { @@ -257,12 +273,11 @@ function parseExpensiMark(markdown: string): MarkdownRange[] { return []; } let markdownRanges = sortRanges(ranges); - if (isNative) { - // Blocks applying italic and strikethrough styles to emojis on Android and iOS - // TODO: Remove this condition when splitting emojis inside the inline code block will be fixed on the web - markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); - } + + // Prevent italic and strikethrough formatting inside emojis and inline code blocks + const rangesToExclude = getRangesToExcludeFormatting(markdownRanges); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'italic', rangesToExclude); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', rangesToExclude); const groupedRanges = groupRanges(markdownRanges); return groupedRanges; diff --git a/src/rangeUtils.ts b/src/rangeUtils.ts index 8e12786a..c73b63f9 100644 --- a/src/rangeUtils.ts +++ b/src/rangeUtils.ts @@ -57,8 +57,13 @@ function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { return ungroupedRanges; } -function splitRangesOnEmojis(ranges: MarkdownRange[], type: MarkdownType): MarkdownRange[] { - const emojiRanges: MarkdownRange[] = ranges.filter((range) => range.type === 'emoji'); +/** + * Splits ranges of a specific type from being formatted by specified markdown types (e.g., 'emoji', 'syntax'). + * @param ranges - The array of MarkdownRange objects to process. + * @param baseMarkdownType - The base markdown type to exclude formatting from (e.g., 'italic'). + * @param rangesToExclude - The array of MarkdownRange objects representing the ranges to exclude from formatting. + */ +function excludeRangeTypesFromFormatting(ranges: MarkdownRange[], baseMarkdownType: MarkdownType, rangesToExclude: MarkdownRange[]): MarkdownRange[] { const newRanges: MarkdownRange[] = []; let i = 0; @@ -69,33 +74,33 @@ function splitRangesOnEmojis(ranges: MarkdownRange[], type: MarkdownType): Markd break; } - if (currentRange.type !== type) { + if (currentRange.type !== baseMarkdownType) { newRanges.push(currentRange); i++; } else { // Iterate through all emoji ranges before the end of the current range, splitting the current range at each intersection. - while (j < emojiRanges.length) { - const emojiRange = emojiRanges[j]; - if (!emojiRange || emojiRange.start > currentRange.start + currentRange.length) { + while (j < rangesToExclude.length) { + const excludeRange = rangesToExclude[j]; + if (!excludeRange || excludeRange.start > currentRange.start + currentRange.length) { break; } const currentStart: number = currentRange.start; const currentEnd: number = currentRange.start + currentRange.length; - const emojiStart: number = emojiRange.start; - const emojiEnd: number = emojiRange.start + emojiRange.length; + const excludeRangeStart: number = excludeRange.start; + const excludeRangeEnd: number = excludeRange.start + excludeRange.length; - if (emojiStart >= currentStart && emojiEnd <= currentEnd) { + if (excludeRangeStart >= currentStart && excludeRangeEnd <= currentEnd) { // Intersection const newRange: MarkdownRange = { type: currentRange.type, start: currentStart, - length: emojiStart - currentStart, + length: excludeRangeStart - currentStart, ...(currentRange?.depth && {depth: currentRange?.depth}), }; - currentRange.start = emojiEnd; - currentRange.length = currentEnd - emojiEnd; + currentRange.start = excludeRangeEnd; + currentRange.length = currentEnd - excludeRangeEnd; if (newRange.length > 0) { newRanges.push(newRange); @@ -113,4 +118,4 @@ function splitRangesOnEmojis(ranges: MarkdownRange[], type: MarkdownType): Markd return newRanges; } -export {sortRanges, groupRanges, ungroupRanges, splitRangesOnEmojis}; +export {sortRanges, groupRanges, ungroupRanges, excludeRangeTypesFromFormatting}; diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index e8679b7b..d69c466f 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -31,9 +31,6 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty Object.assign(node.style, { ...markdownStyle.emoji, verticalAlign: 'middle', - fontStyle: 'normal', // remove italic - textDecoration: 'none', // remove strikethrough - display: 'inline-block', }); break; case 'mention-here': From deed4406634f28ed42ff111f3d37a4011465bd0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 18 Sep 2025 17:28:55 +0200 Subject: [PATCH 2/3] Fix tests --- src/__tests__/parseExpensiMark.test.ts | 1 - src/__tests__/splitRangesOnEmojis.test.ts | 22 +++++++++++----------- src/parseExpensiMark.ts | 20 +------------------- src/rangeUtils.ts | 20 +++++++++++++++++++- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/__tests__/parseExpensiMark.test.ts b/src/__tests__/parseExpensiMark.test.ts index 143fcf3b..868cf42a 100644 --- a/src/__tests__/parseExpensiMark.test.ts +++ b/src/__tests__/parseExpensiMark.test.ts @@ -79,7 +79,6 @@ test('emoji', () => { test('emoji and italic', () => { expect('_😎_').toBeParsedAs([ {type: 'syntax', start: 0, length: 1}, - {type: 'italic', start: 1, length: 2}, {type: 'emoji', start: 1, length: 2}, {type: 'syntax', start: 3, length: 1}, ]); diff --git a/src/__tests__/splitRangesOnEmojis.test.ts b/src/__tests__/splitRangesOnEmojis.test.ts index a9eb11c3..6bd33220 100644 --- a/src/__tests__/splitRangesOnEmojis.test.ts +++ b/src/__tests__/splitRangesOnEmojis.test.ts @@ -1,5 +1,5 @@ import type {MarkdownRange} from '../commonTypes'; -import {splitRangesOnEmojis} from '../rangeUtils'; +import {excludeRangeTypesFromFormatting, getRangesToExcludeFormatting} from '../rangeUtils'; const sortRanges = (ranges: MarkdownRange[]) => { return ranges.sort((a, b) => a.start - b.start); @@ -11,7 +11,7 @@ test('no overlap', () => { {type: 'emoji', start: 12, length: 2}, ]; - const splittedRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + const splittedRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); expect(splittedRanges).toEqual([ {type: 'strikethrough', start: 0, length: 10}, {type: 'emoji', start: 12, length: 2}, @@ -24,7 +24,7 @@ test('overlap different type', () => { {type: 'emoji', start: 3, length: 4}, ]; - const splittedRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + const splittedRanges = excludeRangeTypesFromFormatting(markdownRanges, 'italic', getRangesToExcludeFormatting(markdownRanges)); expect(splittedRanges).toEqual(markdownRanges); }); @@ -35,7 +35,7 @@ describe('single overlap', () => { {type: 'emoji', start: 0, length: 2}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ @@ -50,7 +50,7 @@ describe('single overlap', () => { {type: 'emoji', start: 3, length: 4}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ @@ -66,7 +66,7 @@ describe('single overlap', () => { {type: 'emoji', start: 8, length: 2}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ @@ -82,7 +82,7 @@ describe('single overlap', () => { {type: 'emoji', start: 5, length: 2}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ @@ -101,7 +101,7 @@ describe('single overlap', () => { {type: 'emoji', start: 4, length: 2}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); expect(markdownRanges).toEqual([ {type: 'emoji', start: 0, length: 2}, @@ -121,7 +121,7 @@ describe('multiple overlaps', () => { {type: 'strikethrough', start: 22, length: 5}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ @@ -144,8 +144,8 @@ describe('multiple overlaps', () => { {type: 'strikethrough', start: 22, length: 5}, ]; - markdownRanges = splitRangesOnEmojis(markdownRanges, 'strikethrough'); - markdownRanges = splitRangesOnEmojis(markdownRanges, 'italic'); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'strikethrough', getRangesToExcludeFormatting(markdownRanges)); + markdownRanges = excludeRangeTypesFromFormatting(markdownRanges, 'italic', getRangesToExcludeFormatting(markdownRanges)); sortRanges(markdownRanges); expect(markdownRanges).toEqual([ diff --git a/src/parseExpensiMark.ts b/src/parseExpensiMark.ts index 5da6d0d4..15f520a8 100644 --- a/src/parseExpensiMark.ts +++ b/src/parseExpensiMark.ts @@ -6,7 +6,7 @@ import {unescapeText} from 'expensify-common/dist/utils'; import {decode} from 'html-entities'; import type {WorkletFunction} from 'react-native-reanimated/lib/typescript/commonTypes'; import type {MarkdownType, MarkdownRange} from './commonTypes'; -import {groupRanges, sortRanges, excludeRangeTypesFromFormatting} from './rangeUtils'; +import {groupRanges, sortRanges, excludeRangeTypesFromFormatting, getRangesToExcludeFormatting} from './rangeUtils'; function isWeb() { return Platform.OS === 'web'; @@ -238,24 +238,6 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, MarkdownRange[]] { return [text, ranges]; } -/** - * Creates a list of ranges that should not be formatted by certain markdown types (italic, strikethrough). - * This includes emojis and syntaxes of inline code blocks. - */ -function getRangesToExcludeFormatting(ranges: MarkdownRange[]) { - let closingSyntaxPosition: number | null = null; - return ranges.filter((range, index) => { - const nextRange = ranges[index + 1]; - if (nextRange && nextRange.type === 'code' && range.type === 'syntax') { - closingSyntaxPosition = nextRange.start + nextRange?.length; - } else if (closingSyntaxPosition !== null && range.type === 'syntax' && range.start <= closingSyntaxPosition) { - closingSyntaxPosition = null; - return true; - } - return range.type === 'emoji' || (ranges[index + 1]?.type === 'code' && range.type === 'syntax'); - }); -} - function parseExpensiMark(markdown: string): MarkdownRange[] { if (markdown.length > MAX_PARSABLE_LENGTH) { return []; diff --git a/src/rangeUtils.ts b/src/rangeUtils.ts index c73b63f9..5b0dddde 100644 --- a/src/rangeUtils.ts +++ b/src/rangeUtils.ts @@ -57,6 +57,24 @@ function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { return ungroupedRanges; } +/** + * Creates a list of ranges that should not be formatted by certain markdown types (italic, strikethrough). + * This includes emojis and syntaxes of inline code blocks. + */ +function getRangesToExcludeFormatting(ranges: MarkdownRange[]) { + let closingSyntaxPosition: number | null = null; + return ranges.filter((range, index) => { + const nextRange = ranges[index + 1]; + if (nextRange && nextRange.type === 'code' && range.type === 'syntax') { + closingSyntaxPosition = nextRange.start + nextRange.length; + } else if (closingSyntaxPosition !== null && range.type === 'syntax' && range.start <= closingSyntaxPosition) { + closingSyntaxPosition = null; + return true; + } + return range.type === 'emoji' || (ranges[index + 1]?.type === 'code' && range.type === 'syntax'); + }); +} + /** * Splits ranges of a specific type from being formatted by specified markdown types (e.g., 'emoji', 'syntax'). * @param ranges - The array of MarkdownRange objects to process. @@ -118,4 +136,4 @@ function excludeRangeTypesFromFormatting(ranges: MarkdownRange[], baseMarkdownTy return newRanges; } -export {sortRanges, groupRanges, ungroupRanges, excludeRangeTypesFromFormatting}; +export {sortRanges, groupRanges, ungroupRanges, excludeRangeTypesFromFormatting, getRangesToExcludeFormatting}; From 8f66d97facf6662463b328049238fb806e6b1623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 19 Sep 2025 10:42:28 +0200 Subject: [PATCH 3/3] Correct getRangesToExcludeFormatting function --- src/rangeUtils.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/rangeUtils.ts b/src/rangeUtils.ts index 5b0dddde..2297071e 100644 --- a/src/rangeUtils.ts +++ b/src/rangeUtils.ts @@ -67,11 +67,13 @@ function getRangesToExcludeFormatting(ranges: MarkdownRange[]) { const nextRange = ranges[index + 1]; if (nextRange && nextRange.type === 'code' && range.type === 'syntax') { closingSyntaxPosition = nextRange.start + nextRange.length; - } else if (closingSyntaxPosition !== null && range.type === 'syntax' && range.start <= closingSyntaxPosition) { + return true; + } + if (closingSyntaxPosition !== null && range.type === 'syntax' && range.start <= closingSyntaxPosition) { closingSyntaxPosition = null; return true; } - return range.type === 'emoji' || (ranges[index + 1]?.type === 'code' && range.type === 'syntax'); + return range.type === 'emoji'; }); } @@ -116,7 +118,6 @@ function excludeRangeTypesFromFormatting(ranges: MarkdownRange[], baseMarkdownTy length: excludeRangeStart - currentStart, ...(currentRange?.depth && {depth: currentRange?.depth}), }; - currentRange.start = excludeRangeEnd; currentRange.length = currentEnd - excludeRangeEnd;