Skip to content

Commit

Permalink
Live Markdown for web refactor (#394)
Browse files Browse the repository at this point in the history
Co-authored-by: Bartosz Grajdek <[email protected]>
  • Loading branch information
Skalakid and BartoszGrajdek authored Aug 21, 2024
1 parent 1dc15b9 commit 1be6561
Show file tree
Hide file tree
Showing 21 changed files with 1,061 additions and 689 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module.exports = {
root: true,
rules: {
'rulesdir/prefer-underscore-method': 'off',
'rulesdir/prefer-import-module-contents': 'off',
'react/jsx-props-no-spreading': 'off',
'react/require-default-props': 'off',
'react/jsx-filename-extension': ['error', { extensions: ['.tsx', '.jsx'] }],
Expand Down
12 changes: 6 additions & 6 deletions WebExample/__tests__/input.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {test, expect} from '@playwright/test';
import * as TEST_CONST from '../../example/src/testConstants';
import {checkCursorPosition, setupInput} from './utils';
import {getCursorPosition, getElementValue, setupInput} from './utils';

test.beforeEach(async ({page}) => {
await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'});
Expand All @@ -12,8 +12,8 @@ test.describe('typing', () => {

await inputLocator.focus();
await inputLocator.pressSequentially(TEST_CONST.EXAMPLE_CONTENT);
const value = await inputLocator.innerText();
expect(value).toEqual(TEST_CONST.EXAMPLE_CONTENT);

expect(await getElementValue(inputLocator)).toEqual(TEST_CONST.EXAMPLE_CONTENT);
});

test('fast type cursor position', async ({page}) => {
Expand All @@ -23,10 +23,10 @@ test.describe('typing', () => {

await inputLocator.pressSequentially(EXAMPLE_LONG_CONTENT);

expect(await inputLocator.innerText()).toBe(EXAMPLE_LONG_CONTENT);
expect(await getElementValue(inputLocator)).toBe(EXAMPLE_LONG_CONTENT);

const cursorPosition = await page.evaluate(checkCursorPosition);
const cursorPosition = await getCursorPosition(inputLocator);

expect(cursorPosition).toBe(EXAMPLE_LONG_CONTENT.length);
expect(cursorPosition.end).toBe(EXAMPLE_LONG_CONTENT.length);
});
});
36 changes: 19 additions & 17 deletions WebExample/__tests__/textManipulation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {test, expect} from '@playwright/test';
import type {Locator, Page} from '@playwright/test';
import * as TEST_CONST from '../../example/src/testConstants';
import {checkCursorPosition, setupInput, getElementStyle, pressCmd} from './utils';
import {getCursorPosition, setupInput, getElementStyle, pressCmd, getElementValue} from './utils';

const pasteContent = async ({text, page, inputLocator}: {text: string; page: Page; inputLocator: Locator}) => {
await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), text);
Expand Down Expand Up @@ -43,7 +43,7 @@ test.describe('paste content', () => {
const newText = '*bold*';
await pasteContent({text: newText, page, inputLocator});

expect(await inputLocator.innerText()).toBe(newText);
expect(await getElementValue(inputLocator)).toBe(newText);
});

test('paste undo', async ({page, browserName}) => {
Expand All @@ -61,10 +61,9 @@ test.describe('paste content', () => {
await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_SECOND);
await pressCmd({inputLocator, command: 'v'});
await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS);

await pressCmd({inputLocator, command: 'z'});

expect(await inputLocator.innerText()).toBe(PASTE_TEXT_FIRST);
await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS);
expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST);
});

test('paste redo', async ({page}) => {
Expand All @@ -84,7 +83,7 @@ test.describe('paste content', () => {
await pressCmd({inputLocator, command: 'z'});
await pressCmd({inputLocator, command: 'Shift+z'});

expect(await inputLocator.innerText()).toBe(`${PASTE_TEXT_FIRST}${PASTE_TEXT_SECOND}`);
expect(await getElementValue(inputLocator)).toBe(`${PASTE_TEXT_FIRST}${PASTE_TEXT_SECOND}`);
});
});

Expand All @@ -93,9 +92,9 @@ test('select all', async ({page}) => {
await inputLocator.focus();
await pressCmd({inputLocator, command: 'a'});

const cursorPosition = await page.evaluate(checkCursorPosition);
const cursorPosition = await getCursorPosition(inputLocator);

expect(cursorPosition).toBe(TEST_CONST.EXAMPLE_CONTENT.length);
expect(cursorPosition.end).toBe(TEST_CONST.EXAMPLE_CONTENT.length);
});

test('cut content changes', async ({page, browserName}) => {
Expand All @@ -107,15 +106,12 @@ test('cut content changes', async ({page, browserName}) => {

const inputLocator = await setupInput(page, 'clear');
await pasteContent({text: WRAPPED_CONTENT, page, inputLocator});
const rootHandle = await inputLocator.locator('span.root').first();

await page.evaluate(async (initialContent) => {
const filteredNode = Array.from(document.querySelectorAll('div[contenteditable="true"] > span.root span')).find((node) => {
return node.textContent?.includes(initialContent) && node.nextElementSibling && node.nextElementSibling.textContent?.includes('*');
});
await page.evaluate(async () => {
const filteredNode = Array.from(document.querySelectorAll('span[data-type="text"]'));

const startNode = filteredNode;
const endNode = filteredNode?.nextElementSibling;
const startNode = filteredNode[1];
const endNode = filteredNode[2];

if (startNode?.firstChild && endNode?.lastChild) {
const range = new Range();
Expand All @@ -126,10 +122,16 @@ test('cut content changes', async ({page, browserName}) => {
selection?.removeAllRanges();
selection?.addRange(range);
}
}, INITIAL_CONTENT);

return filteredNode;
});

await inputLocator.focus();
await pressCmd({inputLocator, command: 'x'});

expect(await rootHandle.innerHTML()).toBe(EXPECTED_CONTENT);
expect(await getElementValue(inputLocator)).toBe(EXPECTED_CONTENT);

// Ckeck if there is no markdown elements after the cut operation
const spans = await inputLocator.locator('span[data-type="text"]');
expect(await spans.count()).toBe(1);
});
29 changes: 16 additions & 13 deletions WebExample/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,10 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => {
return inputLocator;
};

const checkCursorPosition = () => {
const editableDiv = document.querySelector('div[contenteditable="true"]') as HTMLElement;
const range = window.getSelection()?.getRangeAt(0);
if (!range || !editableDiv) {
return null;
}
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(editableDiv);
preCaretRange.setEnd(range.endContainer, range.endOffset);
return preCaretRange.toString().length;
const getCursorPosition = async (elementHandle: Locator) => {
const inputSelectionHandle = await elementHandle.evaluateHandle((div: HTMLInputElement) => ({start: div.selectionStart, end: div.selectionEnd}));
const selection = await inputSelectionHandle.jsonValue();
return selection;
};

const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => {
Expand All @@ -43,8 +37,11 @@ const getElementStyle = async (elementHandle: Locator) => {

if (elementHandle) {
await elementHandle.waitFor({state: 'attached'});

elementStyle = await elementHandle.getAttribute('style');
// We need to get styles from the parent element because every text node is wrapped additionally with a span element
const parentElementHandle = await elementHandle.evaluateHandle((element) => {
return element.parentElement;
});
elementStyle = await parentElementHandle.asElement()?.getAttribute('style');
}
return elementStyle;
};
Expand All @@ -55,4 +52,10 @@ const pressCmd = async ({inputLocator, command}: {inputLocator: Locator; command
await inputLocator.press(`${OPERATION_MODIFIER}+${command}`);
};

export {setupInput, checkCursorPosition, setCursorPosition, getElementStyle, pressCmd};
const getElementValue = async (elementHandle: Locator) => {
const inputValueHandle = await elementHandle.evaluateHandle((div: HTMLInputElement) => div.value);
const value = await inputValueHandle.jsonValue();
return value;
};

export {setupInput, getCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue};
2 changes: 0 additions & 2 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import * as React from 'react';

import {Button, Platform, StyleSheet, Text, View} from 'react-native';

import {MarkdownTextInput} from '@expensify/react-native-live-markdown';
import type {TextInput} from 'react-native';
import * as TEST_CONST from './testConstants';
Expand Down
6 changes: 3 additions & 3 deletions parser/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import {expect} from '@jest/globals';
import type * as ParserTypes from '../index';
import type {Range} from '../index';

require('../react-native-live-markdown-parser.js');

declare module 'expect' {
interface Matchers<R> {
toBeParsedAs(expectedRanges: ParserTypes.Range[]): R;
toBeParsedAs(expectedRanges: Range[]): R;
}
}

const toBeParsedAs = function (actual: string, expectedRanges: ParserTypes.Range[]) {
const toBeParsedAs = function (actual: string, expectedRanges: Range[]) {
const actualRanges = global.parseExpensiMarkToRanges(actual);
if (JSON.stringify(actualRanges) !== JSON.stringify(expectedRanges)) {
return {
Expand Down
14 changes: 7 additions & 7 deletions parser/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// eslint-disable-next-line import/no-unresolved
import ExpensiMark from 'expensify-common/dist/ExpensiMark';
import * as Utils from './utils';
import {unescapeText} from './utils';

type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'mention-here' | 'mention-user' | 'mention-report' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax';
type Range = {
Expand Down Expand Up @@ -49,7 +49,7 @@ function parseTokensToTree(tokens: Token[]): StackItem {
const stack: StackItem[] = [{tag: '<>', children: []}];
tokens.forEach(([type, payload]) => {
if (type === 'TEXT') {
const text = Utils.unescapeText(payload);
const text = unescapeText(payload);
const top = stack[stack.length - 1];
top!.children.push(text);
} else if (type === 'HTML') {
Expand Down Expand Up @@ -162,10 +162,10 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, Range[]] {
appendSyntax('```');
} else if (node.tag.startsWith('<a href="')) {
const rawHref = node.tag.match(/href="([^"]*)"/)![1]!; // always present
const href = Utils.unescapeText(rawHref);
const href = unescapeText(rawHref);
const isLabeledLink = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
const dataRawHref = node.tag.match(/data-raw-href="([^"]*)"/);
const matchString = dataRawHref ? Utils.unescapeText(dataRawHref[1]!) : href;
const matchString = dataRawHref ? unescapeText(dataRawHref[1]!) : href;
if (!isLabeledLink && node.children.length === 1 && typeof node.children[0] === 'string' && (node.children[0] === matchString || `mailto:${node.children[0]}` === href)) {
addChildrenWithStyle(node.children[0], 'link');
} else {
Expand All @@ -180,12 +180,12 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, Range[]] {
const alt = node.tag.match(/alt="([^"]*)"/);
const hasAlt = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
const rawLink = node.tag.match(/data-raw-href="([^"]*)"/);
const linkString = rawLink ? Utils.unescapeText(rawLink[1]!) : src;
const linkString = rawLink ? unescapeText(rawLink[1]!) : src;

appendSyntax('!');
if (hasAlt) {
appendSyntax('[');
processChildren(Utils.unescapeText(alt?.[1] || ''));
processChildren(unescapeText(alt?.[1] || ''));
appendSyntax(']');
}
appendSyntax('(');
Expand All @@ -195,7 +195,7 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, Range[]] {
const src = node.tag.match(/data-expensify-source="([^"]*)"/)![1]!; // always present
const rawLink = node.tag.match(/data-raw-href="([^"]*)"/);
const hasAlt = node.tag.match(/data-link-variant="([^"]*)"/)![1] === 'labeled';
const linkString = rawLink ? Utils.unescapeText(rawLink[1]!) : src;
const linkString = rawLink ? unescapeText(rawLink[1]!) : src;
appendSyntax('!');
if (hasAlt) {
appendSyntax('[');
Expand Down
11 changes: 4 additions & 7 deletions src/MarkdownTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ import {StyleSheet, TextInput, processColor} from 'react-native';
import React from 'react';
import type {TextInputProps} from 'react-native';
import MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent';
import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent';
import NativeLiveMarkdownModule from './NativeLiveMarkdownModule';
import type * as MarkdownTextInputDecoratorViewNativeComponentTypes from './MarkdownTextInputDecoratorViewNativeComponent';
import * as StyleUtils from './styleUtils';
import type * as StyleUtilsTypes from './styleUtils';
import {mergeMarkdownStyleWithDefault} from './styleUtils';
import type {PartialMarkdownStyle} from './styleUtils';

if (NativeLiveMarkdownModule) {
NativeLiveMarkdownModule.install();
}

type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle;
type MarkdownStyle = MarkdownTextInputDecoratorViewNativeComponentTypes.MarkdownStyle;

interface MarkdownTextInputProps extends TextInputProps {
markdownStyle?: PartialMarkdownStyle;
}
Expand All @@ -36,7 +33,7 @@ function processColorsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle {
}

function processMarkdownStyle(input: PartialMarkdownStyle | undefined): MarkdownStyle {
return processColorsInMarkdownStyle(StyleUtils.mergeMarkdownStyleWithDefault(input));
return processColorsInMarkdownStyle(mergeMarkdownStyleWithDefault(input));
}

const MarkdownTextInput = React.forwardRef<TextInput, MarkdownTextInputProps>((props, ref) => {
Expand Down
Loading

0 comments on commit 1be6561

Please sign in to comment.