diff --git a/.eslintrc.js b/.eslintrc.js index b9508e9a..a489703f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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'] }], diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts index f3accc26..3ec26376 100644 --- a/WebExample/__tests__/input.spec.ts +++ b/WebExample/__tests__/input.spec.ts @@ -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'}); @@ -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}) => { @@ -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); }); }); diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts index 5c83e3b4..b789bd47 100644 --- a/WebExample/__tests__/textManipulation.spec.ts +++ b/WebExample/__tests__/textManipulation.spec.ts @@ -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); @@ -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}) => { @@ -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}) => { @@ -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}`); }); }); @@ -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}) => { @@ -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(); @@ -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); }); diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index 8b588348..2085afad 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -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}) => { @@ -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; }; @@ -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}; diff --git a/example/src/App.tsx b/example/src/App.tsx index aae9b063..46be45e7 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -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'; diff --git a/parser/__tests__/index.test.ts b/parser/__tests__/index.test.ts index ce174829..67dfdc49 100644 --- a/parser/__tests__/index.test.ts +++ b/parser/__tests__/index.test.ts @@ -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 { - 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 { diff --git a/parser/index.ts b/parser/index.ts index af24107b..5bc36794 100644 --- a/parser/index.ts +++ b/parser/index.ts @@ -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 = { @@ -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') { @@ -162,10 +162,10 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, Range[]] { appendSyntax('```'); } else if (node.tag.startsWith('((props, ref) => { diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 6fdf4c77..01de8eb1 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -11,48 +11,21 @@ import type { TextInputContentSizeChangeEventData, } from 'react-native'; import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; -import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent} from 'react'; +import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent, ClipboardEventHandler} from 'react'; import {StyleSheet} from 'react-native'; -import * as ParseUtils from './web/parserUtils'; -import * as CursorUtils from './web/cursorUtils'; -import * as StyleUtils from './styleUtils'; -import type * as MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; -import './web/MarkdownTextInput.css'; +import {updateInputStructure} from './web/utils/parserUtils'; import InputHistory from './web/InputHistory'; +import type {TreeNode} from './web/utils/treeUtils'; +import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; +import './web/MarkdownTextInput.css'; +import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; +import {getElementHeight, getPlaceholderValue, isEventComposing, normalizeValue, parseInnerHTMLToText} from './web/utils/inputUtils'; +import {parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; require('../parser/react-native-live-markdown-parser.js'); const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; -let createReactDOMStyle: (style: any) => any; -try { - createReactDOMStyle = - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle').default; -} catch (e) { - throw new Error('[react-native-live-markdown] Function `createReactDOMStyle` from react-native-web not found. Please make sure that you are using React Native Web 0.18 or newer.'); -} - -let preprocessStyle: (style: any) => any; -try { - preprocessStyle = - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('react-native-web/dist/exports/StyleSheet/preprocess').default; -} catch (e) { - throw new Error('[react-native-live-markdown] Function `preprocessStyle` from react-native-web not found.'); -} - -let dangerousStyleValue: (name: string, value: any, isCustomProperty: boolean) => any; -try { - dangerousStyleValue = - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('react-native-web/dist/modules/setValueForStyles/dangerousStyleValue').default; -} catch (e) { - throw new Error('[react-native-live-markdown] Function `dangerousStyleValue` from react-native-web not found.'); -} - -type MarkdownStyle = MarkdownTextInputDecoratorViewNativeComponent.MarkdownStyle; - interface MarkdownTextInputProps extends TextInputProps { markdownStyle?: MarkdownStyle; onClick?: (e: MouseEvent) => void; @@ -81,62 +54,14 @@ type ParseTextResult = { let focusTimeout: NodeJS.Timeout | null = null; -// Removes one '\n' from the end of the string that were added by contentEditable div -function normalizeValue(value: string) { - return value.replace(/\n$/, ''); -} -// Adds one '\n' at the end of the string if it's missing -function denormalizeValue(value: string) { - return value.endsWith('\n') ? `${value}\n` : value; -} - -// If an Input Method Editor is processing key input, the 'keyCode' is 229. -// https://www.w3.org/TR/uievents/#determine-keydown-keyup-keyCode -function isEventComposing(nativeEvent: globalThis.KeyboardEvent) { - return nativeEvent.isComposing || nativeEvent.keyCode === 229; -} +type MarkdownTextInputElement = HTMLDivElement & + HTMLInputElement & { + tree: TreeNode; + }; -const ZERO_WIDTH_SPACE = '\u200B'; - -function getPlaceholderValue(placeholder: string | undefined) { - if (!placeholder) { - return ZERO_WIDTH_SPACE; - } - return placeholder.length ? placeholder : ZERO_WIDTH_SPACE; -} - -function processUnitsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { - const output = JSON.parse(JSON.stringify(input)); - - Object.keys(output).forEach((key) => { - const obj = output[key]; - Object.keys(obj).forEach((prop) => { - obj[prop] = dangerousStyleValue(prop, obj[prop], false); - }); - }); - - return output as MarkdownStyle; -} - -function processMarkdownStyle(input: MarkdownStyle | undefined): MarkdownStyle { - return processUnitsInMarkdownStyle(StyleUtils.mergeMarkdownStyleWithDefault(input)); -} - -function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfLines: number | undefined) { - if (numberOfLines) { - const tempElement = document.createElement('div'); - tempElement.setAttribute('contenteditable', 'true'); - Object.assign(tempElement.style, styles); - tempElement.innerText = Array(numberOfLines).fill('A').join('\n'); - if (node.parentElement) { - node.parentElement.appendChild(tempElement); - const height = tempElement.clientHeight; - node.parentElement.removeChild(tempElement); - return height ? `${height}px` : 'auto'; - } - } - return styles.height ? `${styles.height}px` : 'auto'; -} +type HTMLMarkdownElement = HTMLElement & { + value: string; +}; const MarkdownTextInput = React.forwardRef( ( @@ -176,13 +101,13 @@ const MarkdownTextInput = React.forwardRef( ref, ) => { const compositionRef = useRef(false); - const pasteRef = useRef(false); - const divRef = useRef(null); + const divRef = useRef(null); const currentlyFocusedField = useRef(null); const contentSelection = useRef(null); const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; const history = useRef(); - const dimensions = React.useRef(null); + const dimensions = useRef(null); + const pasteContent = useRef(null); if (!history.current) { history.current = new InputHistory(100, 150, value || ''); @@ -195,7 +120,7 @@ const MarkdownTextInput = React.forwardRef( const setEventProps = useCallback((e: NativeSyntheticEvent) => { if (divRef.current) { - const text = normalizeValue(divRef.current.innerText || ''); + const text = divRef.current.value; if (e.target) { // TODO: change the logic here so every event have value property (e.target as unknown as HTMLInputElement).value = text; @@ -208,14 +133,26 @@ const MarkdownTextInput = React.forwardRef( }, []); const parseText = useCallback( - (target: HTMLDivElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true): ParseTextResult => { + ( + target: MarkdownTextInputElement, + text: string | null, + customMarkdownStyles: MarkdownStyle, + cursorPosition: number | null = null, + shouldAddToHistory = true, + shouldForceDOMUpdate = false, + ): ParseTextResult => { + if (!divRef.current) { + return {text: text || '', cursorPosition: null}; + } + if (text === null) { - return {text: target.innerText, cursorPosition: null}; + return {text: divRef.current.value, cursorPosition: null}; } - const parsedText = ParseUtils.parseText(target, text, cursorPosition, customMarkdownStyles, !multiline); + const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline, shouldForceDOMUpdate); + divRef.current.value = parsedText.text; + if (history.current && shouldAddToHistory) { - // We need to normalize the value before saving it to the history to prevent situations when additional new lines break the cursor position calculation logic - history.current.throttledAdd(normalizeValue(parsedText.text), parsedText.cursorPosition); + history.current.throttledAdd(parsedText.text, parsedText.cursorPosition); } return parsedText; @@ -226,7 +163,7 @@ const MarkdownTextInput = React.forwardRef( const processedMarkdownStyle = useMemo(() => { const newMarkdownStyle = processMarkdownStyle(markdownStyle); if (divRef.current) { - parseText(divRef.current, divRef.current.innerText, newMarkdownStyle, null, false); + parseText(divRef.current, divRef.current.value, newMarkdownStyle, null, false); } return newMarkdownStyle; }, [markdownStyle, parseText]); @@ -239,13 +176,13 @@ const MarkdownTextInput = React.forwardRef( caretColor: (flattenedStyle as TextStyle).color || 'black', }, disabled && styles.disabledInputStyles, - createReactDOMStyle(preprocessStyle(flattenedStyle)), + parseToReactDOMStyle(flattenedStyle), ]) as CSSProperties, [flattenedStyle, disabled], ); const undo = useCallback( - (target: HTMLDivElement): ParseTextResult => { + (target: MarkdownTextInputElement): ParseTextResult => { if (!history.current) { return { text: '', @@ -253,14 +190,14 @@ const MarkdownTextInput = React.forwardRef( }; } const item = history.current.undo(); - const undoValue = item ? denormalizeValue(item.text) : null; + const undoValue = item ? item.text : null; return parseText(target, undoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); }, [parseText, processedMarkdownStyle], ); const redo = useCallback( - (target: HTMLDivElement): ParseTextResult => { + (target: MarkdownTextInputElement): ParseTextResult => { if (!history.current) { return { text: '', @@ -268,20 +205,12 @@ const MarkdownTextInput = React.forwardRef( }; } const item = history.current.redo(); - const redoValue = item ? denormalizeValue(item.text) : null; + const redoValue = item ? item.text : null; return parseText(target, redoValue, processedMarkdownStyle, item ? item.cursorPosition : null, false); }, [parseText, processedMarkdownStyle], ); - // We have to process value property since contentEditable div adds one additional '\n' at the end of the text if we are entering new line - const processedValue = useMemo(() => { - if (value && value[value.length - 1] === '\n') { - return `${value}\n`; - } - return value; - }, [value]); - // Placeholder text color logic const updateTextColor = useCallback( (node: HTMLDivElement, text: string) => { @@ -315,7 +244,7 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current) { return; } - const newSelection = predefinedSelection || CursorUtils.getCurrentCursorPosition(divRef.current); + const newSelection = predefinedSelection || getCurrentCursorPosition(divRef.current); if (newSelection && (!contentSelection.current || contentSelection.current.start !== newSelection.start || contentSelection.current.end !== newSelection.end)) { updateRefSelectionVariables(newSelection); @@ -349,20 +278,31 @@ const MarkdownTextInput = React.forwardRef( const handleOnChangeText = useCallback( (e: SyntheticEvent) => { - if (!divRef.current || !(e.target instanceof HTMLElement)) { + if (!divRef.current || !(e.target instanceof HTMLElement) || !contentSelection.current) { return; } + const nativeEvent = e.nativeEvent as MarkdownNativeEvent; + const inputType = nativeEvent.inputType; + + updateTextColor(divRef.current, e.target.textContent ?? ''); + const previousText = divRef.current.value; + const parsedText = normalizeValue(inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement)); + + if (pasteContent.current) { + pasteContent.current = null; + } + const prevSelection = contentSelection.current ?? {start: 0, end: 0}; - const prevTextLength = CursorUtils.getPrevTextLength() ?? 0; - const changedText = e.target.innerText; + const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); + if (compositionRef.current) { - updateTextColor(divRef.current, changedText); + divRef.current.value = parsedText; + compositionRef.current = false; + contentSelection.current.end = newCursorPosition; return; } let newInputUpdate: ParseTextResult; - const nativeEvent = e.nativeEvent as MarkdownNativeEvent; - const inputType = nativeEvent.inputType; switch (inputType) { case 'historyUndo': newInputUpdate = undo(divRef.current); @@ -370,26 +310,15 @@ const MarkdownTextInput = React.forwardRef( case 'historyRedo': newInputUpdate = redo(divRef.current); break; - case 'insertFromPaste': - // if there is no newline at the end of the copied text, contentEditable adds invisible
tag at the end of the text, so we need to normalize it - if (changedText.length > 2 && changedText[changedText.length - 2] !== '\n' && changedText[changedText.length - 1] === '\n') { - newInputUpdate = parseText(divRef.current, normalizeValue(changedText), processedMarkdownStyle); - break; - } - newInputUpdate = parseText(divRef.current, changedText, processedMarkdownStyle); - break; default: - newInputUpdate = parseText(divRef.current, changedText, processedMarkdownStyle); + newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition, true, !inputType); } - const {text, cursorPosition} = newInputUpdate; - const normalizedText = normalizeValue(text); - - if (pasteRef?.current) { - pasteRef.current = false; - updateSelection(e); - } updateTextColor(divRef.current, text); + updateSelection(e, { + start: cursorPosition ?? 0, + end: cursorPosition ?? 0, + }); if (onChange) { const event = e as unknown as NativeSyntheticEvent<{ @@ -400,7 +329,7 @@ const MarkdownTextInput = React.forwardRef( setEventProps(event); // The new text is between the prev start selection and the new end selection, can be empty - const addedText = normalizedText.slice(prevSelection.start, cursorPosition ?? 0); + const addedText = text.slice(prevSelection.start, cursorPosition ?? 0); // The length of the text that replaced the before text const count = addedText.length; // The start index of the replacement operation @@ -411,7 +340,7 @@ const MarkdownTextInput = React.forwardRef( let before = prevSelectionRange; if (prevSelectionRange === 0 && (inputType === 'deleteContentBackward' || inputType === 'deleteContentForward')) { // its possible the user pressed a delete key without a selection range, so we need to adjust the before value to have the length of the deleted text - before = prevTextLength - normalizedText.length; + before = previousText.length - text.length; } if (inputType === 'deleteContentBackward') { @@ -429,12 +358,33 @@ const MarkdownTextInput = React.forwardRef( } if (onChangeText) { - onChangeText(normalizedText); + onChangeText(text); } handleContentSizeChange(); }, - [updateTextColor, handleContentSizeChange, onChange, onChangeText, undo, redo, parseText, processedMarkdownStyle, updateSelection, setEventProps], + [updateTextColor, updateSelection, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps], + ); + + const insertText = useCallback( + (e: SyntheticEvent, text: string) => { + if (!contentSelection.current || !divRef.current) { + return; + } + + const previousText = divRef.current.value; + const newText = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; + if (previousText === newText) { + document.execCommand('delete'); + } + + pasteContent.current = newText; + (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; + + handleOnChangeText(e); + updateSelection(e); + }, + [handleOnChangeText, updateSelection], ); const handleKeyPress = useCallback( @@ -446,7 +396,7 @@ const MarkdownTextInput = React.forwardRef( const hostNode = e.target; e.stopPropagation(); - if (e.key === 'z' && e.metaKey) { + if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); const nativeEvent = e.nativeEvent as unknown as MarkdownNativeEvent; if (e.shiftKey) { @@ -486,16 +436,14 @@ const MarkdownTextInput = React.forwardRef( } else if (multiline) { // We need to change normal behavior of "Enter" key to insert a line breaks, to prevent wrapping contentEditable text in
tags. // Thanks to that in every situation we have proper amount of new lines in our parsed text. Without it pressing enter in empty lines will add 2 more new lines. - document.execCommand('insertLineBreak'); - CursorUtils.scrollCursorIntoView(divRef.current as HTMLInputElement); + insertText(e, '\n'); } - if (!e.shiftKey && ((shouldBlurOnSubmit && hostNode !== null) || !multiline)) { setTimeout(() => divRef.current && divRef.current.blur(), 0); } } }, - [multiline, blurOnSubmit, setEventProps, onKeyPress, updateSelection, handleOnChangeText, onSubmitEditing], + [multiline, blurOnSubmit, setEventProps, onKeyPress, updateSelection, handleOnChangeText, onSubmitEditing, insertText], ); const handleFocus: FocusEventHandler = useCallback( @@ -506,10 +454,10 @@ const MarkdownTextInput = React.forwardRef( setEventProps(e); if (divRef.current) { if (contentSelection.current) { - CursorUtils.setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); + setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); } else { - const valueLength = value ? value.length : divRef.current.innerText.length; - CursorUtils.setCursorPosition(divRef.current, valueLength, null); + const valueLength = value ? value.length : divRef.current.value.length; + setCursorPosition(divRef.current, valueLength, null); } updateSelection(event, contentSelection.current); } @@ -521,7 +469,7 @@ const MarkdownTextInput = React.forwardRef( if (hostNode !== null) { if (clearTextOnFocus && divRef.current) { - divRef.current.innerText = ''; + divRef.current.textContent = ''; } if (selectTextOnFocus) { // Safari requires selection to occur in a setTimeout @@ -543,7 +491,7 @@ const MarkdownTextInput = React.forwardRef( const handleBlur: FocusEventHandler = useCallback( (event) => { const e = event as unknown as NativeSyntheticEvent; - CursorUtils.removeSelection(); + removeSelection(); currentlyFocusedField.current = null; if (onBlur) { setEventProps(e); @@ -559,24 +507,47 @@ const MarkdownTextInput = React.forwardRef( if (!onClick || !divRef.current) { return; } - (e.target as HTMLInputElement).value = normalizeValue(divRef.current.innerText || ''); + (e.target as HTMLInputElement).value = divRef.current.value; onClick(e); }, [onClick, updateSelection], ); - const handlePaste = useCallback((e) => { - pasteRef.current = true; - if (e.isDefaultPrevented()) { + const handleCopy: ClipboardEventHandler = useCallback((e) => { + if (!divRef.current || !contentSelection.current) { return; } - e.preventDefault(); - const clipboardData = e.clipboardData; - const text = clipboardData.getData('text/plain'); - document.execCommand('insertText', false, text); + const text = divRef.current?.value.substring(contentSelection.current.start, contentSelection.current.end); + e.clipboardData.setData('text/plain', text ?? ''); }, []); + const handleCut = useCallback( + (e) => { + if (!divRef.current || !contentSelection.current) { + return; + } + handleCopy(e); + if (contentSelection.current.start !== contentSelection.current.end) { + document.execCommand('delete'); + } + }, + [handleCopy], + ); + + const handlePaste = useCallback( + (e) => { + if (e.isDefaultPrevented() || !divRef.current || !contentSelection.current) { + return; + } + e.preventDefault(); + const clipboardData = e.clipboardData; + const text = clipboardData.getData('text/plain'); + insertText(e, text); + }, + [insertText], + ); + const startComposition = useCallback(() => { compositionRef.current = true; }, []); @@ -594,13 +565,13 @@ const MarkdownTextInput = React.forwardRef( if (r) { (r as unknown as TextInput).isFocused = () => document.activeElement === r; (r as unknown as TextInput).clear = () => { - r.innerText = ''; + r.textContent = ''; updateTextColor(r, ''); }; if (value === '' || value === undefined) { // update to placeholder color when value is empty - updateTextColor(r, r.innerText); + updateTextColor(r, r.textContent ?? ''); } } @@ -612,26 +583,25 @@ const MarkdownTextInput = React.forwardRef( (ref as (elementRef: HTMLDivElement | null) => void)(r); } } - divRef.current = r; + divRef.current = r as MarkdownTextInputElement; }; useClientEffect( function parseAndStyleValue() { - if (!divRef.current || processedValue === divRef.current.innerText) { + if (!divRef.current || value === divRef.current.value) { return; } if (value === undefined) { - parseText(divRef.current, divRef.current.innerText, processedMarkdownStyle); + parseText(divRef.current, divRef.current.value, processedMarkdownStyle); return; } - - const text = processedValue !== undefined ? processedValue : ''; - - parseText(divRef.current, text, processedMarkdownStyle, text.length); + const normalizedValue = normalizeValue(value); + divRef.current.value = normalizedValue; + parseText(divRef.current, normalizedValue, processedMarkdownStyle); updateTextColor(divRef.current, value); }, - [multiline, processedMarkdownStyle, processedValue], + [multiline, processedMarkdownStyle, value], ); useClientEffect( @@ -666,11 +636,10 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !selection || (contentSelection.current && selection.start === contentSelection.current.start && selection.end === contentSelection.current.end)) { return; } - const newSelection: Selection = {start: selection.start, end: selection.end ?? selection.start}; contentSelection.current = newSelection; updateRefSelectionVariables(newSelection); - CursorUtils.setCursorPosition(divRef.current, newSelection.start, newSelection.end); + setCursorPosition(divRef.current, newSelection.start, newSelection.end); }, [selection, updateRefSelectionVariables]); return ( @@ -696,6 +665,8 @@ const MarkdownTextInput = React.forwardRef( onClick={handleClick} onFocus={handleFocus} onBlur={handleBlur} + onCopy={handleCopy} + onCut={handleCut} onPaste={handlePaste} placeholder={heightSafePlaceholder} spellCheck={spellCheck} @@ -727,4 +698,4 @@ const styles = StyleSheet.create({ export default MarkdownTextInput; -export type {MarkdownTextInputProps}; +export type {MarkdownTextInputProps, MarkdownTextInputElement, HTMLMarkdownElement}; diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 73d71994..ffaa87f1 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import {expect} from '@jest/globals'; -import * as ParserUtils from '../web/parserUtils'; -import type * as MarkdownTypes from '../web/parserUtils'; +import {parseRangesToHTMLNodes} from '../web/utils/parserUtils'; +import type {MarkdownRange} from '../web/utils/parserUtils'; require('../../parser/react-native-live-markdown-parser.js'); @@ -17,9 +17,9 @@ const toBeParsedAsHTML = function (actual: string, expectedHTML: string) { } let expected = expectedHTML; const ranges = global.parseExpensiMarkToRanges(actual); - const markdownRanges = ranges as MarkdownTypes.MarkdownRange[]; + const markdownRanges = ranges as MarkdownRange[]; - const actualDOM = ParserUtils.parseRangesToHTMLNodes(actual, markdownRanges, {}, true); + const actualDOM = parseRangesToHTMLNodes(actual, markdownRanges, {}, true).dom; const actualHTML = actualDOM.innerHTML; if (actualHTML === expected) { @@ -39,83 +39,107 @@ expect.extend({ }); test('empty string', () => { - expect('').toBeParsedAsHTML(''); + expect('').toBeParsedAsHTML('


'); }); test('no formatting', () => { - expect('Hello, world!').toBeParsedAsHTML('Hello, world!'); + expect('Hello, world!').toBeParsedAsHTML('

Hello, world!

'); }); test('bold', () => { - expect('Hello, *world*').toBeParsedAsHTML('Hello, *world*'); + expect('Hello, *world*!').toBeParsedAsHTML( + '

Hello, *world*!

', + ); }); test('italic', () => { - expect('Hello, _world_!').toBeParsedAsHTML('Hello, _world_!'); + expect('Hello, _world_!').toBeParsedAsHTML( + '

Hello, _world_!

', + ); }); test('strikethrough', () => { - expect('Hello, ~world~!').toBeParsedAsHTML('Hello, ~world~!'); + expect('Hello, ~world~!').toBeParsedAsHTML( + '

Hello, ~world~!

', + ); }); describe('mention-here', () => { test('normal', () => { - expect('@here Hello!').toBeParsedAsHTML('@here Hello!'); + expect('@here Hello!').toBeParsedAsHTML( + '

@here Hello!

', + ); }); test('with punctation marks', () => { - expect('@here!').toBeParsedAsHTML('@here!'); + expect('@here!').toBeParsedAsHTML( + '

@here!

', + ); }); test('at the beginning of a heading', () => { - expect('# @here').toBeParsedAsHTML('# @here'); + expect('# @here').toBeParsedAsHTML( + '

# @here

', + ); }); }); describe('mention-user', () => { test('normal', () => { - expect('@mail@mail.com Hello!').toBeParsedAsHTML('@mail@mail.com Hello!'); + expect('@mail@mail.com Hello!').toBeParsedAsHTML( + '

@mail@mail.com Hello!

', + ); }); test('with punctation marks', () => { - expect('@mail@mail.com!').toBeParsedAsHTML('@mail@mail.com!'); + expect('@mail@mail.com!').toBeParsedAsHTML( + '

@mail@mail.com!

', + ); }); test('at the beginning of a heading', () => { - expect('# @mail@mail.com').toBeParsedAsHTML('# @mail@mail.com'); + expect('# @mail@mail.com').toBeParsedAsHTML( + '

# @mail@mail.com

', + ); }); }); describe('link', () => { test('plain link', () => { - expect('https://example.com').toBeParsedAsHTML('https://example.com'); + expect('https://example.com').toBeParsedAsHTML( + '

https://example.com

', + ); }); test('labeled link', () => { expect('[Link](https://example.com)').toBeParsedAsHTML( - '[Link](https://example.com)', + '

[Link](https://example.com)

', ); }); test('link with same label as href', () => { expect('[https://example.com](https://example.com)').toBeParsedAsHTML( - '[https://example.com](https://example.com)', + '

[https://example.com](https://example.com)

', ); }); test('link with query string', () => { - expect('https://example.com?name=John&age=25&city=NewYork').toBeParsedAsHTML('https://example.com?name=John&age=25&city=NewYork'); + expect('https://example.com?name=John&age=25&city=NewYork').toBeParsedAsHTML( + '

https://example.com?name=John&age=25&city=NewYork

', + ); }); }); describe('email', () => { test('plain email', () => { - expect('someone@example.com').toBeParsedAsHTML('someone@example.com'); + expect('someone@example.com').toBeParsedAsHTML( + '

someone@example.com

', + ); }); test('labeled email', () => { expect('[Email](mailto:someone@example.com)').toBeParsedAsHTML( - '[Email](mailto:someone@example.com)', + '

[Email](mailto:someone@example.com)

', ); }); }); @@ -123,120 +147,148 @@ describe('email', () => { describe('email with same label as address', () => { test('label and address without "mailto:"', () => { expect('[someone@example.com](someone@example.com)').toBeParsedAsHTML( - '[someone@example.com](someone@example.com)', + '

[someone@example.com](someone@example.com)

', ); }); test('label with "mailto:"', () => { expect('[mailto:someone@example.com](someone@example.com)').toBeParsedAsHTML( - '[mailto:someone@example.com](someone@example.com)', + '

[mailto:someone@example.com](someone@example.com)

', ); }); test('address with "mailto:"', () => { expect('[someone@example.com](mailto:someone@example.com)').toBeParsedAsHTML( - '[someone@example.com](mailto:someone@example.com)', + '

[someone@example.com](mailto:someone@example.com)

', ); }); test('label and address with "mailto:"', () => { expect('[mailto:someone@example.com](mailto:someone@example.com)').toBeParsedAsHTML( - '[mailto:someone@example.com](mailto:someone@example.com)', + '

[mailto:someone@example.com](mailto:someone@example.com)

', ); }); }); test('inline code', () => { - expect('Hello `world`!').toBeParsedAsHTML('Hello `world`!'); + expect('Hello `world`!').toBeParsedAsHTML( + '

Hello `world`!

', + ); }); test('codeblock', () => { - expect('```\nHello world!\n```').toBeParsedAsHTML('```\nHello world!\n```'); + expect('```\nHello world!\n```').toBeParsedAsHTML( + '

```
Hello world!
```

', + ); }); describe('quote', () => { test('with single space', () => { - expect('> Hello world!').toBeParsedAsHTML('> Hello world!'); + expect('> Hello world!').toBeParsedAsHTML( + '

> Hello world!

', + ); }); test('with multiple spaces', () => { - expect('> Hello world!').toBeParsedAsHTML('> Hello world!'); + expect('> Hello world!').toBeParsedAsHTML( + '

> Hello world!

', + ); }); }); test('multiple blockquotes', () => { expect('> Hello\n> beautiful\n> world').toBeParsedAsHTML( - '> Hello\n> beautiful\n> world', + '

> Hello

> beautiful

> world

', ); }); test('separate blockquotes', () => { expect('> Lorem ipsum\ndolor\n> sit amet').toBeParsedAsHTML( - '> Lorem ipsum\ndolor\n> sit amet', + '

> Lorem ipsum

dolor

> sit amet

', ); }); test('nested blockquotes', () => { expect('> > > > Lorem ipsum dolor sit amet').toBeParsedAsHTML( - '> > > > Lorem ipsum dolor sit amet', + '

> > > > Lorem ipsum dolor sit amet

', ); }); test('heading', () => { - expect('# Hello world').toBeParsedAsHTML('# Hello world'); + expect('# Hello world').toBeParsedAsHTML( + '

# Hello world

', + ); }); test('nested bold and italic', () => { expect('*_Hello_*, _*world*_!').toBeParsedAsHTML( - '*_Hello_*, _*world*_!', + '

*_Hello_*, _*world*_!

', ); }); describe('nested heading in blockquote', () => { test('with single space', () => { - expect('> # Hello world').toBeParsedAsHTML('> # Hello world'); + expect('> # Hello world').toBeParsedAsHTML( + '

> # Hello world

', + ); }); test('with multiple spaces after #', () => { - expect('> # Hello world').toBeParsedAsHTML('> # Hello world'); + expect('> # Hello world').toBeParsedAsHTML( + '

> # Hello world

', + ); }); }); describe('trailing whitespace', () => { describe('after blockquote', () => { test('nothing', () => { - expect('> Hello world').toBeParsedAsHTML('> Hello world'); + expect('> Hello world').toBeParsedAsHTML( + '

> Hello world

', + ); }); test('single space', () => { - expect('> Hello world ').toBeParsedAsHTML('> Hello world '); + expect('> Hello world ').toBeParsedAsHTML( + '

> Hello world

', + ); }); test('newline', () => { - expect('> Hello world\n').toBeParsedAsHTML('> Hello world\n'); + expect('> Hello world\n').toBeParsedAsHTML( + '

> Hello world


', + ); }); }); describe('after heading', () => { test('nothing', () => { - expect('# Hello world').toBeParsedAsHTML('# Hello world'); + expect('# Hello world').toBeParsedAsHTML( + '

# Hello world

', + ); }); test('single space', () => { - expect('# Hello world ').toBeParsedAsHTML('# Hello world '); + expect('# Hello world ').toBeParsedAsHTML( + '

# Hello world

', + ); }); test('multiple spaces', () => { - expect('# Hello world ').toBeParsedAsHTML('# Hello world '); + expect('# Hello world ').toBeParsedAsHTML( + '

# Hello world

', + ); }); test('newline', () => { - expect('# Hello world\n').toBeParsedAsHTML('# Hello world\n'); + expect('# Hello world\n').toBeParsedAsHTML( + '

# Hello world


', + ); }); test('multiple quotes', () => { expect('> # Hello\n> # world').toBeParsedAsHTML( - '> # Hello\n> # world', + '

> # Hello

> # world

', ); }); }); diff --git a/src/styleUtils.ts b/src/styleUtils.ts index a6c161ac..4e66dcb8 100644 --- a/src/styleUtils.ts +++ b/src/styleUtils.ts @@ -1,7 +1,5 @@ import {Platform} from 'react-native'; -import type * as MarkdownTextInputDecoractorView from './MarkdownTextInputDecoratorViewNativeComponent'; - -type MarkdownStyle = MarkdownTextInputDecoractorView.MarkdownStyle; +import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; type PartialMarkdownStyle = Partial<{ [K in keyof MarkdownStyle]: Partial; diff --git a/src/web/browserUtils.ts b/src/web/browserUtils.ts deleted file mode 100644 index a70d97c9..00000000 --- a/src/web/browserUtils.ts +++ /dev/null @@ -1,11 +0,0 @@ -const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); -const isChromium = 'chrome' in window; - -/** - * Whether the platform is a mobile browser. - * Copied from Expensify App https://github.com/Expensify/App/blob/90dee7accae79c49debf30354c160cab6c52c423/src/libs/Browser/index.website.ts#L41 - * - */ -const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Silk|Opera Mini/i.test(navigator.userAgent); - -export {isFirefox, isChromium, isMobile}; diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts deleted file mode 100644 index 9c176dbf..00000000 --- a/src/web/cursorUtils.ts +++ /dev/null @@ -1,177 +0,0 @@ -import * as BrowserUtils from './browserUtils'; - -let prevTextLength: number | undefined; - -function getPrevTextLength() { - return prevTextLength; -} - -function findTextNodes(textNodes: Text[], node: ChildNode) { - if (node.nodeType === Node.TEXT_NODE) { - textNodes.push(node as Text); - } else { - for (let i = 0, length = node.childNodes.length; i < length; ++i) { - const childNode = node.childNodes[i]; - if (childNode) { - findTextNodes(textNodes, childNode); - } - } - } -} - -function setPrevText(target: HTMLElement) { - let text = []; - const textNodes: Text[] = []; - findTextNodes(textNodes, target); - text = textNodes - .map((e) => e.nodeValue ?? '') - ?.join('') - ?.split(''); - - prevTextLength = text.length; -} - -function setCursorPosition(target: HTMLElement, start: number, end: number | null = null) { - // We don't want to move the cursor if the target is not focused - if (target !== document.activeElement) { - return; - } - - const range = document.createRange(); - range.selectNodeContents(target); - - const textNodes: Text[] = []; - findTextNodes(textNodes, target); - - // These are utilities for handling the boundary cases (especially onEnter) - // prevChar & nextChar are characters before & after the target cursor position - const textCharacters = textNodes - .map((e) => e.nodeValue ?? '') - ?.join('') - ?.split(''); - const prevChar = textCharacters?.[start - 1] ?? ''; - const nextChar = textCharacters?.[start] ?? ''; - - let charCount = 0; - let startNode: Text | null = null; - let endNode: Text | null = null; - const n = textNodes.length; - for (let i = 0; i < n; ++i) { - const textNode = textNodes[i]; - if (textNode) { - const nextCharCount = charCount + textNode.length; - - if (!startNode && start >= charCount && (start <= nextCharCount || (start === nextCharCount && i < n - 1))) { - startNode = textNode; - - // There are 4 cases to consider here: - // 1. Caret in front of a character, when pressing enter - // 2. Caret at the end of a line (not last one) - // 3. Caret at the end of whole input, when pressing enter - // 4. All other placements - if (prevChar === '\n' && prevTextLength !== undefined && prevTextLength < textCharacters.length) { - if (nextChar && nextChar !== '\n' && i !== n - 1) { - range.setStart(textNodes[i + 1] as Node, 0); - } else if (i !== textNodes.length - 1) { - range.setStart(textNodes[i] as Node, 1); - } else { - range.setStart(textNode, start - charCount); - } - } else { - range.setStart(textNode, start - charCount); - } - if (!end) { - break; - } - } - if (end && !endNode && end >= charCount && (end <= nextCharCount || (end === nextCharCount && i < n - 1))) { - endNode = textNode; - range.setEnd(textNode, end - charCount); - } - charCount = nextCharCount; - } - } - - if (!end) { - range.collapse(true); - } - - const selection = window.getSelection(); - if (selection) { - selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); - } - - scrollCursorIntoView(target as HTMLInputElement); -} - -function moveCursorToEnd(target: HTMLElement) { - const range = document.createRange(); - const selection = window.getSelection(); - if (selection) { - range.setStart(target, target.childNodes.length); - range.collapse(true); - selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); - } -} - -function getCurrentCursorPosition(target: HTMLElement) { - const selection = window.getSelection(); - if (!selection || (selection && selection.rangeCount === 0)) { - return null; - } - const range = selection.getRangeAt(0); - const preSelectionRange = range.cloneRange(); - preSelectionRange.selectNodeContents(target); - preSelectionRange.setEnd(range.startContainer, range.startOffset); - const start = preSelectionRange.toString().length; - const end = start + range.toString().length; - return {start, end}; -} - -function removeSelection() { - const selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - } -} - -function scrollCursorIntoView(target: HTMLInputElement) { - if (target.selectionStart === null || !target.value || BrowserUtils.isFirefox) { - return; - } - - const selection = window.getSelection(); - if (!selection || (selection && selection.rangeCount === 0)) { - return; - } - - const caretRects = selection.getRangeAt(0).getClientRects(); - - // we'll find the caretRect from the DOMRectList above with the largest bottom value - let currentCaretRect = caretRects[0]; - if (currentCaretRect) { - for (let i = 1; i < caretRects.length; i++) { - const caretRect = caretRects[i]; - if (caretRect && caretRect.bottom > currentCaretRect.bottom) { - currentCaretRect = caretRect; - } - } - } - - const editableRect = target.getBoundingClientRect(); - - // Adjust for padding and border - const paddingTop = parseFloat(window.getComputedStyle(target).paddingTop); - const borderTop = parseFloat(window.getComputedStyle(target).borderTopWidth); - - if (currentCaretRect && !(currentCaretRect.top >= editableRect.top + paddingTop + borderTop && currentCaretRect.bottom <= editableRect.bottom - 2 * (paddingTop - borderTop))) { - const topToCaret = currentCaretRect.top - editableRect.top; - const inputHeight = editableRect.height; - // Chrome Rects don't include padding & border, so we're adding them manually - const inputOffset = currentCaretRect.height - inputHeight + paddingTop + borderTop + (BrowserUtils.isChromium ? 0 : 4 * (paddingTop + borderTop)); - - target.scrollTo(0, topToCaret + target.scrollTop + inputOffset); - } -} - -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView, getPrevTextLength}; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts deleted file mode 100644 index e9c41693..00000000 --- a/src/web/parserUtils.ts +++ /dev/null @@ -1,228 +0,0 @@ -import * as CursorUtils from './cursorUtils'; -import type * as StyleUtilsTypes from '../styleUtils'; -import * as BrowserUtils from './browserUtils'; - -type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; - -type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; - -type MarkdownRange = { - type: MarkdownType; - start: number; - length: number; - depth?: number; -}; - -type NestedNode = { - node: HTMLElement; - endIndex: number; -}; - -function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { - const node = targetElement; - switch (type) { - case 'syntax': - Object.assign(node.style, markdownStyle.syntax); - break; - case 'bold': - node.style.fontWeight = 'bold'; - break; - case 'italic': - node.style.fontStyle = 'italic'; - break; - case 'strikethrough': - node.style.textDecoration = 'line-through'; - break; - case 'emoji': - Object.assign(node.style, {...markdownStyle.emoji, verticalAlign: 'middle'}); - break; - case 'mention-here': - Object.assign(node.style, markdownStyle.mentionHere); - break; - case 'mention-user': - Object.assign(node.style, markdownStyle.mentionUser); - break; - case 'mention-report': - Object.assign(node.style, markdownStyle.mentionReport); - break; - case 'link': - Object.assign(node.style, { - ...markdownStyle.link, - textDecoration: 'underline', - }); - break; - case 'code': - Object.assign(node.style, markdownStyle.code); - break; - case 'pre': - Object.assign(node.style, markdownStyle.pre); - break; - - case 'blockquote': - Object.assign(node.style, { - ...markdownStyle.blockquote, - borderLeftStyle: 'solid', - display: 'inline-block', - maxWidth: '100%', - boxSizing: 'border-box', - }); - break; - case 'h1': - Object.assign(node.style, { - ...markdownStyle.h1, - fontWeight: 'bold', - }); - break; - default: - break; - } -} - -function addSubstringAsTextNode(root: HTMLElement, text: string, startIndex: number, endIndex: number) { - const substring = text.substring(startIndex, endIndex); - if (substring.length > 0) { - root.appendChild(document.createTextNode(substring)); - } -} - -function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { - const ungroupedRanges: MarkdownRange[] = []; - ranges.forEach((range) => { - if (!range.depth) { - ungroupedRanges.push(range); - } - const {depth, ...rangeWithoutDepth} = range; - Array.from({length: depth!}).forEach(() => { - ungroupedRanges.push(rangeWithoutDepth); - }); - }); - return ungroupedRanges; -} - -function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false): HTMLElement { - const root: HTMLElement = document.createElement('span'); - root.className = 'root'; - const textLength = text.length; - if (ranges.length === 0) { - addSubstringAsTextNode(root, text, 0, textLength); - return root; - } - - const stack = ungroupRanges(ranges); - const nestedStack: NestedNode[] = [{node: root, endIndex: textLength}]; - let lastRangeEndIndex = 0; - while (stack.length > 0) { - const range = stack.shift(); - if (!range) { - break; - } - let currentRoot = nestedStack[nestedStack.length - 1]; - if (!currentRoot) { - break; - } - - const endOfCurrentRange = range.start + range.length; - const nextRangeStartIndex = stack.length > 0 && !!stack[0] ? stack[0].start || 0 : textLength; - - addSubstringAsTextNode(currentRoot.node, text, lastRangeEndIndex, range.start); // add text with newlines before current range - - const span = document.createElement('span'); - if (disableInlineStyles) { - span.className = range.type; - } else { - addStyling(span, range.type, markdownStyle); - } - - if (stack.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { - // tag nesting - currentRoot.node.appendChild(span); - nestedStack.push({node: span, endIndex: endOfCurrentRange}); - lastRangeEndIndex = range.start; - } else { - addSubstringAsTextNode(span, text, range.start, endOfCurrentRange); - currentRoot.node.appendChild(span); - lastRangeEndIndex = endOfCurrentRange; - - // end of tag nesting - while (nestedStack.length - 1 > 0 && nextRangeStartIndex >= currentRoot.endIndex) { - addSubstringAsTextNode(currentRoot.node, text, lastRangeEndIndex, currentRoot.endIndex); - const prevRoot = nestedStack.pop(); - if (!prevRoot) { - break; - } - lastRangeEndIndex = prevRoot.endIndex; - currentRoot = nestedStack[nestedStack.length - 1] || currentRoot; - } - } - } - - if (nestedStack.length > 1) { - const lastNestedNode = nestedStack[nestedStack.length - 1]; - if (lastNestedNode) { - root.appendChild(lastNestedNode.node); - } - } - - addSubstringAsTextNode(root, text, lastRangeEndIndex, textLength); - return root; -} - -function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: HTMLElement) { - if (!isFocused) { - return; - } - - if (alwaysMoveCursorToTheEnd || cursorPosition === null) { - CursorUtils.moveCursorToEnd(target); - } else if (cursorPosition !== null) { - CursorUtils.setCursorPosition(target, cursorPosition); - } -} - -function parseText(target: HTMLElement, text: string, cursorPositionIndex: number | null, markdownStyle: PartialMarkdownStyle = {}, alwaysMoveCursorToTheEnd = false) { - const targetElement = target; - - // in case the cursorPositionIndex is larger than text length, cursorPosition will be null, i.e: move the caret to the end - let cursorPosition: number | null = cursorPositionIndex && cursorPositionIndex <= text.length ? cursorPositionIndex : null; - const isFocused = document.activeElement === target; - if (isFocused && cursorPositionIndex === null) { - const selection = CursorUtils.getCurrentCursorPosition(target); - cursorPosition = selection ? selection.end : null; - } - const ranges = global.parseExpensiMarkToRanges(text); - - const markdownRanges: MarkdownRange[] = ranges as MarkdownRange[]; - const rootSpan = targetElement.firstChild as HTMLElement | null; - - if (!text || targetElement.innerHTML === '
' || (rootSpan && rootSpan.innerHTML === '\n')) { - targetElement.innerHTML = ''; - targetElement.innerText = ''; - } - - // We don't want to parse text with single '\n', because contentEditable represents it as invisible
- if (text) { - const dom = parseRangesToHTMLNodes(text, markdownRanges, markdownStyle); - - if (!rootSpan || !rootSpan?.classList?.contains('root') || rootSpan.innerHTML !== dom.innerHTML) { - targetElement.innerHTML = ''; - targetElement.innerText = ''; - target.appendChild(dom); - - if (BrowserUtils.isChromium) { - moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); - } - } - - if (!BrowserUtils.isChromium) { - moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); - } - } - - CursorUtils.setPrevText(target); - - return {text: target.innerText, cursorPosition: cursorPosition || 0}; -} - -export {parseText, parseRangesToHTMLNodes}; - -export type {MarkdownRange, MarkdownType}; diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts new file mode 100644 index 00000000..887ef4e1 --- /dev/null +++ b/src/web/utils/blockUtils.ts @@ -0,0 +1,72 @@ +import type {PartialMarkdownStyle} from '../../styleUtils'; +import type {NodeType} from './treeUtils'; + +function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownStyle: PartialMarkdownStyle) { + const node = targetElement; + switch (type) { + case 'line': + Object.assign(node.style, { + display: 'block', + margin: '0', + padding: '0', + }); + break; + case 'syntax': + Object.assign(node.style, markdownStyle.syntax); + break; + case 'bold': + node.style.fontWeight = 'bold'; + break; + case 'italic': + node.style.fontStyle = 'italic'; + break; + case 'strikethrough': + node.style.textDecoration = 'line-through'; + break; + case 'emoji': + Object.assign(node.style, {...markdownStyle.emoji, verticalAlign: 'middle'}); + break; + case 'mention-here': + Object.assign(node.style, markdownStyle.mentionHere); + break; + case 'mention-user': + Object.assign(node.style, markdownStyle.mentionUser); + break; + case 'mention-report': + Object.assign(node.style, markdownStyle.mentionReport); + break; + case 'link': + Object.assign(node.style, { + ...markdownStyle.link, + textDecoration: 'underline', + }); + break; + case 'code': + Object.assign(node.style, markdownStyle.code); + break; + case 'pre': + Object.assign(node.style, markdownStyle.pre); + break; + + case 'blockquote': + Object.assign(node.style, { + ...markdownStyle.blockquote, + borderLeftStyle: 'solid', + display: 'inline-block', + maxWidth: '100%', + boxSizing: 'border-box', + }); + break; + case 'h1': + Object.assign(node.style, { + ...markdownStyle.h1, + fontWeight: 'bold', + }); + break; + default: + break; + } +} + +// eslint-disable-next-line import/prefer-default-export +export {addStyleToBlock}; diff --git a/src/web/utils/browserUtils.ts b/src/web/utils/browserUtils.ts new file mode 100644 index 00000000..cfe157c5 --- /dev/null +++ b/src/web/utils/browserUtils.ts @@ -0,0 +1,13 @@ +const BrowserUtils = { + isFirefox: navigator.userAgent.toLowerCase().includes('firefox'), + isChromium: 'chrome' in window, + + /** + * Whether the platform is a mobile browser. + * Copied from Expensify App https://github.com/Expensify/App/blob/90dee7accae79c49debf30354c160cab6c52c423/src/libs/Browser/index.website.ts#L41 + * + */ + isMobile: /Android|webOS|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Silk|Opera Mini/i.test(navigator.userAgent), +}; + +export default BrowserUtils; diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts new file mode 100644 index 00000000..e0b4e23b --- /dev/null +++ b/src/web/utils/cursorUtils.ts @@ -0,0 +1,116 @@ +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; +import type {TreeNode} from './treeUtils'; + +function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, endIndex: number | null = null) { + // We don't want to move the cursor if the target is not focused + if (!target.tree || target !== document.activeElement) { + return; + } + + const start = Math.max(0, Math.min(startIndex, target.tree.length)); + const end = endIndex ? Math.max(0, Math.min(endIndex, target.tree.length)) : null; + if (end && end < start) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(target); + + const startTreeNode = getTreeNodeByIndex(target.tree, start); + const endTreeNode = end && startTreeNode && (end < startTreeNode.start || end >= startTreeNode.start + startTreeNode.length) ? getTreeNodeByIndex(target.tree, end) : startTreeNode; + if (!startTreeNode || !endTreeNode) { + console.error('Invalid start or end tree node'); + return; + } + + if (startTreeNode.type === 'br') { + range.setStartBefore(startTreeNode.element); + } else { + range.setStart(startTreeNode.element.childNodes[0] as ChildNode, start - startTreeNode.start); + } + + if (endTreeNode.type === 'br') { + range.setEndBefore(endTreeNode.element); + } else { + range.setEnd(endTreeNode.element.childNodes[0] as ChildNode, (end || start) - endTreeNode.start); + } + + if (!end) { + range.collapse(true); + } + + const selection = window.getSelection(); + if (selection) { + selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); + } + + scrollIntoView(startTreeNode); +} + +function scrollIntoView(node: TreeNode) { + if (node.type === 'br' && node.parentNode?.parentNode?.type === 'line') { + // If the node is a line break, scroll to the parent paragraph, because Safari doesn't support scrollIntoView on br elements + node.parentNode.parentNode.element.scrollIntoView({ + block: 'nearest', + }); + } else { + node.element.scrollIntoView({ + block: 'nearest', + }); + } +} + +function moveCursorToEnd(target: HTMLElement) { + const range = document.createRange(); + const selection = window.getSelection(); + if (selection) { + range.setStart(target, target.childNodes.length); + range.collapse(true); + selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); + } +} + +function getCurrentCursorPosition(target: MarkdownTextInputElement) { + function getHTMLElement(node: Node) { + let element = node as HTMLElement | Text; + if (element instanceof Text) { + element = node.parentElement as HTMLElement; + } + return element; + } + + const selection = window.getSelection(); + if (!selection || (selection && selection.rangeCount === 0)) { + return null; + } + const range = selection.getRangeAt(0); + const startElement = getHTMLElement(range.startContainer); + const endElement = range.startContainer === range.endContainer ? startElement : getHTMLElement(range.endContainer); + + const startTreeNode = findHTMLElementInTree(target.tree, startElement); + const endTreeNode = findHTMLElementInTree(target.tree, endElement); + + let start = -1; + let end = -1; + if (startTreeNode && endTreeNode) { + start = startTreeNode.start + range.startOffset; + + // If the end node is a root node, we need to set the end to the end of the text (FireFox fix) + if (endTreeNode?.parentNode === null) { + end = target.value.length; + } else { + end = endTreeNode.start + range.endOffset; + } + } + return {start, end}; +} + +function removeSelection() { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } +} + +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection, scrollIntoView}; diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts new file mode 100644 index 00000000..0e006990 --- /dev/null +++ b/src/web/utils/inputUtils.ts @@ -0,0 +1,106 @@ +import type {CSSProperties} from 'react'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; + +const ZERO_WIDTH_SPACE = '\u200B'; + +// If an Input Method Editor is processing key input, the 'keyCode' is 229. +// https://www.w3.org/TR/uievents/#determine-keydown-keyup-keyCode +function isEventComposing(nativeEvent: globalThis.KeyboardEvent) { + return nativeEvent.isComposing || nativeEvent.keyCode === 229; +} + +function getPlaceholderValue(placeholder: string | undefined) { + if (!placeholder) { + return ZERO_WIDTH_SPACE; + } + return placeholder.length ? placeholder : ZERO_WIDTH_SPACE; +} + +function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfLines: number | undefined) { + if (numberOfLines) { + const tempElement = document.createElement('div'); + tempElement.setAttribute('contenteditable', 'true'); + Object.assign(tempElement.style, styles); + tempElement.textContent = Array(numberOfLines).fill('A').join('\n'); + if (node.parentElement) { + node.parentElement.appendChild(tempElement); + const height = tempElement.clientHeight; + node.parentElement.removeChild(tempElement); + return height ? `${height}px` : 'auto'; + } + } + return styles.height ? `${styles.height}px` : 'auto'; +} + +function normalizeValue(value: string) { + return value.replaceAll('\r\n', '\n'); +} + +// Parses the HTML structure of a MarkdownTextInputElement to a plain text string. Used for getting the correct value of the input element. +function parseInnerHTMLToText(target: MarkdownTextInputElement): string { + // Returns the parent of a given node that is higher in the hierarchy and is of a different type than 'text', 'br' or 'line' + function getTopParentNode(node: ChildNode) { + let currentParentNode = node.parentNode; + while (currentParentNode && ['text', 'br', 'line'].includes(currentParentNode.parentElement?.getAttribute('data-type') || '')) { + currentParentNode = currentParentNode?.parentNode || null; + } + return currentParentNode; + } + + const stack: ChildNode[] = [target]; + let text = ''; + let shouldAddNewline = false; + const lastNode = target.childNodes[target.childNodes.length - 1]; + // Remove the last
element if it's the last child of the target element. Fixes the issue with adding extra newline when pasting into the empty input. + if (lastNode?.nodeName === 'DIV' && (lastNode as HTMLElement)?.innerHTML === '
') { + target.removeChild(lastNode); + } + + while (stack.length > 0) { + const node = stack.pop(); + if (!node) { + break; + } + + // If we are operating on the nodes that are children of the MarkdownTextInputElement, we need to add a newline after each + const isTopComponent = node.parentElement?.contentEditable === 'true'; + if (isTopComponent) { + // Replaced text is beeing added as text node, so we need to not add the newline before and after it + if (node.nodeType === Node.TEXT_NODE) { + shouldAddNewline = false; + } else { + if (shouldAddNewline) { + text += '\n'; + shouldAddNewline = false; + } + shouldAddNewline = true; + } + } + + if (node.nodeType === Node.TEXT_NODE) { + // Parse text nodes into text + text += node.textContent; + } else if (node.nodeName === 'BR') { + const parentNode = getTopParentNode(node); + if (parentNode && parentNode.parentElement?.contentEditable !== 'true') { + // Parse br elements into newlines only if their parent is not a child of the MarkdownTextInputElement (a paragraph when writing or a div when pasting). + // It prevents adding extra newlines when entering text + text += '\n'; + } + } else { + let i = node.childNodes.length - 1; + while (i > -1) { + const child = node.childNodes[i]; + if (!child) { + break; + } + stack.push(child); + i--; + } + } + } + + return text.replaceAll('\r\n', '\n'); +} + +export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText, normalizeValue}; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts new file mode 100644 index 00000000..7b9f7985 --- /dev/null +++ b/src/web/utils/parserUtils.ts @@ -0,0 +1,302 @@ +import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {addNodeToTree} from './treeUtils'; +import type {NodeType, TreeNode} from './treeUtils'; +import type {PartialMarkdownStyle} from '../../styleUtils'; +import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; +import {addStyleToBlock} from './blockUtils'; + +type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; + +type MarkdownRange = { + type: MarkdownType; + start: number; + length: number; + depth?: number; +}; + +type Paragraph = { + text: string; + start: number; + length: number; + markdownRanges: MarkdownRange[]; +}; + +function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { + const ungroupedRanges: MarkdownRange[] = []; + ranges.forEach((range) => { + if (!range.depth) { + ungroupedRanges.push(range); + } + const {depth, ...rangeWithoutDepth} = range; + Array.from({length: depth!}).forEach(() => { + ungroupedRanges.push(rangeWithoutDepth); + }); + }); + return ungroupedRanges; +} + +function splitTextIntoLines(text: string): Paragraph[] { + let lineStartIndex = 0; + const lines: Paragraph[] = text.split('\n').map((line) => { + const lineObject: Paragraph = { + text: line, + start: lineStartIndex, + length: line.length, + markdownRanges: [], + }; + lineStartIndex += line.length + 1; // Adding 1 for the newline character + return lineObject; + }); + + return lines; +} + +/** Merges lines that contain multiline markdown tags into one line */ +function mergeLinesWithMultilineTags(lines: Paragraph[], ranges: MarkdownRange[]) { + let mergedLines = [...lines]; + const lineIndexes = mergedLines.map((_line, index) => index); + + ranges.forEach((range) => { + const beginLineIndex = mergedLines.findLastIndex((line) => line.start <= range.start); + const endLineIndex = mergedLines.findIndex((line) => line.start + line.length >= range.start + range.length); + const correspondingLineIndexes = lineIndexes.slice(beginLineIndex, endLineIndex + 1); + + if (correspondingLineIndexes.length > 0) { + const mainLineIndex = correspondingLineIndexes[0] as number; + const mainLine = mergedLines[mainLineIndex] as Paragraph; + + mainLine.markdownRanges.push(range); + + const otherLineIndexes = correspondingLineIndexes.slice(1); + otherLineIndexes.forEach((lineIndex) => { + const otherLine = mergedLines[lineIndex] as Paragraph; + + mainLine.text += `\n${otherLine.text}`; + mainLine.length += otherLine.length + 1; + mainLine.markdownRanges.push(...otherLine.markdownRanges); + }); + if (otherLineIndexes.length > 0) { + mergedLines = mergedLines.filter((_line, index) => !otherLineIndexes.includes(index)); + } + } + }); + + return mergedLines; +} + +/** Adds a value prop to the element and appends the value to the parent node element */ +function appendValueToElement(element: HTMLMarkdownElement, parentTreeNode: TreeNode, value: string) { + const targetElement = element; + const parentNode = parentTreeNode; + targetElement.value = value; + parentNode.element.value = (parentNode.element.value || '') + value; +} + +function appendNode(element: HTMLMarkdownElement, parentTreeNode: TreeNode, type: NodeType, length: number) { + const node = addNodeToTree(element, parentTreeNode, type, length); + parentTreeNode.element.appendChild(element); + return node; +} + +function addBrElement(node: TreeNode) { + const span = document.createElement('span') as HTMLMarkdownElement; + span.setAttribute('data-type', 'br'); + appendValueToElement(span, node, '\n'); + const spanNode = appendNode(span, node, 'br', 1); + appendNode(document.createElement('br') as unknown as HTMLMarkdownElement, spanNode, 'br', 1); + return spanNode; +} + +function addTextToElement(node: TreeNode, text: string) { + const lines = text.split('\n'); + lines.forEach((line, index) => { + if (line !== '') { + const span = document.createElement('span') as HTMLMarkdownElement; + appendValueToElement(span, node, line); + span.setAttribute('data-type', 'text'); + span.appendChild(document.createTextNode(line)); + appendNode(span, node, 'text', line.length); + } + + if (index < lines.length - 1 || (index === 0 && line === '')) { + addBrElement(node); + } + }); +} + +function addParagraph(node: TreeNode, text: string | null = null, length: number, disableInlineStyles = false) { + const p = document.createElement('p'); + p.setAttribute('data-type', 'line'); + if (!disableInlineStyles) { + addStyleToBlock(p, 'line', {}); + } + + const pNode = appendNode(p as unknown as HTMLMarkdownElement, node, 'line', length); + + if (text === '') { + // If the line is empty, we still need to add a br element to keep the line height + addBrElement(pNode); + } else if (text) { + addTextToElement(pNode, text); + } + + return pNode; +} + +/** Builds HTML DOM structure based on passed text and markdown ranges */ +function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false) { + const rootElement: HTMLMarkdownElement = document.createElement('span') as HTMLMarkdownElement; + const textLength = text.replace(/\n/g, '\\n').length; + const rootNode: TreeNode = { + element: rootElement, + start: 0, + length: textLength, + parentNode: null, + childNodes: [], + type: 'root', + orderIndex: '', + isGeneratingNewline: false, + }; + let currentParentNode: TreeNode = rootNode; + let lines = splitTextIntoLines(text); + + if (ranges.length === 0) { + lines.forEach((line) => { + addParagraph(rootNode, line.text, line.length, disableInlineStyles); + }); + return {dom: rootElement, tree: rootNode}; + } + + const markdownRanges = ungroupRanges(ranges); + lines = mergeLinesWithMultilineTags(lines, markdownRanges); + + let lastRangeEndIndex = 0; + while (lines.length > 0) { + const line = lines.shift(); + if (!line) { + break; + } + + // preparing line paragraph element for markdown text + currentParentNode = addParagraph(rootNode, null, line.length, disableInlineStyles); + rootElement.value = (rootElement.value || '') + line.text; + if (lines.length > 0) { + rootElement.value = `${rootElement.value || ''}\n`; + } + + if (line.markdownRanges.length === 0) { + addTextToElement(currentParentNode, line.text); + } + + lastRangeEndIndex = line.start; + const lineMarkdownRanges = line.markdownRanges; + // go through all markdown ranges in the line + while (lineMarkdownRanges.length > 0) { + const range = lineMarkdownRanges.shift(); + if (!range) { + break; + } + + const endOfCurrentRange = range.start + range.length; + const nextRangeStartIndex = lineMarkdownRanges.length > 0 && !!lineMarkdownRanges[0] ? lineMarkdownRanges[0].start || 0 : textLength; + + // add text before the markdown range + const textBeforeRange = line.text.substring(lastRangeEndIndex - line.start, range.start - line.start); + if (textBeforeRange) { + addTextToElement(currentParentNode, textBeforeRange); + } + + // create markdown span element + const span = document.createElement('span') as HTMLMarkdownElement; + span.setAttribute('data-type', range.type); + if (!disableInlineStyles) { + addStyleToBlock(span, range.type, markdownStyle); + } + + const spanNode = appendNode(span, currentParentNode, range.type, range.length); + + if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { + // tag nesting + currentParentNode = spanNode; + lastRangeEndIndex = range.start; + } else { + // adding markdown tag + addTextToElement(spanNode, text.substring(range.start, endOfCurrentRange)); + currentParentNode.element.value = (currentParentNode.element.value || '') + (spanNode.element.value || ''); + lastRangeEndIndex = endOfCurrentRange; + // tag unnesting and adding text after the tag + while (currentParentNode.parentNode !== null && nextRangeStartIndex >= currentParentNode.start + currentParentNode.length) { + const textAfterRange = line.text.substring(lastRangeEndIndex - line.start, currentParentNode.start - line.start + currentParentNode.length); + if (textAfterRange) { + addTextToElement(currentParentNode, textAfterRange); + } + lastRangeEndIndex = currentParentNode.start + currentParentNode.length; + if (currentParentNode.parentNode.type !== 'root') { + currentParentNode.parentNode.element.value = currentParentNode.element.value || ''; + } + currentParentNode = currentParentNode.parentNode || rootNode; + } + } + } + } + + return {dom: rootElement, tree: rootNode}; +} + +function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: MarkdownTextInputElement) { + if (!isFocused) { + return; + } + + if (alwaysMoveCursorToTheEnd || cursorPosition === null) { + moveCursorToEnd(target); + } else if (cursorPosition !== null) { + setCursorPosition(target, cursorPosition); + } +} +function updateInputStructure( + target: MarkdownTextInputElement, + text: string, + cursorPositionIndex: number | null, + markdownStyle: PartialMarkdownStyle = {}, + alwaysMoveCursorToTheEnd = false, + shouldForceDOMUpdate = false, +) { + const targetElement = target; + + // in case the cursorPositionIndex is larger than text length, cursorPosition will be null, i.e: move the caret to the end + let cursorPosition: number | null = cursorPositionIndex !== null && cursorPositionIndex <= text.length ? cursorPositionIndex : null; + const isFocused = document.activeElement === target; + if (isFocused && cursorPositionIndex === null) { + const selection = getCurrentCursorPosition(target); + cursorPosition = selection ? selection.start : null; + } + const ranges = global.parseExpensiMarkToRanges(text); + const markdownRanges: MarkdownRange[] = ranges as MarkdownRange[]; + if (!text || targetElement.innerHTML === '
' || (targetElement && targetElement.innerHTML === '\n')) { + targetElement.innerHTML = ''; + targetElement.innerText = ''; + } + + // We don't want to parse text with single '\n', because contentEditable represents it as invisible
+ if (text) { + const {dom, tree} = parseRangesToHTMLNodes(text, markdownRanges, markdownStyle); + + if (shouldForceDOMUpdate || targetElement.innerHTML !== dom.innerHTML) { + targetElement.innerHTML = ''; + targetElement.innerText = ''; + Array.from(dom.children).forEach((child) => { + targetElement.appendChild(child); + }); + } + + targetElement.tree = tree; + moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, targetElement); + } + + return {text, cursorPosition: cursorPosition || 0}; +} + +export {updateInputStructure, parseRangesToHTMLNodes}; + +export type {MarkdownRange, MarkdownType}; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts new file mode 100644 index 00000000..d85146db --- /dev/null +++ b/src/web/utils/treeUtils.ts @@ -0,0 +1,103 @@ +import type {HTMLMarkdownElement} from '../../MarkdownTextInput.web'; +import type {MarkdownRange, MarkdownType} from './parserUtils'; + +type NodeType = MarkdownType | 'line' | 'text' | 'br' | 'root'; + +type TreeNode = Omit & { + element: HTMLMarkdownElement; + parentNode: TreeNode | null; + childNodes: TreeNode[]; + type: NodeType; + orderIndex: string; + isGeneratingNewline: boolean; +}; + +function addNodeToTree(element: HTMLMarkdownElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { + const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.value?.length) || 0; + const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && (element.childNodes[0] as HTMLElement)?.getAttribute?.('data-type') === 'br'); + const parentChildrenCount = parentTreeNode?.childNodes.length || 0; + let startIndex = parentTreeNode.start; + if (parentChildrenCount > 0) { + const lastParentChild = parentTreeNode.childNodes[parentChildrenCount - 1]; + if (lastParentChild) { + startIndex = lastParentChild.start + lastParentChild.length; + startIndex += lastParentChild.isGeneratingNewline || element.style.display === 'block' ? 1 : 0; + } + } + + const item: TreeNode = { + element, + parentNode: parentTreeNode, + childNodes: [], + start: startIndex, + length: contentLength, + type, + orderIndex: parentTreeNode.parentNode === null ? `${parentChildrenCount}` : `${parentTreeNode.orderIndex},${parentChildrenCount}`, + isGeneratingNewline, + }; + + element.setAttribute('data-id', item.orderIndex); + parentTreeNode.childNodes.push(item); + return item; +} + +function findHTMLElementInTree(treeRoot: TreeNode, element: HTMLElement): TreeNode | null { + if (element.hasAttribute?.('contenteditable')) { + return treeRoot; + } + + if (!element || !element.hasAttribute?.('data-id')) { + return null; + } + const indexes = element.getAttribute('data-id')?.split(','); + let el: TreeNode | null = treeRoot; + + while (el && indexes && indexes.length > 0) { + const index = Number(indexes.shift() || -1); + if (index < 0) { + break; + } + + if (el) { + el = el.childNodes[index] || null; + } + } + + return el; +} + +function getTreeNodeByIndex(treeRoot: TreeNode, index: number): TreeNode | null { + let el: TreeNode | null = treeRoot; + + let i = 0; + let newLineGenerated = false; + while (el && el.childNodes.length > 0 && i < el.childNodes.length) { + const child = el.childNodes[i] as TreeNode; + + if (!child) { + break; + } + + if (index >= child.start && index < child.start + child.length) { + if (child.childNodes.length === 0) { + return child; + } + el = child; + i = 0; + } else if ((child.isGeneratingNewline || newLineGenerated || i === el.childNodes.length - 1) && index === child.start + child.length) { + newLineGenerated = true; + if (child.childNodes.length === 0) { + return child; + } + el = child; + i = 0; + } else { + i++; + } + } + return null; +} + +export {addNodeToTree, findHTMLElementInTree, getTreeNodeByIndex}; + +export type {TreeNode, NodeType}; diff --git a/src/web/utils/webStyleUtils.ts b/src/web/utils/webStyleUtils.ts new file mode 100644 index 00000000..e0f84510 --- /dev/null +++ b/src/web/utils/webStyleUtils.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type {TextStyle} from 'react-native'; +import type {MarkdownStyle} from '../../MarkdownTextInputDecoratorViewNativeComponent'; +import {mergeMarkdownStyleWithDefault} from '../../styleUtils'; + +let createReactDOMStyle: (style: any) => any; +try { + createReactDOMStyle = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('react-native-web/dist/exports/StyleSheet/compiler/createReactDOMStyle').default; +} catch (e) { + throw new Error('[react-native-live-markdown] Function `createReactDOMStyle` from react-native-web not found. Please make sure that you are using React Native Web 0.18 or newer.'); +} + +let preprocessStyle: (style: any) => any; +try { + preprocessStyle = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('react-native-web/dist/exports/StyleSheet/preprocess').default; +} catch (e) { + throw new Error('[react-native-live-markdown] Function `preprocessStyle` from react-native-web not found.'); +} + +let dangerousStyleValue: (name: string, value: any, isCustomProperty: boolean) => any; +try { + dangerousStyleValue = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('react-native-web/dist/modules/setValueForStyles/dangerousStyleValue').default; +} catch (e) { + throw new Error('[react-native-live-markdown] Function `dangerousStyleValue` from react-native-web not found.'); +} + +function processUnitsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { + const output = JSON.parse(JSON.stringify(input)); + + Object.keys(output).forEach((key) => { + const obj = output[key]; + Object.keys(obj).forEach((prop) => { + obj[prop] = dangerousStyleValue(prop, obj[prop], false); + }); + }); + + return output as MarkdownStyle; +} + +function processMarkdownStyle(input: MarkdownStyle | undefined): MarkdownStyle { + return processUnitsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); +} + +function parseToReactDOMStyle(style: TextStyle): any { + return createReactDOMStyle(preprocessStyle(style)); +} + +export {parseToReactDOMStyle, processMarkdownStyle};