From f853061bed2f480ca34b1396318e9fae6e68974b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 25 Jun 2024 09:40:45 +0200 Subject: [PATCH 01/73] Add refactor changes --- src/MarkdownTextInput.web.tsx | 137 +++++++++------ src/__tests__/webParser.test.tsx | 2 +- src/web/cursorUtils.ts | 129 +++++--------- src/web/parserUtils.ts | 282 +++++++++++++++++++++++-------- src/web/treeUtils.ts | 143 ++++++++++++++++ 5 files changed, 485 insertions(+), 208 deletions(-) create mode 100644 src/web/treeUtils.ts diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 5cb0d067..d6edf7d8 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -16,6 +16,8 @@ import {StyleSheet} from 'react-native'; import * as ParseUtils from './web/parserUtils'; import * as CursorUtils from './web/cursorUtils'; import * as StyleUtils from './styleUtils'; +import * as TreeUtils from './web/treeUtils'; +import type * as TreeUtilsTypes from './web/treeUtils'; import * as BrowserUtils from './web/browserUtils'; import type * as MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; import './web/MarkdownTextInput.css'; @@ -77,14 +79,10 @@ type Dimensions = { 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; -} +type MarkdownTextInputElement = HTMLDivElement & + HTMLInputElement & { + tree: TreeUtilsTypes.TreeNode; + }; // If an Input Method Editor is processing key input, the 'keyCode' is 229. // https://www.w3.org/TR/uievents/#determine-keydown-keyup-keyCode @@ -123,7 +121,7 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL const tempElement = document.createElement('div'); tempElement.setAttribute('contenteditable', 'true'); Object.assign(tempElement.style, styles); - tempElement.innerText = Array(numberOfLines).fill('A').join('\n'); + tempElement.textContent = Array(numberOfLines).fill('A').join('\n'); if (node.parentElement) { node.parentElement.appendChild(tempElement); const height = tempElement.clientHeight; @@ -172,12 +170,13 @@ const MarkdownTextInput = React.forwardRef( ) => { 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 textContent = useRef(''); if (!history.current) { history.current = new InputHistory(100, 150, value || ''); @@ -190,7 +189,7 @@ const MarkdownTextInput = React.forwardRef( const setEventProps = useCallback((e: NativeSyntheticEvent) => { if (divRef.current) { - const text = normalizeValue(divRef.current.innerText || ''); + const text = textContent.current; if (e.target) { // TODO: change the logic here so every event have value property (e.target as unknown as HTMLInputElement).value = text; @@ -205,12 +204,16 @@ const MarkdownTextInput = React.forwardRef( const parseText = useCallback( (target: HTMLDivElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true) => { if (text === null) { - return {text: target.innerText, cursorPosition: null}; + return {text: textContent.current, cursorPosition: null}; } const parsedText = ParseUtils.parseText(target, text, cursorPosition, customMarkdownStyles, !multiline); + + if (divRef.current && parsedText.tree) { + divRef.current.tree = parsedText.tree; + } 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; @@ -221,7 +224,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, textContent.current, newMarkdownStyle, null, false); } return newMarkdownStyle; }, [markdownStyle, parseText]); @@ -245,7 +248,7 @@ const MarkdownTextInput = React.forwardRef( return ''; } 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).text; }, [parseText, processedMarkdownStyle], @@ -257,20 +260,12 @@ const MarkdownTextInput = React.forwardRef( return ''; } 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).text; }, [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) => { @@ -336,14 +331,60 @@ const MarkdownTextInput = React.forwardRef( } }, [multiline, onContentSizeChange]); + const parseInnerHTMLToText = useCallback((target: HTMLElement): string => { + let text = ''; + const childNodes = target.childNodes ?? []; + childNodes.forEach((node, index) => { + const nodeCopy = node.cloneNode(true) as HTMLElement; + if (nodeCopy.innerHTML) { + // Replace single
created by contentEditable with '\n', to enable proper newline deletion on backspace, when next lines also have
tags + if (nodeCopy.innerHTML === '
') { + nodeCopy.innerHTML = '\n'; + } + // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div + nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); + } + let nodeText = nodeCopy.textContent ?? ''; + + // Remove unnecessary new lines from the end of the text + if (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') { + nodeText = nodeText.slice(0, -1); + } + + // Last line specific handling + if (index === childNodes.length - 1) { + if (nodeText === '\n\n') { + // New line creation + nodeText = '\n'; + } else if (nodeText === '\n') { + // New line deletion on backspace + nodeText = ''; + } + } + + text += nodeText; + // Split paragraphs with new lines + if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { + text += '\n'; + } + }); + return text; + }, []); + const handleOnChangeText = useCallback( (e: SyntheticEvent) => { if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } - const changedText = e.target.innerText; + + const parsedText = parseInnerHTMLToText(e.target); + textContent.current = parsedText; + + const tree = TreeUtils.buildTree(divRef.current, parsedText); + divRef.current.tree = tree; + if (compositionRef.current && !BrowserUtils.isMobile) { - updateTextColor(divRef.current, changedText); + updateTextColor(divRef.current, parsedText); compositionRef.current = false; return; } @@ -357,16 +398,8 @@ const MarkdownTextInput = React.forwardRef( case 'historyRedo': text = 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') { - text = parseText(divRef.current, normalizeValue(changedText), processedMarkdownStyle).text; - break; - } - text = parseText(divRef.current, changedText, processedMarkdownStyle).text; - break; default: - text = parseText(divRef.current, changedText, processedMarkdownStyle).text; + text = parseText(divRef.current, parsedText, processedMarkdownStyle).text; } if (pasteRef?.current) { @@ -382,13 +415,12 @@ const MarkdownTextInput = React.forwardRef( } if (onChangeText) { - const normalizedText = normalizeValue(text); - onChangeText(normalizedText); + onChangeText(text); } handleContentSizeChange(); }, - [updateTextColor, handleContentSizeChange, onChange, onChangeText, undo, redo, parseText, processedMarkdownStyle, updateSelection, setEventProps], + [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, parseInnerHTMLToText, processedMarkdownStyle, updateSelection, setEventProps], ); const handleKeyPress = useCallback( @@ -441,9 +473,10 @@ const MarkdownTextInput = React.forwardRef( // 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); + if (contentSelection.current) { + CursorUtils.setCursorPosition(divRef.current, contentSelection.current?.start + 1); + } } - if (!e.shiftKey && ((shouldBlurOnSubmit && hostNode !== null) || !multiline)) { setTimeout(() => divRef.current && divRef.current.blur(), 0); } @@ -462,7 +495,7 @@ const MarkdownTextInput = React.forwardRef( if (contentSelection.current) { CursorUtils.setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); } else { - const valueLength = value ? value.length : divRef.current.innerText.length; + const valueLength = value ? value.length : textContent.current.length; CursorUtils.setCursorPosition(divRef.current, valueLength, null); } updateSelection(event, contentSelection.current); @@ -475,7 +508,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 @@ -513,7 +546,7 @@ const MarkdownTextInput = React.forwardRef( if (!onClick || !divRef.current) { return; } - (e.target as HTMLInputElement).value = normalizeValue(divRef.current.innerText || ''); + (e.target as HTMLInputElement).value = textContent.current; onClick(e); }, [onClick, updateSelection], @@ -532,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 ?? ''); } } @@ -550,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 === textContent.current) { return; } if (value === undefined) { - parseText(divRef.current, divRef.current.innerText, processedMarkdownStyle); + parseText(divRef.current, textContent.current, processedMarkdownStyle); return; } - const text = processedValue !== undefined ? processedValue : ''; - - parseText(divRef.current, text, processedMarkdownStyle, text.length); + textContent.current = value; + parseText(divRef.current, value, processedMarkdownStyle); updateTextColor(divRef.current, value); }, - [multiline, processedMarkdownStyle, processedValue], + [multiline, processedMarkdownStyle], ); useClientEffect( @@ -604,7 +636,6 @@ 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); diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 73d71994..93319e70 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -20,7 +20,7 @@ const toBeParsedAsHTML = function (actual: string, expectedHTML: string) { const markdownRanges = ranges as MarkdownTypes.MarkdownRange[]; const actualDOM = ParserUtils.parseRangesToHTMLNodes(actual, markdownRanges, {}, true); - const actualHTML = actualDOM.innerHTML; + const actualHTML = actualDOM.dom.innerHTML; if (actualHTML === expected) { expected = actualHTML; diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index 1cda6599..127a0228 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -1,91 +1,34 @@ import * as BrowserUtils from './browserUtils'; - -let prevTextLength: number | undefined; - -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; -} +import * as TreeUtils from './treeUtils'; 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) { + if (target !== document.activeElement || start < 0 || (end && end < 0)) { 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 !== '\n') { - 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; - } + const startTreeItem = TreeUtils.getElementByIndex(target.tree, start); + + const endTreeItem = + end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? TreeUtils.getElementByIndex(target.tree, end) : startTreeItem; + + if (!startTreeItem || !endTreeItem) { + throw new Error('Invalid start or end tree item'); + } + + if (startTreeItem.type === 'br') { + range.setStartBefore(startTreeItem.element); + } else { + range.setStart(startTreeItem.element.childNodes[0] as ChildNode, start - startTreeItem.start); + } + + if (endTreeItem.type === 'br') { + range.setEndBefore(endTreeItem.element); + } else { + range.setEnd(endTreeItem.element.childNodes[0] as ChildNode, (end || start) - endTreeItem.start); } if (!end) { @@ -111,16 +54,34 @@ function moveCursorToEnd(target: HTMLElement) { } function getCurrentCursorPosition(target: HTMLElement) { + 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 preSelectionRange = range.cloneRange(); - preSelectionRange.selectNodeContents(target); - preSelectionRange.setEnd(range.startContainer, range.startOffset); - const start = preSelectionRange.toString().length; - const end = start + range.toString().length; + const startElement = getHTMLElement(range.startContainer); + + const endElement = range.startContainer === range.endContainer ? startElement : getHTMLElement(range.endContainer); + + const startTreeItem = TreeUtils.findElementInTree(target.tree, startElement); + const endTreeItem = TreeUtils.findElementInTree(target.tree, endElement); + + let start = -1; + let end = -1; + if (startTreeItem && endTreeItem) { + start = startTreeItem.start + range.startOffset; + end = endTreeItem.start + range.endOffset; + } + return {start, end}; } @@ -158,4 +119,4 @@ function scrollCursorIntoView(target: HTMLInputElement) { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, setPrevText, removeSelection, scrollCursorIntoView}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection, scrollCursorIntoView}; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 49583b9f..840309e0 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -1,8 +1,11 @@ import * as CursorUtils from './cursorUtils'; import type * as StyleUtilsTypes from '../styleUtils'; import * as BrowserUtils from './browserUtils'; +import * as TreeUtils from './treeUtils'; +import type * as TreeUtilsTypes from './treeUtils'; type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; +type TreeNode = TreeUtilsTypes.TreeNode; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; @@ -13,9 +16,18 @@ type MarkdownRange = { depth?: number; }; -type NestedNode = { - node: HTMLElement; - endIndex: number; +type Node = { + element: HTMLElement; + start: number; + length: number; + parent: Node | null; +}; + +type Paragraph = { + text: string; + start: number; + length: number; + markdownRanges: MarkdownRange[]; }; function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { @@ -78,13 +90,6 @@ function addStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyl } } -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) => { @@ -99,72 +104,210 @@ function ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { 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; +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; +} + +function mergeLinesWithMultilineTags(lines: Paragraph[]) { + let multiLineRange: MarkdownRange | null = null; + let lineWithMultilineTag: Paragraph | null = null; + let i = 0; + while (i < lines.length) { + const currentLine = lines[i]; + if (!currentLine) { + break; + } + // start merging if line contains range that ends in a different line + if (lineWithMultilineTag && multiLineRange && currentLine.start <= multiLineRange.start + multiLineRange.length) { + lineWithMultilineTag.text += `\n${currentLine.text}`; + lineWithMultilineTag.markdownRanges.push(...currentLine.markdownRanges); + lineWithMultilineTag.length += currentLine.length + 1; + lines.splice(i, 1); + } else { + multiLineRange = currentLine.markdownRanges.find((range) => range.start + range.length > currentLine.start + currentLine.length) || null; + lineWithMultilineTag = multiLineRange ? currentLine : null; + i += 1; + } + } +} + +function groupMarkdownRangesByLine(lines: Paragraph[], ranges: MarkdownRange[]) { + let lineIndex = 0; + ranges.forEach((range) => { + const {start} = range; + + let currentLine = lines[lineIndex]; + while (currentLine && lineIndex < lines.length && start > currentLine.start + currentLine.length) { + lineIndex += 1; + currentLine = lines[lineIndex]; + } + + if (currentLine) { + currentLine.markdownRanges.push(range); + } + }); +} + +function createBrElement() { + const span = document.createElement('span'); + span.appendChild(document.createElement('br')); + span.setAttribute('data-type', 'br'); + return span; +} + +function addTextToElement(element: HTMLElement, text: string) { + const lines = text.split('\n'); + lines.forEach((line, index) => { + if (line !== '') { + const span = document.createElement('span'); + span.innerText = line; + element.appendChild(span); + } + + if (index < lines.length - 1 || (index === 0 && line === '')) { + element.appendChild(createBrElement()); + } + }); +} + +function createParagraph(text: string | null = null) { + const p = document.createElement('p'); + Object.assign(p.style, { + margin: '0', + padding: '0', + display: 'block', + }); + p.setAttribute('data-type', 'line'); + if (text === '') { + p.appendChild(createBrElement()); + } else if (text) { + addTextToElement(p, text); + } + + return p; +} + +function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false) { + const rootElement: HTMLElement = document.createElement('div'); + const textLength = text.replace(/\n/g, '\\n').length; + + const rootNode: Node = { + element: rootElement, + start: 0, + length: textLength, + parent: null, + }; + + let parent: Node = { + element: rootElement, + start: 0, + length: textLength, + parent: null, + }; + + const lines = splitTextIntoLines(text); + if (ranges.length === 0) { - addSubstringAsTextNode(root, text, 0, textLength); - return root; + lines.forEach((line) => { + parent.element.appendChild(createParagraph(line.text)); + }); + return rootElement; } - const stack = ungroupRanges(ranges); - const nestedStack: NestedNode[] = [{node: root, endIndex: textLength}]; + const markdownRanges = ungroupRanges(ranges); + + groupMarkdownRangesByLine(lines, markdownRanges); + mergeLinesWithMultilineTags(lines); + let lastRangeEndIndex = 0; - while (stack.length > 0) { - const range = stack.shift(); - if (!range) { + while (lines.length > 0) { + const line = lines.shift(); + if (!line) { break; } - let currentRoot = nestedStack[nestedStack.length - 1]; - if (!currentRoot) { - break; + + // preparing line paragraph element for markdown text + const p = createParagraph(null); + rootNode.element.appendChild(p); + parent = { + element: p, + start: line.start, + length: line.length, + parent: rootNode, + }; + if (line.markdownRanges.length === 0) { + addTextToElement(parent.element, line.text); } - const endOfCurrentRange = range.start + range.length; - const nextRangeStartIndex = stack.length > 0 && !!stack[0] ? stack[0].start || 0 : textLength; + lastRangeEndIndex = line.start; - addSubstringAsTextNode(currentRoot.node, text, lastRangeEndIndex, range.start); // add text with newlines before current range + const lineMarkdownRanges = line.markdownRanges; + // go through all markdown ranges in the line + while (lineMarkdownRanges.length > 0) { + const range = lineMarkdownRanges.shift(); + if (!range) { + break; + } - const span = document.createElement('span'); - if (disableInlineStyles) { - span.className = range.type; - } else { - addStyling(span, range.type, markdownStyle); - } + const endOfCurrentRange = range.start + range.length; + const nextRangeStartIndex = lineMarkdownRanges.length > 0 && !!lineMarkdownRanges[0] ? lineMarkdownRanges[0].start || 0 : textLength; - 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; + // add text before the markdown range + const textBeforeRange = line.text.substring(lastRangeEndIndex - line.start, range.start - line.start); + if (textBeforeRange) { + addTextToElement(parent.element, textBeforeRange); } - } - } - if (nestedStack.length > 1) { - const lastNestedNode = nestedStack[nestedStack.length - 1]; - if (lastNestedNode) { - root.appendChild(lastNestedNode.node); + // create markdown span element + const span = document.createElement('span'); + if (disableInlineStyles) { + span.className = range.type; + } else { + addStyling(span, range.type, markdownStyle); + span.setAttribute('data-type', range.type); + } + + if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { + // tag nesting + parent.element.appendChild(span); + parent = { + element: span, + start: range.start, + length: range.length, + parent, + }; + lastRangeEndIndex = range.start; + } else { + // adding markdown tag + parent.element.appendChild(span); + addTextToElement(span, text.substring(range.start, endOfCurrentRange)); + lastRangeEndIndex = endOfCurrentRange; + // tag unnesting and adding text after the tag + while (parent.parent !== null && nextRangeStartIndex >= parent.start + parent.length) { + const textAfterRange = line.text.substring(lastRangeEndIndex - line.start, parent.start - line.start + parent.length); + if (textAfterRange) { + addTextToElement(parent.element, textAfterRange); + } + lastRangeEndIndex = parent.start + parent.length; + parent = parent.parent || rootNode; + } + } } } - addSubstringAsTextNode(root, text, lastRangeEndIndex, textLength); - return root; + return rootElement; } function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: HTMLElement) { @@ -187,14 +330,13 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe const isFocused = document.activeElement === target; if (isFocused && cursorPositionIndex === null) { const selection = CursorUtils.getCurrentCursorPosition(target); - cursorPosition = selection ? selection.end : null; + cursorPosition = selection ? selection.start : null; } const ranges = global.parseExpensiMarkToRanges(text); - const markdownRanges: MarkdownRange[] = ranges as MarkdownRange[]; - const rootSpan = targetElement.firstChild as HTMLElement | null; + let tree: TreeNode | null = null; - if (!text || targetElement.innerHTML === '
' || (rootSpan && rootSpan.innerHTML === '\n')) { + if (!text || targetElement.innerHTML === '
' || (targetElement && targetElement.innerHTML === '\n')) { targetElement.innerHTML = ''; targetElement.innerText = ''; } @@ -202,11 +344,13 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe // 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.innerHTML !== dom.innerHTML) { + if (targetElement.innerHTML !== dom.innerHTML) { targetElement.innerHTML = ''; targetElement.innerText = ''; - target.appendChild(dom); + targetElement.innerHTML = dom.innerHTML || ''; + + tree = TreeUtils.buildTree(targetElement, text); + targetElement.tree = tree; if (BrowserUtils.isChromium) { moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); @@ -218,9 +362,7 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe } } - CursorUtils.setPrevText(target); - - return {text: target.innerText, cursorPosition: cursorPosition || 0}; + return {text, cursorPosition: cursorPosition || 0, tree}; } export {parseText, parseRangesToHTMLNodes}; diff --git a/src/web/treeUtils.ts b/src/web/treeUtils.ts new file mode 100644 index 00000000..9a9e4e20 --- /dev/null +++ b/src/web/treeUtils.ts @@ -0,0 +1,143 @@ +import type * as ParserUtilsTypes from './parserUtils'; + +type MarkdownType = ParserUtilsTypes.MarkdownType; + +type MarkdownRange = ParserUtilsTypes.MarkdownRange; + +type ElementType = MarkdownType | 'line' | 'text' | 'br'; + +type TreeNode = Omit & { + element: HTMLElement; + parentNode: TreeNode | null; + childNodes: TreeNode[]; + type: ElementType; + orderIndex: string; + isGeneratingNewline: boolean; +}; + +function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: ElementType) { + const contentLength = element.nodeName === 'BR' ? 1 : element.innerText.length; + const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element.childNodes[0]?.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 ? 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 buildTree(rootElement: HTMLElement, text: string) { + function getElementType(element: HTMLElement): ElementType { + if (element.nodeName === 'BR') { + return 'br'; + } + if (element.nodeName === 'P') { + return 'line'; + } + + return (element.getAttribute('data-type') as ElementType) || 'text'; + } + const rootTreeItem: TreeNode = { + element: rootElement, + parentNode: null, + childNodes: [], + start: 0, + length: text.replace(/\n/g, '\\n').length, + type: 'text', + orderIndex: '', + isGeneratingNewline: false, + }; + const stack = [rootTreeItem]; + while (stack.length > 0) { + const treeItem = stack.pop(); + if (!treeItem) { + break; + } + + Array.from(treeItem.element.children).forEach((childElement) => { + const newTreeItem = addItemToTree(childElement as HTMLElement, treeItem, getElementType(childElement as HTMLElement)); + stack.push(newTreeItem); + }); + } + + return rootTreeItem; +} + +function findElementInTree(treeRoot: TreeNode, element: HTMLElement) { + if (element.hasAttribute('contenteditable')) { + return treeRoot; + } + + if (!element || !element.hasAttribute('data-id')) { + return; + } + 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 getElementByIndex(treeRoot: TreeNode, index: number) { + 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) && index === child.start + child.length) { + newLineGenerated = true; + if (child.childNodes.length === 0) { + return child; + } + el = child; + i = el.childNodes.length - 1; + } else { + i++; + } + } + return null; +} + +export {addItemToTree, findElementInTree, getElementByIndex, buildTree}; + +export type {TreeNode}; From c4108c1d15be7ed070f3661d9311c5bc07bc92ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 25 Jun 2024 11:43:00 +0200 Subject: [PATCH 02/73] Fix parserUtils --- src/web/parserUtils.ts | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 840309e0..7da47756 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -20,7 +20,7 @@ type Node = { element: HTMLElement; start: number; length: number; - parent: Node | null; + parentNode: Node | null; }; type Paragraph = { @@ -207,21 +207,15 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS element: rootElement, start: 0, length: textLength, - parent: null, - }; - - let parent: Node = { - element: rootElement, - start: 0, - length: textLength, - parent: null, + parentNode: null, }; + let currentParentNode: Node = {...rootNode}; const lines = splitTextIntoLines(text); if (ranges.length === 0) { lines.forEach((line) => { - parent.element.appendChild(createParagraph(line.text)); + currentParentNode.element.appendChild(createParagraph(line.text)); }); return rootElement; } @@ -241,14 +235,14 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // preparing line paragraph element for markdown text const p = createParagraph(null); rootNode.element.appendChild(p); - parent = { + currentParentNode = { element: p, start: line.start, length: line.length, - parent: rootNode, + parentNode: rootNode, }; if (line.markdownRanges.length === 0) { - addTextToElement(parent.element, line.text); + addTextToElement(currentParentNode.element, line.text); } lastRangeEndIndex = line.start; @@ -267,7 +261,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // add text before the markdown range const textBeforeRange = line.text.substring(lastRangeEndIndex - line.start, range.start - line.start); if (textBeforeRange) { - addTextToElement(parent.element, textBeforeRange); + addTextToElement(currentParentNode.element, textBeforeRange); } // create markdown span element @@ -281,27 +275,27 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { // tag nesting - parent.element.appendChild(span); - parent = { + currentParentNode.element.appendChild(span); + currentParentNode = { element: span, start: range.start, length: range.length, - parent, + parentNode: currentParentNode, }; lastRangeEndIndex = range.start; } else { // adding markdown tag - parent.element.appendChild(span); + currentParentNode.element.appendChild(span); addTextToElement(span, text.substring(range.start, endOfCurrentRange)); lastRangeEndIndex = endOfCurrentRange; // tag unnesting and adding text after the tag - while (parent.parent !== null && nextRangeStartIndex >= parent.start + parent.length) { - const textAfterRange = line.text.substring(lastRangeEndIndex - line.start, parent.start - line.start + parent.length); + 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(parent.element, textAfterRange); + addTextToElement(currentParentNode.element, textAfterRange); } - lastRangeEndIndex = parent.start + parent.length; - parent = parent.parent || rootNode; + lastRangeEndIndex = currentParentNode.start + currentParentNode.length; + currentParentNode = currentParentNode.parentNode || rootNode; } } } From 38693c4c074fe4d0d90c58ac0f06f584f67ad216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 15:50:03 +0200 Subject: [PATCH 03/73] Add tree building when creating markdown HTML structure --- src/web/parserUtils.ts | 69 ++++++++++++++++++++++-------------------- src/web/treeUtils.ts | 4 +-- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 7da47756..efcc0ea4 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -21,6 +21,7 @@ type Node = { start: number; length: number; parentNode: Node | null; + childNodes?: Node[]; }; type Paragraph = { @@ -160,29 +161,36 @@ function groupMarkdownRangesByLine(lines: Paragraph[], ranges: MarkdownRange[]) }); } -function createBrElement() { +function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: 'line' | 'text' | 'br', length: number) { + const node = TreeUtils.addItemToTree(element, parentTreeNode, type, length); + parentTreeNode.element.appendChild(element); + return node; +} + +function addBrElement(node: TreeNode) { const span = document.createElement('span'); - span.appendChild(document.createElement('br')); span.setAttribute('data-type', 'br'); - return span; + const spanNode = addItemToTree(span, node, 'br', 1); + addItemToTree(document.createElement('br'), spanNode, 'br', 1); + return spanNode; } -function addTextToElement(element: HTMLElement, text: string) { +function addTextToElement(node: TreeNode, text: string) { const lines = text.split('\n'); lines.forEach((line, index) => { if (line !== '') { const span = document.createElement('span'); span.innerText = line; - element.appendChild(span); + addItemToTree(span, node, 'text', line.length); } if (index < lines.length - 1 || (index === 0 && line === '')) { - element.appendChild(createBrElement()); + addBrElement(node); } }); } -function createParagraph(text: string | null = null) { +function addParagraph(node: TreeNode, text: string | null = null, length: number) { const p = document.createElement('p'); Object.assign(p.style, { margin: '0', @@ -190,32 +198,39 @@ function createParagraph(text: string | null = null) { display: 'block', }); p.setAttribute('data-type', 'line'); + + const pNode = addItemToTree(p, node, 'line', length); + if (text === '') { - p.appendChild(createBrElement()); + addBrElement(pNode); } else if (text) { - addTextToElement(p, text); + addTextToElement(pNode, text); } - return p; + return pNode; } function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false) { const rootElement: HTMLElement = document.createElement('div'); const textLength = text.replace(/\n/g, '\\n').length; - const rootNode: Node = { + const rootNode: TreeNode = { element: rootElement, start: 0, length: textLength, parentNode: null, + childNodes: [], + type: 'text', + orderIndex: '', + isGeneratingNewline: false, }; - let currentParentNode: Node = {...rootNode}; + let currentParentNode: TreeNode = rootNode; const lines = splitTextIntoLines(text); if (ranges.length === 0) { lines.forEach((line) => { - currentParentNode.element.appendChild(createParagraph(line.text)); + addParagraph(rootNode, line.text, line.length); }); return rootElement; } @@ -233,16 +248,9 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } // preparing line paragraph element for markdown text - const p = createParagraph(null); - rootNode.element.appendChild(p); - currentParentNode = { - element: p, - start: line.start, - length: line.length, - parentNode: rootNode, - }; + currentParentNode = addParagraph(rootNode, null, line.length); if (line.markdownRanges.length === 0) { - addTextToElement(currentParentNode.element, line.text); + addTextToElement(currentParentNode, line.text); } lastRangeEndIndex = line.start; @@ -261,7 +269,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // add text before the markdown range const textBeforeRange = line.text.substring(lastRangeEndIndex - line.start, range.start - line.start); if (textBeforeRange) { - addTextToElement(currentParentNode.element, textBeforeRange); + addTextToElement(currentParentNode, textBeforeRange); } // create markdown span element @@ -273,26 +281,21 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS span.setAttribute('data-type', range.type); } + const spanNode = addItemToTree(span, currentParentNode, range.type, range.length); + if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { // tag nesting - currentParentNode.element.appendChild(span); - currentParentNode = { - element: span, - start: range.start, - length: range.length, - parentNode: currentParentNode, - }; + currentParentNode = spanNode; lastRangeEndIndex = range.start; } else { // adding markdown tag - currentParentNode.element.appendChild(span); - addTextToElement(span, text.substring(range.start, endOfCurrentRange)); + addTextToElement(spanNode, text.substring(range.start, endOfCurrentRange)); 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.element, textAfterRange); + addTextToElement(currentParentNode, textAfterRange); } lastRangeEndIndex = currentParentNode.start + currentParentNode.length; currentParentNode = currentParentNode.parentNode || rootNode; diff --git a/src/web/treeUtils.ts b/src/web/treeUtils.ts index 9a9e4e20..62e566c0 100644 --- a/src/web/treeUtils.ts +++ b/src/web/treeUtils.ts @@ -15,8 +15,8 @@ type TreeNode = Omit & { isGeneratingNewline: boolean; }; -function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: ElementType) { - const contentLength = element.nodeName === 'BR' ? 1 : element.innerText.length; +function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: ElementType, length: number | null = null) { + const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText.length); const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element.childNodes[0]?.getAttribute('data-type') === 'br'); const parentChildrenCount = parentTreeNode?.childNodes.length || 0; let startIndex = parentTreeNode.start; From 6a1eaf14efb8e95608f0a90642c0a9c9fe6a78c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 16:12:31 +0200 Subject: [PATCH 04/73] Refactor function and type names --- src/web/cursorUtils.ts | 8 ++++---- src/web/parserUtils.ts | 23 ++++++++--------------- src/web/treeUtils.ts | 22 +++++++++++----------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index 127a0228..8a50529d 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -10,10 +10,10 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul const range = document.createRange(); range.selectNodeContents(target); - const startTreeItem = TreeUtils.getElementByIndex(target.tree, start); + const startTreeItem = TreeUtils.getTreeNodeByIndex(target.tree, start); const endTreeItem = - end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? TreeUtils.getElementByIndex(target.tree, end) : startTreeItem; + end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? TreeUtils.getTreeNodeByIndex(target.tree, end) : startTreeItem; if (!startTreeItem || !endTreeItem) { throw new Error('Invalid start or end tree item'); @@ -72,8 +72,8 @@ function getCurrentCursorPosition(target: HTMLElement) { const endElement = range.startContainer === range.endContainer ? startElement : getHTMLElement(range.endContainer); - const startTreeItem = TreeUtils.findElementInTree(target.tree, startElement); - const endTreeItem = TreeUtils.findElementInTree(target.tree, endElement); + const startTreeItem = TreeUtils.findHTMLElementInTree(target.tree, startElement); + const endTreeItem = TreeUtils.findHTMLElementInTree(target.tree, endElement); let start = -1; let end = -1; diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index efcc0ea4..6e287fd1 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -6,6 +6,7 @@ import type * as TreeUtilsTypes from './treeUtils'; type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; type TreeNode = TreeUtilsTypes.TreeNode; +type NodeType = TreeUtilsTypes.NodeType; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; @@ -16,14 +17,6 @@ type MarkdownRange = { depth?: number; }; -type Node = { - element: HTMLElement; - start: number; - length: number; - parentNode: Node | null; - childNodes?: Node[]; -}; - type Paragraph = { text: string; start: number; @@ -161,8 +154,8 @@ function groupMarkdownRangesByLine(lines: Paragraph[], ranges: MarkdownRange[]) }); } -function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: 'line' | 'text' | 'br', length: number) { - const node = TreeUtils.addItemToTree(element, parentTreeNode, type, length); +function appendNode(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number) { + const node = TreeUtils.addNodeToTree(element, parentTreeNode, type, length); parentTreeNode.element.appendChild(element); return node; } @@ -170,8 +163,8 @@ function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: 'li function addBrElement(node: TreeNode) { const span = document.createElement('span'); span.setAttribute('data-type', 'br'); - const spanNode = addItemToTree(span, node, 'br', 1); - addItemToTree(document.createElement('br'), spanNode, 'br', 1); + const spanNode = appendNode(span, node, 'br', 1); + appendNode(document.createElement('br'), spanNode, 'br', 1); return spanNode; } @@ -181,7 +174,7 @@ function addTextToElement(node: TreeNode, text: string) { if (line !== '') { const span = document.createElement('span'); span.innerText = line; - addItemToTree(span, node, 'text', line.length); + appendNode(span, node, 'text', line.length); } if (index < lines.length - 1 || (index === 0 && line === '')) { @@ -199,7 +192,7 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number }); p.setAttribute('data-type', 'line'); - const pNode = addItemToTree(p, node, 'line', length); + const pNode = appendNode(p, node, 'line', length); if (text === '') { addBrElement(pNode); @@ -281,7 +274,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS span.setAttribute('data-type', range.type); } - const spanNode = addItemToTree(span, currentParentNode, range.type, range.length); + const spanNode = appendNode(span, currentParentNode, range.type, range.length); if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { // tag nesting diff --git a/src/web/treeUtils.ts b/src/web/treeUtils.ts index 62e566c0..7f1e70c1 100644 --- a/src/web/treeUtils.ts +++ b/src/web/treeUtils.ts @@ -4,18 +4,18 @@ type MarkdownType = ParserUtilsTypes.MarkdownType; type MarkdownRange = ParserUtilsTypes.MarkdownRange; -type ElementType = MarkdownType | 'line' | 'text' | 'br'; +type NodeType = MarkdownType | 'line' | 'text' | 'br'; type TreeNode = Omit & { element: HTMLElement; parentNode: TreeNode | null; childNodes: TreeNode[]; - type: ElementType; + type: NodeType; orderIndex: string; isGeneratingNewline: boolean; }; -function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: ElementType, length: number | null = null) { +function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText.length); const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element.childNodes[0]?.getAttribute('data-type') === 'br'); const parentChildrenCount = parentTreeNode?.childNodes.length || 0; @@ -45,7 +45,7 @@ function addItemToTree(element: HTMLElement, parentTreeNode: TreeNode, type: Ele } function buildTree(rootElement: HTMLElement, text: string) { - function getElementType(element: HTMLElement): ElementType { + function getElementType(element: HTMLElement): NodeType { if (element.nodeName === 'BR') { return 'br'; } @@ -53,7 +53,7 @@ function buildTree(rootElement: HTMLElement, text: string) { return 'line'; } - return (element.getAttribute('data-type') as ElementType) || 'text'; + return (element.getAttribute('data-type') as NodeType) || 'text'; } const rootTreeItem: TreeNode = { element: rootElement, @@ -73,7 +73,7 @@ function buildTree(rootElement: HTMLElement, text: string) { } Array.from(treeItem.element.children).forEach((childElement) => { - const newTreeItem = addItemToTree(childElement as HTMLElement, treeItem, getElementType(childElement as HTMLElement)); + const newTreeItem = addNodeToTree(childElement as HTMLElement, treeItem, getElementType(childElement as HTMLElement)); stack.push(newTreeItem); }); } @@ -81,13 +81,13 @@ function buildTree(rootElement: HTMLElement, text: string) { return rootTreeItem; } -function findElementInTree(treeRoot: TreeNode, element: HTMLElement) { +function findHTMLElementInTree(treeRoot: TreeNode, element: HTMLElement): TreeNode | null { if (element.hasAttribute('contenteditable')) { return treeRoot; } if (!element || !element.hasAttribute('data-id')) { - return; + return null; } const indexes = element.getAttribute('data-id')?.split(','); let el: TreeNode | null = treeRoot; @@ -106,7 +106,7 @@ function findElementInTree(treeRoot: TreeNode, element: HTMLElement) { return el; } -function getElementByIndex(treeRoot: TreeNode, index: number) { +function getTreeNodeByIndex(treeRoot: TreeNode, index: number): TreeNode | null { let el: TreeNode | null = treeRoot; let i = 0; @@ -138,6 +138,6 @@ function getElementByIndex(treeRoot: TreeNode, index: number) { return null; } -export {addItemToTree, findElementInTree, getElementByIndex, buildTree}; +export {addNodeToTree, findHTMLElementInTree, getTreeNodeByIndex, buildTree}; -export type {TreeNode}; +export type {TreeNode, NodeType}; From 75d6afde732af727ebe006e2fa8a88ea98f263b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 16:56:43 +0200 Subject: [PATCH 05/73] Fix TS errors --- .eslintrc.js | 1 + example/src/App.tsx | 2 -- src/MarkdownTextInput.tsx | 11 +++----- src/MarkdownTextInput.web.tsx | 44 +++++++++++++++----------------- src/__tests__/webParser.test.tsx | 10 ++++---- src/styleUtils.ts | 4 +-- src/web/cursorUtils.ts | 20 +++++++-------- src/web/parserUtils.ts | 35 +++++++++++++------------ src/web/treeUtils.ts | 8 ++---- 9 files changed, 63 insertions(+), 72 deletions(-) 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/example/src/App.tsx b/example/src/App.tsx index 71648f56..a3f1be4c 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 '../../WebExample/__tests__/testConstants'; diff --git a/src/MarkdownTextInput.tsx b/src/MarkdownTextInput.tsx index 95a24f0a..823c6640 100644 --- a/src/MarkdownTextInput.tsx +++ b/src/MarkdownTextInput.tsx @@ -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; } @@ -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((props, ref) => { diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index d6edf7d8..556401f4 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -13,15 +13,15 @@ import type { import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent} 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 * as TreeUtils from './web/treeUtils'; -import type * as TreeUtilsTypes from './web/treeUtils'; +import {updateInputStructure} from './web/parserUtils'; import * as BrowserUtils from './web/browserUtils'; -import type * as MarkdownTextInputDecoratorViewNativeComponent from './MarkdownTextInputDecoratorViewNativeComponent'; -import './web/MarkdownTextInput.css'; import InputHistory from './web/InputHistory'; +import {buildTree} from './web/treeUtils'; +import type {TreeNode} from './web/treeUtils'; +import {mergeMarkdownStyleWithDefault} from './styleUtils'; +import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/cursorUtils'; +import './web/MarkdownTextInput.css'; +import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; require('../parser/react-native-live-markdown-parser.js'); @@ -54,8 +54,6 @@ try { 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,7 +79,7 @@ let focusTimeout: NodeJS.Timeout | null = null; type MarkdownTextInputElement = HTMLDivElement & HTMLInputElement & { - tree: TreeUtilsTypes.TreeNode; + tree: TreeNode; }; // If an Input Method Editor is processing key input, the 'keyCode' is 229. @@ -113,7 +111,7 @@ function processUnitsInMarkdownStyle(input: MarkdownStyle): MarkdownStyle { } function processMarkdownStyle(input: MarkdownStyle | undefined): MarkdownStyle { - return processUnitsInMarkdownStyle(StyleUtils.mergeMarkdownStyleWithDefault(input)); + return processUnitsInMarkdownStyle(mergeMarkdownStyleWithDefault(input)); } function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfLines: number | undefined) { @@ -202,11 +200,11 @@ const MarkdownTextInput = React.forwardRef( }, []); const parseText = useCallback( - (target: HTMLDivElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true) => { + (target: MarkdownTextInputElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true) => { if (text === null) { return {text: textContent.current, cursorPosition: null}; } - const parsedText = ParseUtils.parseText(target, text, cursorPosition, customMarkdownStyles, !multiline); + const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline); if (divRef.current && parsedText.tree) { divRef.current.tree = parsedText.tree; @@ -243,7 +241,7 @@ const MarkdownTextInput = React.forwardRef( ); const undo = useCallback( - (target: HTMLDivElement) => { + (target: MarkdownTextInputElement) => { if (!history.current) { return ''; } @@ -255,7 +253,7 @@ const MarkdownTextInput = React.forwardRef( ); const redo = useCallback( - (target: HTMLDivElement) => { + (target: MarkdownTextInputElement) => { if (!history.current) { return ''; } @@ -299,7 +297,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); @@ -380,7 +378,7 @@ const MarkdownTextInput = React.forwardRef( const parsedText = parseInnerHTMLToText(e.target); textContent.current = parsedText; - const tree = TreeUtils.buildTree(divRef.current, parsedText); + const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; if (compositionRef.current && !BrowserUtils.isMobile) { @@ -474,7 +472,7 @@ const MarkdownTextInput = React.forwardRef( // 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'); if (contentSelection.current) { - CursorUtils.setCursorPosition(divRef.current, contentSelection.current?.start + 1); + setCursorPosition(divRef.current, contentSelection.current?.start + 1); } } if (!e.shiftKey && ((shouldBlurOnSubmit && hostNode !== null) || !multiline)) { @@ -493,10 +491,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 : textContent.current.length; - CursorUtils.setCursorPosition(divRef.current, valueLength, null); + setCursorPosition(divRef.current, valueLength, null); } updateSelection(event, contentSelection.current); } @@ -530,7 +528,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); @@ -639,7 +637,7 @@ const MarkdownTextInput = React.forwardRef( 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 ( @@ -694,4 +692,4 @@ const styles = StyleSheet.create({ export default MarkdownTextInput; -export type {MarkdownTextInputProps}; +export type {MarkdownTextInputProps, MarkdownTextInputElement}; diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 93319e70..024bdb28 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/parserUtils'; +import type {MarkdownRange} from '../web/parserUtils'; require('../../parser/react-native-live-markdown-parser.js'); @@ -17,10 +17,10 @@ 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 actualHTML = actualDOM.dom.innerHTML; + const actualDOM = parseRangesToHTMLNodes(actual, markdownRanges, {}, true); + const actualHTML = actualDOM.innerHTML; if (actualHTML === expected) { expected = actualHTML; 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/cursorUtils.ts b/src/web/cursorUtils.ts index 8a50529d..9207450a 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -1,7 +1,8 @@ +import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; import * as BrowserUtils from './browserUtils'; -import * as TreeUtils from './treeUtils'; +import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; -function setCursorPosition(target: HTMLElement, start: number, end: number | null = null) { +function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { // We don't want to move the cursor if the target is not focused if (target !== document.activeElement || start < 0 || (end && end < 0)) { return; @@ -10,10 +11,9 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul const range = document.createRange(); range.selectNodeContents(target); - const startTreeItem = TreeUtils.getTreeNodeByIndex(target.tree, start); + const startTreeItem = getTreeNodeByIndex(target.tree, start); - const endTreeItem = - end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? TreeUtils.getTreeNodeByIndex(target.tree, end) : startTreeItem; + const endTreeItem = end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? getTreeNodeByIndex(target.tree, end) : startTreeItem; if (!startTreeItem || !endTreeItem) { throw new Error('Invalid start or end tree item'); @@ -40,7 +40,7 @@ function setCursorPosition(target: HTMLElement, start: number, end: number | nul selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - scrollCursorIntoView(target as HTMLInputElement); + scrollCursorIntoView(target); } function moveCursorToEnd(target: HTMLElement) { @@ -53,7 +53,7 @@ function moveCursorToEnd(target: HTMLElement) { } } -function getCurrentCursorPosition(target: HTMLElement) { +function getCurrentCursorPosition(target: MarkdownTextInputElement) { function getHTMLElement(node: Node) { let element = node as HTMLElement | Text; if (element instanceof Text) { @@ -72,8 +72,8 @@ function getCurrentCursorPosition(target: HTMLElement) { const endElement = range.startContainer === range.endContainer ? startElement : getHTMLElement(range.endContainer); - const startTreeItem = TreeUtils.findHTMLElementInTree(target.tree, startElement); - const endTreeItem = TreeUtils.findHTMLElementInTree(target.tree, endElement); + const startTreeItem = findHTMLElementInTree(target.tree, startElement); + const endTreeItem = findHTMLElementInTree(target.tree, endElement); let start = -1; let end = -1; @@ -92,7 +92,7 @@ function removeSelection() { } } -function scrollCursorIntoView(target: HTMLInputElement) { +function scrollCursorIntoView(target: MarkdownTextInputElement) { if (target.selectionStart === null || !target.value || BrowserUtils.isFirefox) { return; } diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 6e287fd1..59063bb6 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -1,12 +1,9 @@ -import * as CursorUtils from './cursorUtils'; -import type * as StyleUtilsTypes from '../styleUtils'; import * as BrowserUtils from './browserUtils'; -import * as TreeUtils from './treeUtils'; -import type * as TreeUtilsTypes from './treeUtils'; - -type PartialMarkdownStyle = StyleUtilsTypes.PartialMarkdownStyle; -type TreeNode = TreeUtilsTypes.TreeNode; -type NodeType = TreeUtilsTypes.NodeType; +import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; +import {addNodeToTree, buildTree} from './treeUtils'; +import type {NodeType, TreeNode} from './treeUtils'; +import type {PartialMarkdownStyle} from '../styleUtils'; +import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; @@ -155,7 +152,7 @@ function groupMarkdownRangesByLine(lines: Paragraph[], ranges: MarkdownRange[]) } function appendNode(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number) { - const node = TreeUtils.addNodeToTree(element, parentTreeNode, type, length); + const node = addNodeToTree(element, parentTreeNode, type, length); parentTreeNode.element.appendChild(element); return node; } @@ -300,26 +297,32 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS return rootElement; } -function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: HTMLElement) { +function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: MarkdownTextInputElement) { if (!isFocused) { return; } if (alwaysMoveCursorToTheEnd || cursorPosition === null) { - CursorUtils.moveCursorToEnd(target); + moveCursorToEnd(target); } else if (cursorPosition !== null) { - CursorUtils.setCursorPosition(target, cursorPosition); + setCursorPosition(target, cursorPosition); } } -function parseText(target: HTMLElement, text: string, cursorPositionIndex: number | null, markdownStyle: PartialMarkdownStyle = {}, alwaysMoveCursorToTheEnd = false) { +function updateInputStructure( + target: MarkdownTextInputElement, + 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); + const selection = getCurrentCursorPosition(target); cursorPosition = selection ? selection.start : null; } const ranges = global.parseExpensiMarkToRanges(text); @@ -339,7 +342,7 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe targetElement.innerText = ''; targetElement.innerHTML = dom.innerHTML || ''; - tree = TreeUtils.buildTree(targetElement, text); + tree = buildTree(targetElement, text); targetElement.tree = tree; if (BrowserUtils.isChromium) { @@ -355,6 +358,6 @@ function parseText(target: HTMLElement, text: string, cursorPositionIndex: numbe return {text, cursorPosition: cursorPosition || 0, tree}; } -export {parseText, parseRangesToHTMLNodes}; +export {updateInputStructure, parseRangesToHTMLNodes}; export type {MarkdownRange, MarkdownType}; diff --git a/src/web/treeUtils.ts b/src/web/treeUtils.ts index 7f1e70c1..3a071029 100644 --- a/src/web/treeUtils.ts +++ b/src/web/treeUtils.ts @@ -1,8 +1,4 @@ -import type * as ParserUtilsTypes from './parserUtils'; - -type MarkdownType = ParserUtilsTypes.MarkdownType; - -type MarkdownRange = ParserUtilsTypes.MarkdownRange; +import type {MarkdownRange, MarkdownType} from './parserUtils'; type NodeType = MarkdownType | 'line' | 'text' | 'br'; @@ -17,7 +13,7 @@ type TreeNode = Omit & { function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText.length); - const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element.childNodes[0]?.getAttribute('data-type') === 'br'); + 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) { From db68b78554ce9907891eaa29d0332eb8d16b55df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 17:01:35 +0200 Subject: [PATCH 06/73] Update parser structure --- parser/__tests__/index.test.ts | 6 +++--- parser/index.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/parser/__tests__/index.test.ts b/parser/__tests__/index.test.ts index 35983601..eb0641bf 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 da240d4d..c82f329a 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') { @@ -160,10 +160,10 @@ function parseTreeToTextAndRanges(tree: StackItem): [string, Range[]] { appendSyntax('```'); } else if (node.tag.startsWith(' Date: Wed, 26 Jun 2024 17:02:05 +0200 Subject: [PATCH 07/73] Move BrowserUtils into an object --- src/MarkdownTextInput.web.tsx | 2 +- src/web/browserUtils.ts | 20 +++++++++++--------- src/web/cursorUtils.ts | 2 +- src/web/parserUtils.ts | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 556401f4..ecca2ed2 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -14,7 +14,7 @@ import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'r import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent} from 'react'; import {StyleSheet} from 'react-native'; import {updateInputStructure} from './web/parserUtils'; -import * as BrowserUtils from './web/browserUtils'; +import BrowserUtils from './web/browserUtils'; import InputHistory from './web/InputHistory'; import {buildTree} from './web/treeUtils'; import type {TreeNode} from './web/treeUtils'; diff --git a/src/web/browserUtils.ts b/src/web/browserUtils.ts index a70d97c9..cfe157c5 100644 --- a/src/web/browserUtils.ts +++ b/src/web/browserUtils.ts @@ -1,11 +1,13 @@ -const isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); -const isChromium = 'chrome' in window; +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 - * - */ -const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|BB|PlayBook|IEMobile|Windows Phone|Silk|Opera Mini/i.test(navigator.userAgent); + /** + * 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 {isFirefox, isChromium, isMobile}; +export default BrowserUtils; diff --git a/src/web/cursorUtils.ts b/src/web/cursorUtils.ts index 9207450a..6aa10c71 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/cursorUtils.ts @@ -1,5 +1,5 @@ import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; -import * as BrowserUtils from './browserUtils'; +import BrowserUtils from './browserUtils'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { diff --git a/src/web/parserUtils.ts b/src/web/parserUtils.ts index 59063bb6..718cf2fa 100644 --- a/src/web/parserUtils.ts +++ b/src/web/parserUtils.ts @@ -1,4 +1,4 @@ -import * as BrowserUtils from './browserUtils'; +import BrowserUtils from './browserUtils'; import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; import {addNodeToTree, buildTree} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; From cab8ca745db561c66ba53399201a49807079502f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 17:09:28 +0200 Subject: [PATCH 08/73] Move utils to separate folder --- src/MarkdownTextInput.web.tsx | 10 +++++----- src/__tests__/webParser.test.tsx | 4 ++-- src/web/{ => utils}/browserUtils.ts | 0 src/web/{ => utils}/cursorUtils.ts | 2 +- src/web/{ => utils}/parserUtils.ts | 4 ++-- src/web/{ => utils}/treeUtils.ts | 0 6 files changed, 10 insertions(+), 10 deletions(-) rename src/web/{ => utils}/browserUtils.ts (100%) rename src/web/{ => utils}/cursorUtils.ts (98%) rename src/web/{ => utils}/parserUtils.ts (98%) rename src/web/{ => utils}/treeUtils.ts (100%) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index ecca2ed2..74c388b4 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -13,13 +13,13 @@ import type { import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent} from 'react'; import {StyleSheet} from 'react-native'; -import {updateInputStructure} from './web/parserUtils'; -import BrowserUtils from './web/browserUtils'; +import {updateInputStructure} from './web/utils/parserUtils'; +import BrowserUtils from './web/utils/browserUtils'; import InputHistory from './web/InputHistory'; -import {buildTree} from './web/treeUtils'; -import type {TreeNode} from './web/treeUtils'; +import {buildTree} from './web/utils/treeUtils'; +import type {TreeNode} from './web/utils/treeUtils'; import {mergeMarkdownStyleWithDefault} from './styleUtils'; -import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/cursorUtils'; +import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; import './web/MarkdownTextInput.css'; import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 024bdb28..51d868ca 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 {parseRangesToHTMLNodes} from '../web/parserUtils'; -import type {MarkdownRange} from '../web/parserUtils'; +import {parseRangesToHTMLNodes} from '../web/utils/parserUtils'; +import type {MarkdownRange} from '../web/utils/parserUtils'; require('../../parser/react-native-live-markdown-parser.js'); diff --git a/src/web/browserUtils.ts b/src/web/utils/browserUtils.ts similarity index 100% rename from src/web/browserUtils.ts rename to src/web/utils/browserUtils.ts diff --git a/src/web/cursorUtils.ts b/src/web/utils/cursorUtils.ts similarity index 98% rename from src/web/cursorUtils.ts rename to src/web/utils/cursorUtils.ts index 6aa10c71..de4dde71 100644 --- a/src/web/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,4 +1,4 @@ -import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import BrowserUtils from './browserUtils'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; diff --git a/src/web/parserUtils.ts b/src/web/utils/parserUtils.ts similarity index 98% rename from src/web/parserUtils.ts rename to src/web/utils/parserUtils.ts index 718cf2fa..06a7bd0c 100644 --- a/src/web/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -1,8 +1,8 @@ import BrowserUtils from './browserUtils'; -import type {MarkdownTextInputElement} from '../MarkdownTextInput.web'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import {addNodeToTree, buildTree} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; -import type {PartialMarkdownStyle} from '../styleUtils'; +import type {PartialMarkdownStyle} from '../../styleUtils'; import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; diff --git a/src/web/treeUtils.ts b/src/web/utils/treeUtils.ts similarity index 100% rename from src/web/treeUtils.ts rename to src/web/utils/treeUtils.ts From 2f829cc7b1ca8504801e46ff8a46febd0b58e27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 17:14:27 +0200 Subject: [PATCH 09/73] Add block utils --- src/web/utils/blockUtils.ts | 65 ++++++++++++++++++++++++++++++++++++ src/web/utils/parserUtils.ts | 63 ++-------------------------------- 2 files changed, 67 insertions(+), 61 deletions(-) create mode 100644 src/web/utils/blockUtils.ts diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts new file mode 100644 index 00000000..0e7aee21 --- /dev/null +++ b/src/web/utils/blockUtils.ts @@ -0,0 +1,65 @@ +import type {MarkdownType} from './parserUtils'; +import type {PartialMarkdownStyle} from '../../styleUtils'; + +function addBlockStyling(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; + } +} + +// eslint-disable-next-line import/prefer-default-export +export {addBlockStyling}; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 06a7bd0c..bed8300e 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -4,6 +4,7 @@ import {addNodeToTree, buildTree} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; +import {addBlockStyling} from './blockUtils'; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; @@ -21,66 +22,6 @@ type Paragraph = { markdownRanges: MarkdownRange[]; }; -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 ungroupRanges(ranges: MarkdownRange[]): MarkdownRange[] { const ungroupedRanges: MarkdownRange[] = []; ranges.forEach((range) => { @@ -267,7 +208,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS if (disableInlineStyles) { span.className = range.type; } else { - addStyling(span, range.type, markdownStyle); + addBlockStyling(span, range.type, markdownStyle); span.setAttribute('data-type', range.type); } From b9b29849e2cb2443b22f04e4cdc4880d8aef074d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 26 Jun 2024 17:45:16 +0200 Subject: [PATCH 10/73] Move functions from above the web component to utils --- src/MarkdownTextInput.web.tsx | 80 ++-------------------------------- src/web/utils/inputUtils.ts | 34 +++++++++++++++ src/web/utils/webStyleUtils.ts | 54 +++++++++++++++++++++++ 3 files changed, 91 insertions(+), 77 deletions(-) create mode 100644 src/web/utils/inputUtils.ts create mode 100644 src/web/utils/webStyleUtils.ts diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 74c388b4..70ff8b73 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -18,42 +18,16 @@ import BrowserUtils from './web/utils/browserUtils'; import InputHistory from './web/InputHistory'; import {buildTree} from './web/utils/treeUtils'; import type {TreeNode} from './web/utils/treeUtils'; -import {mergeMarkdownStyleWithDefault} from './styleUtils'; import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; import './web/MarkdownTextInput.css'; import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; +import {getElementHeight, getPlaceholderValue, isEventComposing} 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.'); -} - interface MarkdownTextInputProps extends TextInputProps { markdownStyle?: MarkdownStyle; onClick?: (e: MouseEvent) => void; @@ -82,54 +56,6 @@ type MarkdownTextInputElement = HTMLDivElement & tree: TreeNode; }; -// 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; -} - -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(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.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'; -} - const MarkdownTextInput = React.forwardRef( ( { @@ -235,7 +161,7 @@ const MarkdownTextInput = React.forwardRef( caretColor: (flattenedStyle as TextStyle).color || 'black', }, disabled && styles.disabledInputStyles, - createReactDOMStyle(preprocessStyle(flattenedStyle)), + parseToReactDOMStyle(flattenedStyle), ]) as CSSProperties, [flattenedStyle, disabled], ); diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts new file mode 100644 index 00000000..7a7abc4c --- /dev/null +++ b/src/web/utils/inputUtils.ts @@ -0,0 +1,34 @@ +import type {CSSProperties} from 'react'; + +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'; +} + +export {isEventComposing, getPlaceholderValue, getElementHeight}; 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}; From 3fbc45687a474b2a06ff8cdcb668244bdd63e040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 27 Jun 2024 16:24:24 +0200 Subject: [PATCH 11/73] Fix unit tests --- src/__tests__/webParser.test.tsx | 132 +++++++++++++++++++++---------- src/web/utils/blockUtils.ts | 13 ++- src/web/utils/cursorUtils.ts | 4 +- src/web/utils/parserUtils.ts | 29 ++++--- src/web/utils/treeUtils.ts | 4 +- 5 files changed, 120 insertions(+), 62 deletions(-) diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 51d868ca..0f59c9d1 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -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/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index 0e7aee21..887ef4e1 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -1,9 +1,16 @@ -import type {MarkdownType} from './parserUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; +import type {NodeType} from './treeUtils'; -function addBlockStyling(targetElement: HTMLElement, type: MarkdownType, markdownStyle: PartialMarkdownStyle) { +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; @@ -62,4 +69,4 @@ function addBlockStyling(targetElement: HTMLElement, type: MarkdownType, markdow } // eslint-disable-next-line import/prefer-default-export -export {addBlockStyling}; +export {addStyleToBlock}; diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index de4dde71..bf13c094 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -22,13 +22,13 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: if (startTreeItem.type === 'br') { range.setStartBefore(startTreeItem.element); } else { - range.setStart(startTreeItem.element.childNodes[0] as ChildNode, start - startTreeItem.start); + range.setStart(startTreeItem.element as ChildNode, start - startTreeItem.start); } if (endTreeItem.type === 'br') { range.setEndBefore(endTreeItem.element); } else { - range.setEnd(endTreeItem.element.childNodes[0] as ChildNode, (end || start) - endTreeItem.start); + range.setEnd(endTreeItem.element as ChildNode, (end || start) - endTreeItem.start); } if (!end) { diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index bed8300e..097a20f5 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -4,7 +4,7 @@ import {addNodeToTree, buildTree} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; -import {addBlockStyling} from './blockUtils'; +import {addStyleToBlock} from './blockUtils'; type MarkdownType = 'bold' | 'italic' | 'strikethrough' | 'emoji' | 'link' | 'code' | 'pre' | 'blockquote' | 'h1' | 'syntax' | 'mention-here' | 'mention-user' | 'mention-report'; @@ -111,7 +111,8 @@ function addTextToElement(node: TreeNode, text: string) { lines.forEach((line, index) => { if (line !== '') { const span = document.createElement('span'); - span.innerText = line; + span.setAttribute('data-type', 'text'); + span.appendChild(document.createTextNode(line)); appendNode(span, node, 'text', line.length); } @@ -121,14 +122,12 @@ function addTextToElement(node: TreeNode, text: string) { }); } -function addParagraph(node: TreeNode, text: string | null = null, length: number) { +function addParagraph(node: TreeNode, text: string | null = null, length: number, disableInlineStyles = false) { const p = document.createElement('p'); - Object.assign(p.style, { - margin: '0', - padding: '0', - display: 'block', - }); p.setAttribute('data-type', 'line'); + if (!disableInlineStyles) { + addStyleToBlock(p, 'line', {}); + } const pNode = appendNode(p, node, 'line', length); @@ -161,8 +160,9 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS if (ranges.length === 0) { lines.forEach((line) => { - addParagraph(rootNode, line.text, line.length); + addParagraph(rootNode, line.text, line.length, disableInlineStyles); }); + return rootElement; } @@ -179,7 +179,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } // preparing line paragraph element for markdown text - currentParentNode = addParagraph(rootNode, null, line.length); + currentParentNode = addParagraph(rootNode, null, line.length, disableInlineStyles); if (line.markdownRanges.length === 0) { addTextToElement(currentParentNode, line.text); } @@ -205,11 +205,9 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // create markdown span element const span = document.createElement('span'); - if (disableInlineStyles) { - span.className = range.type; - } else { - addBlockStyling(span, range.type, markdownStyle); - span.setAttribute('data-type', range.type); + span.setAttribute('data-type', range.type); + if (!disableInlineStyles) { + addStyleToBlock(span, range.type, markdownStyle); } const spanNode = appendNode(span, currentParentNode, range.type, range.length); @@ -278,6 +276,7 @@ function updateInputStructure( // 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 (targetElement.innerHTML !== dom.innerHTML) { targetElement.innerHTML = ''; targetElement.innerText = ''; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 3a071029..dc577c80 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -12,8 +12,8 @@ type TreeNode = Omit & { }; function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { - const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText.length); - const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && (element.childNodes[0] as HTMLElement)?.getAttribute('data-type') === 'br'); + const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText?.length) || 0; + const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element?.getAttribute('data-type') === 'br'); const parentChildrenCount = parentTreeNode?.childNodes.length || 0; let startIndex = parentTreeNode.start; if (parentChildrenCount > 0) { From d74e0e42070fef57a4ff3593d6318ad12d80723d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 28 Jun 2024 17:06:07 +0200 Subject: [PATCH 12/73] Fix cursor positioning bugs --- src/web/utils/cursorUtils.ts | 9 ++------- src/web/utils/treeUtils.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index bf13c094..d30c67b0 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -12,9 +12,7 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: range.selectNodeContents(target); const startTreeItem = getTreeNodeByIndex(target.tree, start); - const endTreeItem = end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? getTreeNodeByIndex(target.tree, end) : startTreeItem; - if (!startTreeItem || !endTreeItem) { throw new Error('Invalid start or end tree item'); } @@ -22,13 +20,13 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: if (startTreeItem.type === 'br') { range.setStartBefore(startTreeItem.element); } else { - range.setStart(startTreeItem.element as ChildNode, start - startTreeItem.start); + range.setStart(startTreeItem.element.childNodes[0] as ChildNode, start - startTreeItem.start); } if (endTreeItem.type === 'br') { range.setEndBefore(endTreeItem.element); } else { - range.setEnd(endTreeItem.element as ChildNode, (end || start) - endTreeItem.start); + range.setEnd(endTreeItem.element.childNodes[0] as ChildNode, (end || start) - endTreeItem.start); } if (!end) { @@ -66,10 +64,8 @@ function getCurrentCursorPosition(target: MarkdownTextInputElement) { 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 startTreeItem = findHTMLElementInTree(target.tree, startElement); @@ -81,7 +77,6 @@ function getCurrentCursorPosition(target: MarkdownTextInputElement) { start = startTreeItem.start + range.startOffset; end = endTreeItem.start + range.endOffset; } - return {start, end}; } diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index dc577c80..8ee32a07 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -13,7 +13,7 @@ type TreeNode = Omit & { function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText?.length) || 0; - const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && element?.getAttribute('data-type') === 'br'); + 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) { From 220395b68c653013174a153a9509b03148f42909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 28 Jun 2024 18:26:37 +0200 Subject: [PATCH 13/73] Remove scrollCursorIntoView function --- src/web/utils/cursorUtils.ts | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index d30c67b0..b3e6f70c 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,5 +1,4 @@ import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; -import BrowserUtils from './browserUtils'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { @@ -38,7 +37,7 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - scrollCursorIntoView(target); + startTreeItem.element.scrollIntoView(); } function moveCursorToEnd(target: HTMLElement) { @@ -87,31 +86,4 @@ function removeSelection() { } } -function scrollCursorIntoView(target: MarkdownTextInputElement) { - if (target.selectionStart === null || !target.value || BrowserUtils.isFirefox) { - return; - } - - const selection = window.getSelection(); - if (!selection || (selection && selection.rangeCount === 0)) { - return; - } - - const caretRect = selection.getRangeAt(0).getClientRects()[0]; - const editableRect = target.getBoundingClientRect(); - - // Adjust for padding and border - const paddingTop = parseFloat(window.getComputedStyle(target).paddingTop); - const borderTop = parseFloat(window.getComputedStyle(target).borderTopWidth); - - if (caretRect && !(caretRect.top >= editableRect.top + paddingTop + borderTop && caretRect.bottom <= editableRect.bottom - 2 * (paddingTop - borderTop))) { - const topToCaret = caretRect.top - editableRect.top; - const inputHeight = editableRect.height; - // Chrome Rects don't include padding & border, so we're adding them manually - const inputOffset = caretRect.height - inputHeight + paddingTop + borderTop + (BrowserUtils.isChromium ? 0 : 4 * (paddingTop + borderTop)); - - target.scrollTo(0, topToCaret + target.scrollTop + inputOffset); - } -} - -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection, scrollCursorIntoView}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection}; From 4d95382b08f7e7281f58eab281bd5729671ac65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 28 Jun 2024 18:36:53 +0200 Subject: [PATCH 14/73] Rename variable names --- example/src/App.tsx | 1 + src/web/utils/cursorUtils.ts | 32 ++++++++++++++++---------------- src/web/utils/treeUtils.ts | 16 ++++++++-------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index a3f1be4c..f5899e57 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -192,6 +192,7 @@ const styles = StyleSheet.create({ borderColor: 'gray', borderWidth: 1, textAlignVertical: 'top', + height: 100, }, text: { fontFamily: 'Courier New', diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index b3e6f70c..46e35b4e 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -10,22 +10,22 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: const range = document.createRange(); range.selectNodeContents(target); - const startTreeItem = getTreeNodeByIndex(target.tree, start); - const endTreeItem = end && startTreeItem && (end < startTreeItem.start || end >= startTreeItem.start + startTreeItem.length) ? getTreeNodeByIndex(target.tree, end) : startTreeItem; - if (!startTreeItem || !endTreeItem) { - throw new Error('Invalid start or end tree item'); + 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) { + throw new Error('Invalid start or end tree node'); } - if (startTreeItem.type === 'br') { - range.setStartBefore(startTreeItem.element); + if (startTreeNode.type === 'br') { + range.setStartBefore(startTreeNode.element); } else { - range.setStart(startTreeItem.element.childNodes[0] as ChildNode, start - startTreeItem.start); + range.setStart(startTreeNode.element.childNodes[0] as ChildNode, start - startTreeNode.start); } - if (endTreeItem.type === 'br') { - range.setEndBefore(endTreeItem.element); + if (endTreeNode.type === 'br') { + range.setEndBefore(endTreeNode.element); } else { - range.setEnd(endTreeItem.element.childNodes[0] as ChildNode, (end || start) - endTreeItem.start); + range.setEnd(endTreeNode.element.childNodes[0] as ChildNode, (end || start) - endTreeNode.start); } if (!end) { @@ -37,7 +37,7 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - startTreeItem.element.scrollIntoView(); + startTreeNode.element.scrollIntoView(); } function moveCursorToEnd(target: HTMLElement) { @@ -67,14 +67,14 @@ function getCurrentCursorPosition(target: MarkdownTextInputElement) { const startElement = getHTMLElement(range.startContainer); const endElement = range.startContainer === range.endContainer ? startElement : getHTMLElement(range.endContainer); - const startTreeItem = findHTMLElementInTree(target.tree, startElement); - const endTreeItem = findHTMLElementInTree(target.tree, endElement); + const startTreeNode = findHTMLElementInTree(target.tree, startElement); + const endTreeNode = findHTMLElementInTree(target.tree, endElement); let start = -1; let end = -1; - if (startTreeItem && endTreeItem) { - start = startTreeItem.start + range.startOffset; - end = endTreeItem.start + range.endOffset; + if (startTreeNode && endTreeNode) { + start = startTreeNode.start + range.startOffset; + end = endTreeNode.start + range.endOffset; } return {start, end}; } diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 8ee32a07..31d229ec 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -51,7 +51,7 @@ function buildTree(rootElement: HTMLElement, text: string) { return (element.getAttribute('data-type') as NodeType) || 'text'; } - const rootTreeItem: TreeNode = { + const rootTreeNode: TreeNode = { element: rootElement, parentNode: null, childNodes: [], @@ -61,20 +61,20 @@ function buildTree(rootElement: HTMLElement, text: string) { orderIndex: '', isGeneratingNewline: false, }; - const stack = [rootTreeItem]; + const stack = [rootTreeNode]; while (stack.length > 0) { - const treeItem = stack.pop(); - if (!treeItem) { + const treeNode = stack.pop(); + if (!treeNode) { break; } - Array.from(treeItem.element.children).forEach((childElement) => { - const newTreeItem = addNodeToTree(childElement as HTMLElement, treeItem, getElementType(childElement as HTMLElement)); - stack.push(newTreeItem); + Array.from(treeNode.element.children).forEach((childElement) => { + const newTreeNode = addNodeToTree(childElement as HTMLElement, treeNode, getElementType(childElement as HTMLElement)); + stack.push(newTreeNode); }); } - return rootTreeItem; + return rootTreeNode; } function findHTMLElementInTree(treeRoot: TreeNode, element: HTMLElement): TreeNode | null { From 907c73b0bd7c9c2afeeeb49172250ff3cc5cbb8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 28 Jun 2024 19:00:03 +0200 Subject: [PATCH 15/73] Replace textContent with ref value --- example/src/App.tsx | 1 - src/MarkdownTextInput.web.tsx | 30 +++++++++++++----------------- src/web/utils/parserUtils.ts | 2 +- 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index f5899e57..a3f1be4c 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -192,7 +192,6 @@ const styles = StyleSheet.create({ borderColor: 'gray', borderWidth: 1, textAlignVertical: 'top', - height: 100, }, text: { fontFamily: 'Courier New', diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 70ff8b73..676aeb47 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -100,7 +100,6 @@ const MarkdownTextInput = React.forwardRef( const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; const history = useRef(); const dimensions = React.useRef(null); - const textContent = useRef(''); if (!history.current) { history.current = new InputHistory(100, 150, value || ''); @@ -113,11 +112,7 @@ const MarkdownTextInput = React.forwardRef( const setEventProps = useCallback((e: NativeSyntheticEvent) => { if (divRef.current) { - const text = textContent.current; - if (e.target) { - // TODO: change the logic here so every event have value property - (e.target as unknown as HTMLInputElement).value = text; - } + const text = divRef.current.value; if (e.nativeEvent && e.nativeEvent.text) { e.nativeEvent.text = text; } @@ -127,14 +122,15 @@ const MarkdownTextInput = React.forwardRef( const parseText = useCallback( (target: MarkdownTextInputElement, text: string | null, customMarkdownStyles: MarkdownStyle, cursorPosition: number | null = null, shouldAddToHistory = true) => { + if (!divRef.current) { + return {text: text || '', cursorPosition: null}; + } + if (text === null) { - return {text: textContent.current, cursorPosition: null}; + return {text: divRef.current.value, cursorPosition: null}; } const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline); - if (divRef.current && parsedText.tree) { - divRef.current.tree = parsedText.tree; - } 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(parsedText.text, parsedText.cursorPosition); @@ -148,7 +144,7 @@ const MarkdownTextInput = React.forwardRef( const processedMarkdownStyle = useMemo(() => { const newMarkdownStyle = processMarkdownStyle(markdownStyle); if (divRef.current) { - parseText(divRef.current, textContent.current, newMarkdownStyle, null, false); + parseText(divRef.current, divRef.current.value, newMarkdownStyle, null, false); } return newMarkdownStyle; }, [markdownStyle, parseText]); @@ -302,7 +298,7 @@ const MarkdownTextInput = React.forwardRef( } const parsedText = parseInnerHTMLToText(e.target); - textContent.current = parsedText; + divRef.current.value = parsedText; const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; @@ -419,7 +415,7 @@ const MarkdownTextInput = React.forwardRef( if (contentSelection.current) { setCursorPosition(divRef.current, contentSelection.current.start, contentSelection.current.end); } else { - const valueLength = value ? value.length : textContent.current.length; + const valueLength = value ? value.length : divRef.current.value.length; setCursorPosition(divRef.current, valueLength, null); } updateSelection(event, contentSelection.current); @@ -470,7 +466,7 @@ const MarkdownTextInput = React.forwardRef( if (!onClick || !divRef.current) { return; } - (e.target as HTMLInputElement).value = textContent.current; + (e.target as HTMLInputElement).value = divRef.current.value; onClick(e); }, [onClick, updateSelection], @@ -512,16 +508,16 @@ const MarkdownTextInput = React.forwardRef( useClientEffect( function parseAndStyleValue() { - if (!divRef.current || value === textContent.current) { + if (!divRef.current || value === divRef.current.value) { return; } if (value === undefined) { - parseText(divRef.current, textContent.current, processedMarkdownStyle); + parseText(divRef.current, divRef.current.value, processedMarkdownStyle); return; } - textContent.current = value; + divRef.current.value = value; parseText(divRef.current, value, processedMarkdownStyle); updateTextColor(divRef.current, value); }, diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 097a20f5..9191d3c1 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -295,7 +295,7 @@ function updateInputStructure( } } - return {text, cursorPosition: cursorPosition || 0, tree}; + return {text, cursorPosition: cursorPosition || 0}; } export {updateInputStructure, parseRangesToHTMLNodes}; From ff41dd790f0287af7326e99dead1d9b11da1e07d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 1 Jul 2024 10:31:17 +0200 Subject: [PATCH 16/73] Fix crashes and cursor positioning in E/App --- src/MarkdownTextInput.web.tsx | 6 +++++- src/web/utils/cursorUtils.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 76b85862..e66aca92 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -113,6 +113,10 @@ const MarkdownTextInput = React.forwardRef( const setEventProps = useCallback((e: NativeSyntheticEvent) => { if (divRef.current) { 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; + } if (e.nativeEvent && e.nativeEvent.text) { e.nativeEvent.text = text; } @@ -526,7 +530,7 @@ const MarkdownTextInput = React.forwardRef( parseText(divRef.current, value, processedMarkdownStyle); updateTextColor(divRef.current, value); }, - [multiline, processedMarkdownStyle], + [multiline, processedMarkdownStyle, value], ); useClientEffect( diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index 46e35b4e..77e18db5 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -3,7 +3,7 @@ import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { // We don't want to move the cursor if the target is not focused - if (target !== document.activeElement || start < 0 || (end && end < 0)) { + if (!target.tree || target !== document.activeElement || start < 0 || (end && end < 0)) { return; } From d1d8a497624bcfd110417a7a5d6376d739b70614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 1 Jul 2024 13:26:19 +0200 Subject: [PATCH 17/73] Fix copying and pasting text with markdown --- src/MarkdownTextInput.web.tsx | 26 ++++++++++++++++++++------ src/web/utils/cursorUtils.ts | 3 ++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index e66aca92..11e3959e 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -11,7 +11,7 @@ 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 {updateInputStructure} from './web/utils/parserUtils'; import BrowserUtils from './web/utils/browserUtils'; @@ -476,13 +476,25 @@ const MarkdownTextInput = React.forwardRef( [onClick, updateSelection], ); - const handlePaste = useCallback((e) => { - pasteRef.current = true; + const handleCopy: ClipboardEventHandler = useCallback((e) => { + if (!divRef.current || !contentSelection.current) { + return; + } e.preventDefault(); + const text = divRef.current?.value.substring(contentSelection.current.start, contentSelection.current.end); + e.clipboardData.setData('text/plain', text ?? ''); + }, []); - const clipboardData = e.clipboardData; - const text = clipboardData.getData('text/plain'); - document.execCommand('insertText', false, text); + const handleCut = useCallback((e) => { + if (!divRef.current || !contentSelection.current) { + return; + } + const text = divRef.current?.value.substring(contentSelection.current.start, contentSelection.current.end); + e.clipboardData.setData('text/plain', text ?? ''); + }, []); + + const handlePaste = useCallback(() => { + pasteRef.current = true; }, []); const startComposition = useCallback(() => { @@ -593,6 +605,8 @@ const MarkdownTextInput = React.forwardRef( onClick={handleClick} onFocus={handleFocus} onBlur={handleBlur} + onCopy={handleCopy} + onCut={handleCut} onPaste={handlePaste} placeholder={heightSafePlaceholder} spellCheck={spellCheck} diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index 77e18db5..aa545f58 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -13,7 +13,8 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: 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) { - throw new Error('Invalid start or end tree node'); + console.error('Invalid start or end tree node'); + return; } if (startTreeNode.type === 'br') { From 153ead877a18f4f7fb5646d099b3cf85e250f895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 1 Jul 2024 14:21:33 +0200 Subject: [PATCH 18/73] Fix pasting text starting with newlines --- src/MarkdownTextInput.web.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 11e3959e..808a4733 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -266,7 +266,7 @@ const MarkdownTextInput = React.forwardRef( nodeCopy.innerHTML = '\n'; } // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div - nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); + nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); } let nodeText = nodeCopy.textContent ?? ''; From ea6aad80f9a7c557df0f7dafc3e9b02ef1871f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 10:22:31 +0200 Subject: [PATCH 19/73] Fix errors when replacing text --- src/web/utils/treeUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 31d229ec..85205f54 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -13,7 +13,7 @@ type TreeNode = Omit & { function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText?.length) || 0; - const isGeneratingNewline = type === 'line' && !(element.childNodes.length === 1 && (element.childNodes[0] as HTMLElement)?.getAttribute('data-type') === 'br'); + 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) { From 87953bbb01a4b9d0494459af38fe7143c53e372b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 12:41:44 +0200 Subject: [PATCH 20/73] Fix HTML injestions and pasted text parsing --- src/MarkdownTextInput.web.tsx | 54 +++++++---------------------------- src/web/utils/inputUtils.ts | 46 ++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 808a4733..70910399 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -21,7 +21,7 @@ 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} from './web/utils/inputUtils'; +import {getElementHeight, getPlaceholderValue, isEventComposing, parseInnerHTMLToText} from './web/utils/inputUtils'; import {parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; require('../parser/react-native-live-markdown-parser.js'); @@ -255,46 +255,6 @@ const MarkdownTextInput = React.forwardRef( } }, [multiline, onContentSizeChange]); - const parseInnerHTMLToText = useCallback((target: HTMLElement): string => { - let text = ''; - const childNodes = target.childNodes ?? []; - childNodes.forEach((node, index) => { - const nodeCopy = node.cloneNode(true) as HTMLElement; - if (nodeCopy.innerHTML) { - // Replace single
created by contentEditable with '\n', to enable proper newline deletion on backspace, when next lines also have
tags - if (nodeCopy.innerHTML === '
') { - nodeCopy.innerHTML = '\n'; - } - // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div - nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); - } - let nodeText = nodeCopy.textContent ?? ''; - - // Remove unnecessary new lines from the end of the text - if (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') { - nodeText = nodeText.slice(0, -1); - } - - // Last line specific handling - if (index === childNodes.length - 1) { - if (nodeText === '\n\n') { - // New line creation - nodeText = '\n'; - } else if (nodeText === '\n') { - // New line deletion on backspace - nodeText = ''; - } - } - - text += nodeText; - // Split paragraphs with new lines - if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { - text += '\n'; - } - }); - return text; - }, []); - const handleOnChangeText = useCallback( (e: SyntheticEvent) => { if (!divRef.current || !(e.target instanceof HTMLElement)) { @@ -344,7 +304,7 @@ const MarkdownTextInput = React.forwardRef( handleContentSizeChange(); }, - [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, parseInnerHTMLToText, processedMarkdownStyle, updateSelection, setEventProps], + [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, updateSelection, setEventProps], ); const handleKeyPress = useCallback( @@ -493,8 +453,16 @@ const MarkdownTextInput = React.forwardRef( e.clipboardData.setData('text/plain', text ?? ''); }, []); - const handlePaste = useCallback(() => { + const handlePaste = useCallback((e) => { pasteRef.current = true; + e.preventDefault(); + + const clipboardData = e.clipboardData; + const text = clipboardData.getData('text/plain'); + const span = document.createElement('span'); + span.setAttribute('data-type', 'paste'); + span.textContent = text; + document.execCommand('insertHTML', true, span.outerHTML); }, []); const startComposition = useCallback(() => { diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 7a7abc4c..218800ad 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -31,4 +31,48 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL return styles.height ? `${styles.height}px` : 'auto'; } -export {isEventComposing, getPlaceholderValue, getElementHeight}; +const parseInnerHTMLToText = (target: HTMLElement): string => { + let text = ''; + const childNodes = target.childNodes ?? []; + childNodes.forEach((node, index) => { + const nodeCopy = node.cloneNode(true) as HTMLElement; + if (nodeCopy.innerHTML) { + // Replace single
created by contentEditable with '\n', to enable proper newline deletion on backspace, when next lines also have
tags + if (nodeCopy.innerHTML === '
') { + nodeCopy.innerHTML = '\n'; + } + // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div + nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); + } + + let nodeText = nodeCopy.textContent ?? ''; + if (nodeCopy.innerHTML && nodeCopy.innerHTML.includes(' 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') { + nodeText = nodeText.slice(0, -1); + } + + // Last line specific handling + if (index === childNodes.length - 1) { + if (nodeText === '\n\n') { + // New line creation + nodeText = '\n'; + } else if (nodeText === '\n') { + // New line deletion on backspace + nodeText = ''; + } + } + + text += nodeText; + // Split paragraphs with new lines + if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { + text += '\n'; + } + }); + return text; +}; + +export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText}; From 3d30621a55713dfba281b6c2a912de2952a7ccef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 12:50:23 +0200 Subject: [PATCH 21/73] Fix paste text trimming condiftion --- src/web/utils/inputUtils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 218800ad..84a38455 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -46,7 +46,14 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { } let nodeText = nodeCopy.textContent ?? ''; - if (nodeCopy.innerHTML && nodeCopy.innerHTML.includes(' 0 && + nodeCopy.children[0]?.getAttribute('data-type') === 'br' && + nodeCopy.children[0].children.length > 0 && + nodeCopy.children[0].children[0]?.getAttribute('data-type') === 'paste' + ) { nodeText = nodeText.slice(0, -1); } From 471f53a6b065922e01464896798385c465ba4a36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 14:13:57 +0200 Subject: [PATCH 22/73] Fix cutting text --- src/MarkdownTextInput.web.tsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 70910399..7c2d7740 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -445,13 +445,18 @@ const MarkdownTextInput = React.forwardRef( e.clipboardData.setData('text/plain', text ?? ''); }, []); - const handleCut = useCallback((e) => { - if (!divRef.current || !contentSelection.current) { - return; - } - 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) => { pasteRef.current = true; @@ -462,7 +467,7 @@ const MarkdownTextInput = React.forwardRef( const span = document.createElement('span'); span.setAttribute('data-type', 'paste'); span.textContent = text; - document.execCommand('insertHTML', true, span.outerHTML); + document.execCommand('insertHTML', false, span.outerHTML); }, []); const startComposition = useCallback(() => { From 92a224c6746b2b6cd2230ff2ebe123b8e60c7242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 15:06:20 +0200 Subject: [PATCH 23/73] Change handlePaste logic to fix newlines in pasted text --- src/MarkdownTextInput.web.tsx | 36 ++++++++++++++++++++++------------- src/web/utils/inputUtils.ts | 10 ---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 7c2d7740..bd2e14d2 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -260,9 +260,14 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } + const nativeEvent = e.nativeEvent as MarkdownNativeEvent; - const parsedText = parseInnerHTMLToText(e.target); - divRef.current.value = parsedText; + let parsedText = ''; + if (nativeEvent.inputType === 'pasteText') { + parsedText = divRef.current.value; + } else { + parsedText = parseInnerHTMLToText(e.target); + } const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; @@ -274,7 +279,6 @@ const MarkdownTextInput = React.forwardRef( } let text = ''; - const nativeEvent = e.nativeEvent as MarkdownNativeEvent; switch (nativeEvent.inputType) { case 'historyUndo': text = undo(divRef.current); @@ -458,17 +462,23 @@ const MarkdownTextInput = React.forwardRef( [handleCopy], ); - const handlePaste = useCallback((e) => { - pasteRef.current = true; - e.preventDefault(); + const handlePaste = useCallback( + (e) => { + if (!divRef.current || !contentSelection.current) { + return; + } + pasteRef.current = true; + e.preventDefault(); - const clipboardData = e.clipboardData; - const text = clipboardData.getData('text/plain'); - const span = document.createElement('span'); - span.setAttribute('data-type', 'paste'); - span.textContent = text; - document.execCommand('insertHTML', false, span.outerHTML); - }, []); + const clipboardData = e.clipboardData; + const text = clipboardData.getData('text/plain'); + divRef.current.value = divRef.current?.value.substring(0, contentSelection.current?.start) + text + divRef.current?.value.substring(contentSelection.current?.end); + e.nativeEvent.inputType = 'pasteText'; + handleOnChangeText(e); + setCursorPosition(divRef.current, contentSelection.current.start + text.length); + }, + [handleOnChangeText], + ); const startComposition = useCallback(() => { compositionRef.current = true; diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 84a38455..3dcffd1e 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -47,16 +47,6 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { let nodeText = nodeCopy.textContent ?? ''; - // In case text was pasted into a new empty line, remove the line break - if ( - nodeCopy.children.length > 0 && - nodeCopy.children[0]?.getAttribute('data-type') === 'br' && - nodeCopy.children[0].children.length > 0 && - nodeCopy.children[0].children[0]?.getAttribute('data-type') === 'paste' - ) { - nodeText = nodeText.slice(0, -1); - } - // Remove unnecessary new lines from the end of the text if (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') { nodeText = nodeText.slice(0, -1); From c14add4cd493ff43e8a04e49b3edc41cff5c9b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 15:34:36 +0200 Subject: [PATCH 24/73] Fix cursor positioning when undoing/redoing previously pasted text --- src/MarkdownTextInput.web.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index bd2e14d2..97e74e4f 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -93,7 +93,6 @@ const MarkdownTextInput = React.forwardRef( ref, ) => { const compositionRef = useRef(false); - const pasteRef = useRef(false); const divRef = useRef(null); const currentlyFocusedField = useRef(null); const contentSelection = useRef(null); @@ -260,10 +259,13 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } + updateTextColor(divRef.current, e.target.textContent ?? ''); + const nativeEvent = e.nativeEvent as MarkdownNativeEvent; + const isPasteInputType = nativeEvent.inputType === 'pasteText'; let parsedText = ''; - if (nativeEvent.inputType === 'pasteText') { + if (isPasteInputType) { parsedText = divRef.current.value; } else { parsedText = parseInnerHTMLToText(e.target); @@ -287,14 +289,8 @@ const MarkdownTextInput = React.forwardRef( text = redo(divRef.current); break; default: - text = parseText(divRef.current, parsedText, processedMarkdownStyle).text; - } - - if (pasteRef?.current) { - pasteRef.current = false; - updateSelection(e); + text = parseText(divRef.current, parsedText, processedMarkdownStyle, nativeEvent.inputType === 'pasteText' ? contentSelection.current?.start : null).text; } - updateTextColor(divRef.current, text); if (onChange) { const event = e as unknown as NativeSyntheticEvent; @@ -308,7 +304,7 @@ const MarkdownTextInput = React.forwardRef( handleContentSizeChange(); }, - [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, updateSelection, setEventProps], + [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps], ); const handleKeyPress = useCallback( @@ -467,15 +463,13 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !contentSelection.current) { return; } - pasteRef.current = true; e.preventDefault(); - const clipboardData = e.clipboardData; const text = clipboardData.getData('text/plain'); divRef.current.value = divRef.current?.value.substring(0, contentSelection.current?.start) + text + divRef.current?.value.substring(contentSelection.current?.end); e.nativeEvent.inputType = 'pasteText'; + contentSelection.current = {start: contentSelection.current.start + text.length, end: contentSelection.current.start + text.length}; handleOnChangeText(e); - setCursorPosition(divRef.current, contentSelection.current.start + text.length); }, [handleOnChangeText], ); From e9d8ec3ae904cc6e2f40b8d25938f9b449ea9aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 2 Jul 2024 15:37:09 +0200 Subject: [PATCH 25/73] Fix text coloring --- src/MarkdownTextInput.web.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 97e74e4f..15e43a74 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -259,8 +259,6 @@ const MarkdownTextInput = React.forwardRef( if (!divRef.current || !(e.target instanceof HTMLElement)) { return; } - updateTextColor(divRef.current, e.target.textContent ?? ''); - const nativeEvent = e.nativeEvent as MarkdownNativeEvent; const isPasteInputType = nativeEvent.inputType === 'pasteText'; @@ -292,6 +290,8 @@ const MarkdownTextInput = React.forwardRef( text = parseText(divRef.current, parsedText, processedMarkdownStyle, nativeEvent.inputType === 'pasteText' ? contentSelection.current?.start : null).text; } + updateTextColor(divRef.current, text); + if (onChange) { const event = e as unknown as NativeSyntheticEvent; setEventProps(event); From e24d25941f3ecbd95e86c7fd2412724a910346cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 12:01:35 +0200 Subject: [PATCH 26/73] Add review changes --- src/MarkdownTextInput.web.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 15e43a74..3ef365cb 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -262,13 +262,7 @@ const MarkdownTextInput = React.forwardRef( const nativeEvent = e.nativeEvent as MarkdownNativeEvent; const isPasteInputType = nativeEvent.inputType === 'pasteText'; - let parsedText = ''; - if (isPasteInputType) { - parsedText = divRef.current.value; - } else { - parsedText = parseInnerHTMLToText(e.target); - } - + const parsedText = isPasteInputType ? divRef.current.value : parseInnerHTMLToText(e.target); const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; From 092775dbde9aa52dad94b5e0da87d127262c3a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 13:32:12 +0200 Subject: [PATCH 27/73] Move updateTextColor function --- src/MarkdownTextInput.web.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 3ef365cb..1444d9b5 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -263,11 +263,12 @@ const MarkdownTextInput = React.forwardRef( const isPasteInputType = nativeEvent.inputType === 'pasteText'; const parsedText = isPasteInputType ? divRef.current.value : parseInnerHTMLToText(e.target); + updateTextColor(divRef.current, parsedText); + const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; if (compositionRef.current && !BrowserUtils.isMobile) { - updateTextColor(divRef.current, parsedText); compositionRef.current = false; return; } @@ -284,8 +285,6 @@ const MarkdownTextInput = React.forwardRef( text = parseText(divRef.current, parsedText, processedMarkdownStyle, nativeEvent.inputType === 'pasteText' ? contentSelection.current?.start : null).text; } - updateTextColor(divRef.current, text); - if (onChange) { const event = e as unknown as NativeSyntheticEvent; setEventProps(event); From a0c1dfa9c4b8648b06d20ab5b3f6d4ffe67fd445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 15:01:36 +0200 Subject: [PATCH 28/73] Fix cursor positioning when changing text and styles at the same time --- src/MarkdownTextInput.web.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 1444d9b5..0f9db879 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -284,6 +284,7 @@ const MarkdownTextInput = React.forwardRef( default: text = parseText(divRef.current, parsedText, processedMarkdownStyle, nativeEvent.inputType === 'pasteText' ? contentSelection.current?.start : null).text; } + divRef.current.value = text; if (onChange) { const event = e as unknown as NativeSyntheticEvent; From d04ad32d546257eec36a17e0ba5986e4fa2bffae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 15:50:05 +0200 Subject: [PATCH 29/73] Fix newlines on FireFox --- src/MarkdownTextInput.web.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 0f9db879..545e4994 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -301,6 +301,20 @@ const MarkdownTextInput = React.forwardRef( [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps], ); + const insertText = useCallback( + (e: SyntheticEvent, text: string) => { + if (!contentSelection.current || !divRef.current) { + return; + } + + divRef.current.value = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; + (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; + contentSelection.current = {start: contentSelection.current.start + text.length, end: contentSelection.current.start + text.length}; + handleOnChangeText(e); + }, + [handleOnChangeText], + ); + const handleKeyPress = useCallback( (e: KeyboardEvent) => { if (!divRef.current) { @@ -350,17 +364,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'); - if (contentSelection.current) { - setCursorPosition(divRef.current, contentSelection.current?.start + 1); - } + 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( @@ -460,12 +471,9 @@ const MarkdownTextInput = React.forwardRef( e.preventDefault(); const clipboardData = e.clipboardData; const text = clipboardData.getData('text/plain'); - divRef.current.value = divRef.current?.value.substring(0, contentSelection.current?.start) + text + divRef.current?.value.substring(contentSelection.current?.end); - e.nativeEvent.inputType = 'pasteText'; - contentSelection.current = {start: contentSelection.current.start + text.length, end: contentSelection.current.start + text.length}; - handleOnChangeText(e); + insertText(e, text); }, - [handleOnChangeText], + [insertText], ); const startComposition = useCallback(() => { From 63de14897d8a53851f1255c4004c69a53b906890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 16:00:20 +0200 Subject: [PATCH 30/73] Fix cursor position value update when entering newline inside codeblock --- src/MarkdownTextInput.web.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 545e4994..c7918d78 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -282,7 +282,7 @@ const MarkdownTextInput = React.forwardRef( text = redo(divRef.current); break; default: - text = parseText(divRef.current, parsedText, processedMarkdownStyle, nativeEvent.inputType === 'pasteText' ? contentSelection.current?.start : null).text; + text = parseText(divRef.current, parsedText, processedMarkdownStyle).text; } divRef.current.value = text; @@ -309,8 +309,8 @@ const MarkdownTextInput = React.forwardRef( divRef.current.value = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; - contentSelection.current = {start: contentSelection.current.start + text.length, end: contentSelection.current.start + text.length}; handleOnChangeText(e); + setCursorPosition(divRef.current, contentSelection.current.start + text.length, null); }, [handleOnChangeText], ); From f06d10f0db4f007b418afc6aaf4eff2809525ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 16:31:15 +0200 Subject: [PATCH 31/73] Fix removing characters when cursor is at the beginning of the line on Firefox --- src/MarkdownTextInput.web.tsx | 12 +++++++++--- src/web/utils/parserUtils.ts | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index c7918d78..2b0626c2 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -256,14 +256,13 @@ 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 isPasteInputType = nativeEvent.inputType === 'pasteText'; const parsedText = isPasteInputType ? divRef.current.value : parseInnerHTMLToText(e.target); - updateTextColor(divRef.current, parsedText); const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; @@ -282,8 +281,15 @@ const MarkdownTextInput = React.forwardRef( text = redo(divRef.current); break; default: - text = parseText(divRef.current, parsedText, processedMarkdownStyle).text; + text = parseText( + divRef.current, + parsedText, + processedMarkdownStyle, + nativeEvent.inputType === 'deleteContentBackward' && contentSelection.current?.start === contentSelection.current?.end ? Math.max(contentSelection.current.start - 1, 0) : null, + ).text; } + + updateTextColor(divRef.current, text); divRef.current.value = text; if (onChange) { diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 9191d3c1..babc649c 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -258,7 +258,7 @@ function updateInputStructure( 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; + let cursorPosition: number | null = cursorPositionIndex !== null && cursorPositionIndex <= text.length ? cursorPositionIndex : null; const isFocused = document.activeElement === target; if (isFocused && cursorPositionIndex === null) { const selection = getCurrentCursorPosition(target); @@ -267,7 +267,6 @@ function updateInputStructure( const ranges = global.parseExpensiMarkToRanges(text); const markdownRanges: MarkdownRange[] = ranges as MarkdownRange[]; let tree: TreeNode | null = null; - if (!text || targetElement.innerHTML === '
' || (targetElement && targetElement.innerHTML === '\n')) { targetElement.innerHTML = ''; targetElement.innerText = ''; From 34eabe29c91712889c5d2e43d4d745f44d0c0d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 17:27:56 +0200 Subject: [PATCH 32/73] Fix writing in empty line on Firefox --- src/web/utils/inputUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 3dcffd1e..394e8318 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -1,4 +1,5 @@ import type {CSSProperties} from 'react'; +import BrowserUtils from './browserUtils'; const ZERO_WIDTH_SPACE = '\u200B'; @@ -48,7 +49,10 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { let nodeText = nodeCopy.textContent ?? ''; // Remove unnecessary new lines from the end of the text - if (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') { + if ( + (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') || + (BrowserUtils.isFirefox && nodeCopy.children?.[0]?.getAttribute('data-type') === 'br' && (nodeCopy.children?.[0]?.textContent?.length || -1) > 1) + ) { nodeText = nodeText.slice(0, -1); } From a52e3f5a83fbf99881a76a5469ac7acce838de68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 3 Jul 2024 17:44:17 +0200 Subject: [PATCH 33/73] Fix cursor position value on Cmd+A on FireFox --- src/web/utils/cursorUtils.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index aa545f58..386505e7 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -38,7 +38,7 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - startTreeNode.element.scrollIntoView(); + startTreeNode.element.scrollIntoView({block: 'nearest'}); } function moveCursorToEnd(target: HTMLElement) { @@ -75,7 +75,13 @@ function getCurrentCursorPosition(target: MarkdownTextInputElement) { let end = -1; if (startTreeNode && endTreeNode) { start = startTreeNode.start + range.startOffset; - end = endTreeNode.start + range.endOffset; + + // 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}; } From b5515cf85a5aaf06a95fd7d6353b9d52740a1d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 10:00:22 +0200 Subject: [PATCH 34/73] Fix getting value in e2e tests --- WebExample/__tests__/input.spec.ts | 5 +++-- WebExample/__tests__/utils.ts | 22 ++++++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts index f3accc26..1676c4e1 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 {checkCursorPosition, getElementValue, setupInput} from './utils'; test.beforeEach(async ({page}) => { await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); @@ -12,7 +12,8 @@ test.describe('typing', () => { await inputLocator.focus(); await inputLocator.pressSequentially(TEST_CONST.EXAMPLE_CONTENT); - const value = await inputLocator.innerText(); + + const value = await getElementValue(inputLocator); expect(value).toEqual(TEST_CONST.EXAMPLE_CONTENT); }); diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index 8b588348..4ffc599d 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -1,5 +1,7 @@ import type {Locator, Page} from '@playwright/test'; import * as TEST_CONST from '../../example/src/testConstants'; +import type {MarkdownTextInputElement} from '../../src/MarkdownTextInput.web'; +import {getCurrentCursorPosition} from '../../src/web/utils/cursorUtils'; const setupInput = async (page: Page, action?: 'clear' | 'reset') => { const inputLocator = await page.locator(`div#${TEST_CONST.INPUT_ID}`); @@ -11,15 +13,9 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { }; 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 editableDiv = document.querySelector('div[contenteditable="true"]') as MarkdownTextInputElement; + + return getCurrentCursorPosition(editableDiv); }; const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => { @@ -55,4 +51,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 customVariableValue = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.value); + const value = await customVariableValue.jsonValue(); + return value; +}; + +export {setupInput, checkCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue}; From 5a043a32a96c98728904b6b48075512265de2315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 12:18:53 +0200 Subject: [PATCH 35/73] Fix input e2e tests --- WebExample/__tests__/input.spec.ts | 7 +++---- WebExample/__tests__/utils.ts | 13 ++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts index 1676c4e1..25b5ef53 100644 --- a/WebExample/__tests__/input.spec.ts +++ b/WebExample/__tests__/input.spec.ts @@ -13,8 +13,7 @@ test.describe('typing', () => { await inputLocator.focus(); await inputLocator.pressSequentially(TEST_CONST.EXAMPLE_CONTENT); - const value = await getElementValue(inputLocator); - expect(value).toEqual(TEST_CONST.EXAMPLE_CONTENT); + expect(await getElementValue(inputLocator)).toEqual(TEST_CONST.EXAMPLE_CONTENT); }); test('fast type cursor position', async ({page}) => { @@ -24,9 +23,9 @@ 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 checkCursorPosition(inputLocator); expect(cursorPosition).toBe(EXAMPLE_LONG_CONTENT.length); }); diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index 4ffc599d..144e376c 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -1,7 +1,6 @@ import type {Locator, Page} from '@playwright/test'; import * as TEST_CONST from '../../example/src/testConstants'; import type {MarkdownTextInputElement} from '../../src/MarkdownTextInput.web'; -import {getCurrentCursorPosition} from '../../src/web/utils/cursorUtils'; const setupInput = async (page: Page, action?: 'clear' | 'reset') => { const inputLocator = await page.locator(`div#${TEST_CONST.INPUT_ID}`); @@ -12,10 +11,10 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { return inputLocator; }; -const checkCursorPosition = () => { - const editableDiv = document.querySelector('div[contenteditable="true"]') as MarkdownTextInputElement; - - return getCurrentCursorPosition(editableDiv); +const checkCursorPosition = async (elementHandle: Locator) => { + const inputTreeHanlde = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.selectionStart); + const tree = await inputTreeHanlde.jsonValue(); + return tree; }; const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => { @@ -52,8 +51,8 @@ const pressCmd = async ({inputLocator, command}: {inputLocator: Locator; command }; const getElementValue = async (elementHandle: Locator) => { - const customVariableValue = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.value); - const value = await customVariableValue.jsonValue(); + const inputValueHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.value); + const value = await inputValueHandle.jsonValue(); return value; }; From 5a1dae2fea2640344f369dee7cfa5f4a93a9deba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 12:46:25 +0200 Subject: [PATCH 36/73] Fix style e2e tests --- WebExample/__tests__/utils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index 144e376c..46c64270 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -38,8 +38,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; }; From 60f4f4b04bed4302338910c9b5d0f5a0e05fb22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 14:11:14 +0200 Subject: [PATCH 37/73] Fix text manipulation e2e tests --- WebExample/__tests__/textManipulation.spec.ts | 31 ++++++++++--------- WebExample/__tests__/utils.ts | 6 ++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts index 5c83e3b4..369aa037 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 {checkCursorPosition, 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}) => { @@ -64,7 +64,7 @@ test.describe('paste content', () => { await pressCmd({inputLocator, command: 'z'}); - expect(await inputLocator.innerText()).toBe(PASTE_TEXT_FIRST); + expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST); }); test('paste redo', async ({page}) => { @@ -84,7 +84,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,7 +93,7 @@ test('select all', async ({page}) => { await inputLocator.focus(); await pressCmd({inputLocator, command: 'a'}); - const cursorPosition = await page.evaluate(checkCursorPosition); + const cursorPosition = await checkCursorPosition(inputLocator); expect(cursorPosition).toBe(TEST_CONST.EXAMPLE_CONTENT.length); }); @@ -107,15 +107,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 +123,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 46c64270..a5c42a8e 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -12,9 +12,9 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { }; const checkCursorPosition = async (elementHandle: Locator) => { - const inputTreeHanlde = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.selectionStart); - const tree = await inputTreeHanlde.jsonValue(); - return tree; + const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.selectionEnd); + const selection = await inputSelectionHandle.jsonValue(); + return selection; }; const setCursorPosition = ({startNode, endNode}: {startNode?: Element; endNode?: Element | null}) => { From 55e78b49edeaee363a385c051bf78c1efadc3d87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 16:01:45 +0200 Subject: [PATCH 38/73] Update checkCursorPosition function in e2e tests --- WebExample/__tests__/input.spec.ts | 6 +++--- WebExample/__tests__/textManipulation.spec.ts | 6 +++--- WebExample/__tests__/utils.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts index 25b5ef53..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, getElementValue, setupInput} from './utils'; +import {getCursorPosition, getElementValue, setupInput} from './utils'; test.beforeEach(async ({page}) => { await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); @@ -25,8 +25,8 @@ test.describe('typing', () => { expect(await getElementValue(inputLocator)).toBe(EXAMPLE_LONG_CONTENT); - const cursorPosition = await checkCursorPosition(inputLocator); + 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 369aa037..fa970c92 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, getElementValue} 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); @@ -93,9 +93,9 @@ test('select all', async ({page}) => { await inputLocator.focus(); await pressCmd({inputLocator, command: 'a'}); - const cursorPosition = await checkCursorPosition(inputLocator); + 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}) => { diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index a5c42a8e..f93b233d 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -11,8 +11,8 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { return inputLocator; }; -const checkCursorPosition = async (elementHandle: Locator) => { - const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.selectionEnd); +const getCursorPosition = async (elementHandle: Locator) => { + const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => ({start: div.selectionStart, end: div.selectionEnd})); const selection = await inputSelectionHandle.jsonValue(); return selection; }; @@ -59,4 +59,4 @@ const getElementValue = async (elementHandle: Locator) => { return value; }; -export {setupInput, checkCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue}; +export {setupInput, getCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue}; From 8a0a7beb774f10eab289c5e3b6814971b4aa8b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 4 Jul 2024 16:01:45 +0200 Subject: [PATCH 39/73] Update checkCursorPosition function in e2e tests --- WebExample/__tests__/input.spec.ts | 6 +++--- WebExample/__tests__/textManipulation.spec.ts | 6 +++--- WebExample/__tests__/utils.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/WebExample/__tests__/input.spec.ts b/WebExample/__tests__/input.spec.ts index 25b5ef53..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, getElementValue, setupInput} from './utils'; +import {getCursorPosition, getElementValue, setupInput} from './utils'; test.beforeEach(async ({page}) => { await page.goto(TEST_CONST.LOCAL_URL, {waitUntil: 'load'}); @@ -25,8 +25,8 @@ test.describe('typing', () => { expect(await getElementValue(inputLocator)).toBe(EXAMPLE_LONG_CONTENT); - const cursorPosition = await checkCursorPosition(inputLocator); + 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 369aa037..fa970c92 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, getElementValue} 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); @@ -93,9 +93,9 @@ test('select all', async ({page}) => { await inputLocator.focus(); await pressCmd({inputLocator, command: 'a'}); - const cursorPosition = await checkCursorPosition(inputLocator); + 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}) => { diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index a5c42a8e..f93b233d 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -11,8 +11,8 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { return inputLocator; }; -const checkCursorPosition = async (elementHandle: Locator) => { - const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.selectionEnd); +const getCursorPosition = async (elementHandle: Locator) => { + const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => ({start: div.selectionStart, end: div.selectionEnd})); const selection = await inputSelectionHandle.jsonValue(); return selection; }; @@ -59,4 +59,4 @@ const getElementValue = async (elementHandle: Locator) => { return value; }; -export {setupInput, checkCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue}; +export {setupInput, getCursorPosition, setCursorPosition, getElementStyle, pressCmd, getElementValue}; From 96a9b5c2fc5181adfdf9fcba2e4a15c2331183b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 08:13:41 +0200 Subject: [PATCH 40/73] Fix cursor position after redoing pasted text --- src/MarkdownTextInput.web.tsx | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 2b0626c2..6ec28715 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -135,7 +135,6 @@ const MarkdownTextInput = React.forwardRef( const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline); 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(parsedText.text, parsedText.cursorPosition); } @@ -272,6 +271,7 @@ const MarkdownTextInput = React.forwardRef( return; } + let cursorPosition: number | null = null; let text = ''; switch (nativeEvent.inputType) { case 'historyUndo': @@ -281,12 +281,13 @@ const MarkdownTextInput = React.forwardRef( text = redo(divRef.current); break; default: - text = parseText( - divRef.current, - parsedText, - processedMarkdownStyle, - nativeEvent.inputType === 'deleteContentBackward' && contentSelection.current?.start === contentSelection.current?.end ? Math.max(contentSelection.current.start - 1, 0) : null, - ).text; + if (nativeEvent.inputType === 'deleteContentBackward' && contentSelection.current?.start === contentSelection.current?.end) { + cursorPosition = Math.max(contentSelection.current.start - 1, 0); + } else if (isPasteInputType) { + cursorPosition = divRef.current.selectionStart; + } + + text = parseText(divRef.current, parsedText, processedMarkdownStyle, cursorPosition).text; } updateTextColor(divRef.current, text); @@ -315,10 +316,14 @@ const MarkdownTextInput = React.forwardRef( divRef.current.value = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; + const cursorPositionAfterPaste = contentSelection.current.start + text.length; + updateRefSelectionVariables({ + start: cursorPositionAfterPaste, + end: cursorPositionAfterPaste, + }); handleOnChangeText(e); - setCursorPosition(divRef.current, contentSelection.current.start + text.length, null); }, - [handleOnChangeText], + [handleOnChangeText, updateRefSelectionVariables], ); const handleKeyPress = useCallback( From 862e74ad130c01edd5aabd5706f482542d3e6ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 09:14:08 +0200 Subject: [PATCH 41/73] Fix e2e tests on CI/CD --- WebExample/__tests__/textManipulation.spec.ts | 33 ++++++++++--------- src/MarkdownTextInput.web.tsx | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts index fa970c92..76b74664 100644 --- a/WebExample/__tests__/textManipulation.spec.ts +++ b/WebExample/__tests__/textManipulation.spec.ts @@ -46,26 +46,29 @@ test.describe('paste content', () => { expect(await getElementValue(inputLocator)).toBe(newText); }); - test('paste undo', async ({page, browserName}) => { - test.skip(!!process.env.CI && browserName === 'firefox', 'Excluded from Firefox CI tests'); + // test('paste undo', async ({page, browserName}) => { + // test.skip(!!process.env.CI && browserName === 'firefox', 'Excluded from Firefox CI tests'); - const PASTE_TEXT_FIRST = '*bold*'; - const PASTE_TEXT_SECOND = '@here'; - - const inputLocator = await setupInput(page, 'clear'); + // const PASTE_TEXT_FIRST = '*bold*'; + // const PASTE_TEXT_SECOND = '@here'; - await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); + // const inputLocator = await setupInput(page, 'clear'); - await pressCmd({inputLocator, command: 'v'}); - await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); - 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 page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); - await pressCmd({inputLocator, command: 'z'}); + // await pressCmd({inputLocator, command: 'v'}); + // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + // 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); + // console.log(await getElementValue(inputLocator)); + // await pressCmd({inputLocator, command: 'z'}); + // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + // console.log(await getElementValue(inputLocator), await inputLocator.innerText()); + // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); - expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST); - }); + // expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST); + // }); test('paste redo', async ({page}) => { const PASTE_TEXT_FIRST = '*bold*'; diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 6ec28715..b961621d 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -133,6 +133,7 @@ const MarkdownTextInput = React.forwardRef( return {text: divRef.current.value, cursorPosition: null}; } const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline); + divRef.current.value = parsedText.text; if (history.current && shouldAddToHistory) { history.current.throttledAdd(parsedText.text, parsedText.cursorPosition); @@ -291,7 +292,6 @@ const MarkdownTextInput = React.forwardRef( } updateTextColor(divRef.current, text); - divRef.current.value = text; if (onChange) { const event = e as unknown as NativeSyntheticEvent; From 0580f45add9d1b8d5f6b81d62f766316c1707f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 09:22:26 +0200 Subject: [PATCH 42/73] Uncomment undo test --- WebExample/__tests__/textManipulation.spec.ts | 34 ++++++++----------- src/MarkdownTextInput.web.tsx | 2 +- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/WebExample/__tests__/textManipulation.spec.ts b/WebExample/__tests__/textManipulation.spec.ts index 76b74664..b789bd47 100644 --- a/WebExample/__tests__/textManipulation.spec.ts +++ b/WebExample/__tests__/textManipulation.spec.ts @@ -46,29 +46,25 @@ test.describe('paste content', () => { expect(await getElementValue(inputLocator)).toBe(newText); }); - // test('paste undo', async ({page, browserName}) => { - // test.skip(!!process.env.CI && browserName === 'firefox', 'Excluded from Firefox CI tests'); + test('paste undo', async ({page, browserName}) => { + test.skip(!!process.env.CI && browserName === 'firefox', 'Excluded from Firefox CI tests'); - // const PASTE_TEXT_FIRST = '*bold*'; - // const PASTE_TEXT_SECOND = '@here'; - - // const inputLocator = await setupInput(page, 'clear'); + const PASTE_TEXT_FIRST = '*bold*'; + const PASTE_TEXT_SECOND = '@here'; - // await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); + const inputLocator = await setupInput(page, 'clear'); - // await pressCmd({inputLocator, command: 'v'}); - // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); - // 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); - // console.log(await getElementValue(inputLocator)); - // await pressCmd({inputLocator, command: 'z'}); - // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); - // console.log(await getElementValue(inputLocator), await inputLocator.innerText()); - // await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + await page.evaluate(async (pasteText) => navigator.clipboard.writeText(pasteText), PASTE_TEXT_FIRST); - // expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST); - // }); + await pressCmd({inputLocator, command: 'v'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + 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'}); + await page.waitForTimeout(TEST_CONST.INPUT_HISTORY_DEBOUNCE_TIME_MS); + expect(await getElementValue(inputLocator)).toBe(PASTE_TEXT_FIRST); + }); test('paste redo', async ({page}) => { const PASTE_TEXT_FIRST = '*bold*'; diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index b961621d..28c9b2d0 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -335,7 +335,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) { From 6682c9d570202539249675a7b7f6416c3bdb9950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 09:37:56 +0200 Subject: [PATCH 43/73] Fix TS errors --- WebExample/__tests__/utils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/WebExample/__tests__/utils.ts b/WebExample/__tests__/utils.ts index f93b233d..2085afad 100644 --- a/WebExample/__tests__/utils.ts +++ b/WebExample/__tests__/utils.ts @@ -1,6 +1,5 @@ import type {Locator, Page} from '@playwright/test'; import * as TEST_CONST from '../../example/src/testConstants'; -import type {MarkdownTextInputElement} from '../../src/MarkdownTextInput.web'; const setupInput = async (page: Page, action?: 'clear' | 'reset') => { const inputLocator = await page.locator(`div#${TEST_CONST.INPUT_ID}`); @@ -12,7 +11,7 @@ const setupInput = async (page: Page, action?: 'clear' | 'reset') => { }; const getCursorPosition = async (elementHandle: Locator) => { - const inputSelectionHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => ({start: div.selectionStart, end: div.selectionEnd})); + const inputSelectionHandle = await elementHandle.evaluateHandle((div: HTMLInputElement) => ({start: div.selectionStart, end: div.selectionEnd})); const selection = await inputSelectionHandle.jsonValue(); return selection; }; @@ -54,7 +53,7 @@ const pressCmd = async ({inputLocator, command}: {inputLocator: Locator; command }; const getElementValue = async (elementHandle: Locator) => { - const inputValueHandle = await elementHandle.evaluateHandle((div: MarkdownTextInputElement) => div.value); + const inputValueHandle = await elementHandle.evaluateHandle((div: HTMLInputElement) => div.value); const value = await inputValueHandle.jsonValue(); return value; }; From 8165fb92715d9b5a1110850cffa3ea24bdef961b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 12:06:03 +0200 Subject: [PATCH 44/73] Change line merging funciton --- src/web/utils/parserUtils.ts | 65 ++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index babc649c..29a425a9 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -52,44 +52,36 @@ function splitTextIntoLines(text: string): Paragraph[] { return lines; } -function mergeLinesWithMultilineTags(lines: Paragraph[]) { - let multiLineRange: MarkdownRange | null = null; - let lineWithMultilineTag: Paragraph | null = null; - let i = 0; - while (i < lines.length) { - const currentLine = lines[i]; - if (!currentLine) { - break; - } - // start merging if line contains range that ends in a different line - if (lineWithMultilineTag && multiLineRange && currentLine.start <= multiLineRange.start + multiLineRange.length) { - lineWithMultilineTag.text += `\n${currentLine.text}`; - lineWithMultilineTag.markdownRanges.push(...currentLine.markdownRanges); - lineWithMultilineTag.length += currentLine.length + 1; - lines.splice(i, 1); - } else { - multiLineRange = currentLine.markdownRanges.find((range) => range.start + range.length > currentLine.start + currentLine.length) || null; - lineWithMultilineTag = multiLineRange ? currentLine : null; - i += 1; - } - } -} +function mergeLinesWithMultilineTags(lines: Paragraph[], ranges: MarkdownRange[]) { + let mergedLines = [...lines]; + const lineIndexes = mergedLines.map((_line, index) => index); -function groupMarkdownRangesByLine(lines: Paragraph[], ranges: MarkdownRange[]) { - let lineIndex = 0; ranges.forEach((range) => { - const {start} = range; - - let currentLine = lines[lineIndex]; - while (currentLine && lineIndex < lines.length && start > currentLine.start + currentLine.length) { - lineIndex += 1; - currentLine = lines[lineIndex]; - } - - if (currentLine) { - currentLine.markdownRanges.push(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; } function appendNode(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number) { @@ -156,7 +148,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS }; let currentParentNode: TreeNode = rootNode; - const lines = splitTextIntoLines(text); + let lines = splitTextIntoLines(text); if (ranges.length === 0) { lines.forEach((line) => { @@ -168,8 +160,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS const markdownRanges = ungroupRanges(ranges); - groupMarkdownRangesByLine(lines, markdownRanges); - mergeLinesWithMultilineTags(lines); + lines = mergeLinesWithMultilineTags(lines, markdownRanges); let lastRangeEndIndex = 0; while (lines.length > 0) { From 9e2b943f4d6f8ff63b90522570b408bde74675a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 5 Jul 2024 12:48:45 +0200 Subject: [PATCH 45/73] Fix selection event sending on paste --- src/MarkdownTextInput.web.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 28c9b2d0..11691450 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -322,8 +322,9 @@ const MarkdownTextInput = React.forwardRef( end: cursorPositionAfterPaste, }); handleOnChangeText(e); + updateSelection(e); }, - [handleOnChangeText, updateRefSelectionVariables], + [handleOnChangeText, updateRefSelectionVariables, updateSelection], ); const handleKeyPress = useCallback( From 5f369ec3d02e607c650341602f595bcb2fbbf0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 8 Jul 2024 13:07:37 +0200 Subject: [PATCH 46/73] Fix scrolling cursor into view on Safari browser --- src/web/utils/cursorUtils.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index 386505e7..ce3810de 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,5 +1,6 @@ import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; +import type {TreeNode} from './treeUtils'; function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { // We don't want to move the cursor if the target is not focused @@ -38,7 +39,20 @@ function setCursorPosition(target: MarkdownTextInputElement, start: number, end: selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } - startTreeNode.element.scrollIntoView({block: 'nearest'}); + 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) { @@ -93,4 +107,4 @@ function removeSelection() { } } -export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection}; +export {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition, removeSelection, scrollIntoView}; From 6bada780ce9262bd8982f034ab2614724c0b2b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 9 Jul 2024 12:02:17 +0200 Subject: [PATCH 47/73] Enhance cursor positioning on input --- src/MarkdownTextInput.web.tsx | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index ff2e1682..b277db95 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -98,7 +98,8 @@ const MarkdownTextInput = React.forwardRef( 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 || ''); @@ -262,7 +263,11 @@ const MarkdownTextInput = React.forwardRef( const nativeEvent = e.nativeEvent as MarkdownNativeEvent; const isPasteInputType = nativeEvent.inputType === 'pasteText'; - const parsedText = isPasteInputType ? divRef.current.value : parseInnerHTMLToText(e.target); + const previousText = divRef.current.value; + const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target); + if (pasteContent.current) { + pasteContent.current = null; + } const tree = buildTree(divRef.current, parsedText); divRef.current.tree = tree; @@ -272,7 +277,7 @@ const MarkdownTextInput = React.forwardRef( return; } - let cursorPosition: number | null = null; + const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); let text = ''; switch (nativeEvent.inputType) { case 'historyUndo': @@ -282,13 +287,7 @@ const MarkdownTextInput = React.forwardRef( text = redo(divRef.current); break; default: - if (nativeEvent.inputType === 'deleteContentBackward' && contentSelection.current?.start === contentSelection.current?.end) { - cursorPosition = Math.max(contentSelection.current.start - 1, 0); - } else if (isPasteInputType) { - cursorPosition = divRef.current.selectionStart; - } - - text = parseText(divRef.current, parsedText, processedMarkdownStyle, cursorPosition).text; + text = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition).text; } updateTextColor(divRef.current, text); @@ -314,17 +313,12 @@ const MarkdownTextInput = React.forwardRef( return; } - divRef.current.value = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; + pasteContent.current = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; - const cursorPositionAfterPaste = contentSelection.current.start + text.length; - updateRefSelectionVariables({ - start: cursorPositionAfterPaste, - end: cursorPositionAfterPaste, - }); handleOnChangeText(e); updateSelection(e); }, - [handleOnChangeText, updateRefSelectionVariables, updateSelection], + [handleOnChangeText, updateSelection], ); const handleKeyPress = useCallback( From 309bccf33b577b4bbab63684b86bd94998042ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 10 Jul 2024 10:48:22 +0200 Subject: [PATCH 48/73] Fix diacritics after CMD+A --- src/MarkdownTextInput.web.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 1c3e3231..bedf38b6 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -276,6 +276,8 @@ const MarkdownTextInput = React.forwardRef( const previousText = divRef.current.value; const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target); + updateTextColor(divRef.current, parsedText); + if (pasteContent.current) { pasteContent.current = null; } @@ -285,10 +287,12 @@ const MarkdownTextInput = React.forwardRef( const prevSelection = contentSelection.current ?? {start: 0, end: 0}; if (compositionRef.current && !BrowserUtils.isMobile) { + divRef.current.value = parsedText; compositionRef.current = false; return; } + const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); let newInputUpdate: ParseTextResult; const inputType = nativeEvent.inputType; switch (nativeEvent.inputType) { @@ -299,16 +303,10 @@ const MarkdownTextInput = React.forwardRef( newInputUpdate = redo(divRef.current); break; default: - newInputUpdate = parseText( - divRef.current, - parsedText, - processedMarkdownStyle, - Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0), - ); + newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition); } const {text, cursorPosition} = newInputUpdate; - updateTextColor(divRef.current, text); if (onChange) { const event = e as unknown as NativeSyntheticEvent<{ From 90d2ea35db450b1434ed4079071677487baa21b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 10 Jul 2024 11:23:03 +0200 Subject: [PATCH 49/73] Fix autocorrect cursor positioning --- src/MarkdownTextInput.web.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index bedf38b6..556f9bf2 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -305,8 +305,11 @@ const MarkdownTextInput = React.forwardRef( default: newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition); } - const {text, cursorPosition} = newInputUpdate; + updateSelection(e, { + start: cursorPosition ?? 0, + end: cursorPosition ?? 0, + }); if (onChange) { const event = e as unknown as NativeSyntheticEvent<{ @@ -351,7 +354,7 @@ const MarkdownTextInput = React.forwardRef( handleContentSizeChange(); }, - [updateTextColor, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps], + [updateTextColor, updateSelection, onChange, onChangeText, handleContentSizeChange, undo, redo, parseText, processedMarkdownStyle, setEventProps], ); const insertText = useCallback( From 111fd6b73ac3a12f53d53bfd50f271c1abc6f261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 10 Jul 2024 12:21:27 +0200 Subject: [PATCH 50/73] Fix deleting codeBlock lines with CMD+backspace --- src/web/utils/inputUtils.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 394e8318..4a5d46ae 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -37,21 +37,31 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { const childNodes = target.childNodes ?? []; childNodes.forEach((node, index) => { const nodeCopy = node.cloneNode(true) as HTMLElement; + let isIncorrectNewLineGenerated = false; if (nodeCopy.innerHTML) { // Replace single
created by contentEditable with '\n', to enable proper newline deletion on backspace, when next lines also have
tags if (nodeCopy.innerHTML === '
') { nodeCopy.innerHTML = '\n'; } + if (nodeCopy.innerHTML.includes('\n')) { + isIncorrectNewLineGenerated = true; + } // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); } let nodeText = nodeCopy.textContent ?? ''; - // Remove unnecessary new lines from the end of the text + // Remove unnecessary new lines from the end of the text in following cases: + // 1. '\n\n' is at the end of the line - it means that '\n' was added by the browser or by the user. We can delete it since we are adding new lines after each paragraph. + // 2. BR span contains text + BR - fix for writing in empty line on Firefox browser. + // 3. Last child is a
tag - it means that BR was added by the browser since our br are wrapped in span with data-type attribute. + // 4. innerHTML contains '\n' - it means that the '\n' was added by the browser since we are using only BR tags for new lines. if ( (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') || - (BrowserUtils.isFirefox && nodeCopy.children?.[0]?.getAttribute('data-type') === 'br' && (nodeCopy.children?.[0]?.textContent?.length || -1) > 1) + (BrowserUtils.isFirefox && nodeCopy.children?.[0]?.getAttribute('data-type') === 'br' && (nodeCopy.children?.[0]?.textContent?.length || -1) > 1) || + nodeCopy.childNodes[nodeCopy.childNodes.length - 1]?.nodeName === 'BR' || + isIncorrectNewLineGenerated ) { nodeText = nodeText.slice(0, -1); } @@ -71,6 +81,9 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { // Split paragraphs with new lines if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { text += '\n'; + } else if (index === childNodes.length - 1 && nodeText === '') { + // Remove unnecessary new line from the end of the text if the last line is empty + text = text.slice(0, -1); } }); return text; From d14f4ba2104a5ed757774680742937a812bf9855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 10 Jul 2024 13:22:03 +0200 Subject: [PATCH 51/73] Fix text color on undo/redo --- src/MarkdownTextInput.web.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 556f9bf2..84e45118 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -276,8 +276,8 @@ const MarkdownTextInput = React.forwardRef( const previousText = divRef.current.value; const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target); - updateTextColor(divRef.current, parsedText); + updateTextColor(divRef.current, parsedText); if (pasteContent.current) { pasteContent.current = null; } @@ -306,6 +306,7 @@ const MarkdownTextInput = React.forwardRef( newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition); } const {text, cursorPosition} = newInputUpdate; + updateTextColor(divRef.current, text); updateSelection(e, { start: cursorPosition ?? 0, end: cursorPosition ?? 0, From 82d58ca0e48f5afce61ad9e635c63269f5b71869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 10 Jul 2024 13:32:06 +0200 Subject: [PATCH 52/73] Fix removing last letter from the line --- src/MarkdownTextInput.web.tsx | 6 +++--- src/web/utils/inputUtils.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 84e45118..a453cd04 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -272,10 +272,11 @@ const MarkdownTextInput = React.forwardRef( return; } const nativeEvent = e.nativeEvent as MarkdownNativeEvent; - const isPasteInputType = nativeEvent.inputType === 'pasteText'; + const inputType = nativeEvent.inputType; + const isPasteInputType = inputType === 'pasteText'; const previousText = divRef.current.value; - const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target); + const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target, inputType); updateTextColor(divRef.current, parsedText); if (pasteContent.current) { @@ -294,7 +295,6 @@ const MarkdownTextInput = React.forwardRef( const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); let newInputUpdate: ParseTextResult; - const inputType = nativeEvent.inputType; switch (nativeEvent.inputType) { case 'historyUndo': newInputUpdate = undo(divRef.current); diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 4a5d46ae..62761698 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -32,7 +32,7 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL return styles.height ? `${styles.height}px` : 'auto'; } -const parseInnerHTMLToText = (target: HTMLElement): string => { +const parseInnerHTMLToText = (target: HTMLElement, inputType = 'insertText'): string => { let text = ''; const childNodes = target.childNodes ?? []; childNodes.forEach((node, index) => { @@ -81,8 +81,8 @@ const parseInnerHTMLToText = (target: HTMLElement): string => { // Split paragraphs with new lines if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { text += '\n'; - } else if (index === childNodes.length - 1 && nodeText === '') { - // Remove unnecessary new line from the end of the text if the last line is empty + } else if (index === childNodes.length - 1 && inputType === 'deleteSoftLineBackward' && nodeText === '') { + // Remove unnecessary '\n' from the end of the text if user deleted line using CMD + Backspace text = text.slice(0, -1); } }); From 2b9e56a89c4267efcbe45001861130f07f545210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 11 Jul 2024 11:46:31 +0200 Subject: [PATCH 53/73] Fix cursor position when replacing text with the same text --- src/web/utils/parserUtils.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 29a425a9..c8e2a994 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -1,4 +1,3 @@ -import BrowserUtils from './browserUtils'; import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import {addNodeToTree, buildTree} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; @@ -274,15 +273,9 @@ function updateInputStructure( tree = buildTree(targetElement, text); targetElement.tree = tree; - - if (BrowserUtils.isChromium) { - moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); - } } - if (!BrowserUtils.isChromium) { - moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); - } + moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); } return {text, cursorPosition: cursorPosition || 0}; From 5861a21c0ad17419d4cd0edd043720722ea7b405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 15 Jul 2024 10:19:54 +0200 Subject: [PATCH 54/73] Change parseInnerHTMLToText function --- src/MarkdownTextInput.web.tsx | 10 ++-- src/web/utils/inputUtils.ts | 109 +++++++++++++++++++--------------- src/web/utils/parserUtils.ts | 2 +- src/web/utils/treeUtils.ts | 4 +- 4 files changed, 70 insertions(+), 55 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index a453cd04..5eeec5d2 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -275,18 +275,17 @@ const MarkdownTextInput = React.forwardRef( const inputType = nativeEvent.inputType; const isPasteInputType = inputType === 'pasteText'; + updateTextColor(divRef.current, e.target.textContent ?? ''); + const tree = buildTree(divRef.current, divRef.current.value); + divRef.current.tree = tree; const previousText = divRef.current.value; - const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target, inputType); + const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement); - updateTextColor(divRef.current, parsedText); if (pasteContent.current) { pasteContent.current = null; } - const tree = buildTree(divRef.current, parsedText); - divRef.current.tree = tree; const prevSelection = contentSelection.current ?? {start: 0, end: 0}; - if (compositionRef.current && !BrowserUtils.isMobile) { divRef.current.value = parsedText; compositionRef.current = false; @@ -366,6 +365,7 @@ const MarkdownTextInput = React.forwardRef( pasteContent.current = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; (e.nativeEvent as MarkdownNativeEvent).inputType = 'pasteText'; + handleOnChangeText(e); updateSelection(e); }, diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 62761698..4a37279a 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -1,5 +1,6 @@ import type {CSSProperties} from 'react'; -import BrowserUtils from './browserUtils'; +import type {TreeNode} from './treeUtils'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; const ZERO_WIDTH_SPACE = '\u200B'; @@ -32,61 +33,75 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL return styles.height ? `${styles.height}px` : 'auto'; } -const parseInnerHTMLToText = (target: HTMLElement, inputType = 'insertText'): string => { - let text = ''; - const childNodes = target.childNodes ?? []; - childNodes.forEach((node, index) => { - const nodeCopy = node.cloneNode(true) as HTMLElement; - let isIncorrectNewLineGenerated = false; - if (nodeCopy.innerHTML) { - // Replace single
created by contentEditable with '\n', to enable proper newline deletion on backspace, when next lines also have
tags - if (nodeCopy.innerHTML === '
') { - nodeCopy.innerHTML = '\n'; - } - if (nodeCopy.innerHTML.includes('\n')) { - isIncorrectNewLineGenerated = true; +function parseInnerHTMLToText(target: MarkdownTextInputElement) { + function getParentType(node: TreeNode) { + let currentNode = node; + while (['text', 'br'].includes(currentNode.type)) { + if (currentNode.parentNode) { + currentNode = currentNode.parentNode; + } else { + return null; } - // Replace only br tags with data-id attribute, because we know that were created by the web parser. We need to ignore tags created by contentEditable div - nodeCopy.innerHTML = nodeCopy.innerHTML.replaceAll(/
/g, '\n'); } - let nodeText = nodeCopy.textContent ?? ''; + return currentNode.type; + } + + const root = target.tree; - // Remove unnecessary new lines from the end of the text in following cases: - // 1. '\n\n' is at the end of the line - it means that '\n' was added by the browser or by the user. We can delete it since we are adding new lines after each paragraph. - // 2. BR span contains text + BR - fix for writing in empty line on Firefox browser. - // 3. Last child is a
tag - it means that BR was added by the browser since our br are wrapped in span with data-type attribute. - // 4. innerHTML contains '\n' - it means that the '\n' was added by the browser since we are using only BR tags for new lines. - if ( - (nodeText.length > 2 && nodeText[-3] !== '\n' && nodeText.slice(-2) === '\n\n') || - (BrowserUtils.isFirefox && nodeCopy.children?.[0]?.getAttribute('data-type') === 'br' && (nodeCopy.children?.[0]?.textContent?.length || -1) > 1) || - nodeCopy.childNodes[nodeCopy.childNodes.length - 1]?.nodeName === 'BR' || - isIncorrectNewLineGenerated - ) { - nodeText = nodeText.slice(0, -1); + if (root.childNodes.length === 0 || (root.childNodes.length === 1 && root.childNodes?.[0]?.type === 'line')) { + return root.element.textContent ?? ''; + } + + const stack: TreeNode[] = [root]; + let text = ''; + let ShouldInsertNewlineAfterParagraph = false; + while (stack.length > 0) { + const node = stack.pop(); + if (!node) { + break; } - // Last line specific handling - if (index === childNodes.length - 1) { - if (nodeText === '\n\n') { - // New line creation - nodeText = '\n'; - } else if (nodeText === '\n') { - // New line deletion on backspace - nodeText = ''; - } + switch (node.type) { + case 'line': + if (ShouldInsertNewlineAfterParagraph) { + text += '\n'; + ShouldInsertNewlineAfterParagraph = false; + } + if (node.element.textContent !== '') { + ShouldInsertNewlineAfterParagraph = true; + } + break; + case 'br': + if (node.element.nodeName === 'BR') { + const parentType = getParentType(node); + if ((parentType === 'line' && node.parentNode?.element?.textContent === '') || parentType !== 'line') { + text += `\n`; + } + } else if (node.element?.textContent) { + text += node.element?.textContent; + } + break; + case 'text': + text += node.element.textContent; + break; + default: + break; } - text += nodeText; - // Split paragraphs with new lines - if (/[^\n]/.test(nodeText) && index < childNodes.length - 1) { - text += '\n'; - } else if (index === childNodes.length - 1 && inputType === 'deleteSoftLineBackward' && nodeText === '') { - // Remove unnecessary '\n' from the end of the text if user deleted line using CMD + Backspace - text = text.slice(0, -1); + let i = node.childNodes.length - 1; + while (i > -1) { + const child = node.childNodes[i]; + if (!child) { + break; + } + + stack.push(child); + i--; } - }); + } + return text; -}; +} export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText}; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index c8e2a994..7d7ca6d8 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -141,7 +141,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS length: textLength, parentNode: null, childNodes: [], - type: 'text', + type: 'root', orderIndex: '', isGeneratingNewline: false, }; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 85205f54..bf75fc38 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -1,6 +1,6 @@ import type {MarkdownRange, MarkdownType} from './parserUtils'; -type NodeType = MarkdownType | 'line' | 'text' | 'br'; +type NodeType = MarkdownType | 'line' | 'text' | 'br' | 'root'; type TreeNode = Omit & { element: HTMLElement; @@ -57,7 +57,7 @@ function buildTree(rootElement: HTMLElement, text: string) { childNodes: [], start: 0, length: text.replace(/\n/g, '\\n').length, - type: 'text', + type: 'root', orderIndex: '', isGeneratingNewline: false, }; From 68ae9bea5491107e1136152835cbfcda86e3e023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 15 Jul 2024 20:40:58 +0200 Subject: [PATCH 55/73] Fix input behavior when interracting with display: block element --- src/MarkdownTextInput.web.tsx | 6 +++- src/__tests__/webParser.test.tsx | 2 +- src/web/utils/parserUtils.ts | 58 +++++++++++++++++++------------- src/web/utils/treeUtils.ts | 15 +++++---- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 5eeec5d2..b44b0530 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -61,6 +61,10 @@ type MarkdownTextInputElement = HTMLDivElement & tree: TreeNode; }; +type HTMLMarkdownElement = HTMLElement & { + value: string; +}; + const MarkdownTextInput = React.forwardRef( ( { @@ -673,4 +677,4 @@ const styles = StyleSheet.create({ export default MarkdownTextInput; -export type {MarkdownTextInputProps, MarkdownTextInputElement}; +export type {MarkdownTextInputProps, MarkdownTextInputElement, HTMLMarkdownElement}; diff --git a/src/__tests__/webParser.test.tsx b/src/__tests__/webParser.test.tsx index 0f59c9d1..ffaa87f1 100644 --- a/src/__tests__/webParser.test.tsx +++ b/src/__tests__/webParser.test.tsx @@ -19,7 +19,7 @@ const toBeParsedAsHTML = function (actual: string, expectedHTML: string) { const ranges = global.parseExpensiMarkToRanges(actual); const markdownRanges = ranges as MarkdownRange[]; - const actualDOM = parseRangesToHTMLNodes(actual, markdownRanges, {}, true); + const actualDOM = parseRangesToHTMLNodes(actual, markdownRanges, {}, true).dom; const actualHTML = actualDOM.innerHTML; if (actualHTML === expected) { diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 7d7ca6d8..2fde2a6a 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -1,5 +1,5 @@ -import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; -import {addNodeToTree, buildTree} from './treeUtils'; +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'; @@ -83,17 +83,25 @@ function mergeLinesWithMultilineTags(lines: Paragraph[], ranges: MarkdownRange[] return mergedLines; } -function appendNode(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number) { +function appendValueToElement(element: HTMLMarkdownElement, parentNode: TreeNode, value: string) { + const targetElement = element; + const node = parentNode; + targetElement.value = value; + node.element.value = (node.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'); + 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'), spanNode, 'br', 1); + appendNode(document.createElement('br') as unknown as HTMLMarkdownElement, spanNode, 'br', 1); return spanNode; } @@ -101,7 +109,9 @@ function addTextToElement(node: TreeNode, text: string) { const lines = text.split('\n'); lines.forEach((line, index) => { if (line !== '') { - const span = document.createElement('span'); + 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); @@ -120,7 +130,11 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number addStyleToBlock(p, 'line', {}); } - const pNode = appendNode(p, node, 'line', length); + if (text) { + appendValueToElement(p as unknown as HTMLMarkdownElement, node, text); + } + + const pNode = appendNode(p as unknown as HTMLMarkdownElement, node, 'line', length); if (text === '') { addBrElement(pNode); @@ -132,9 +146,8 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number } function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false) { - const rootElement: HTMLElement = document.createElement('div'); + const rootElement: HTMLMarkdownElement = document.createElement('span') as HTMLMarkdownElement; const textLength = text.replace(/\n/g, '\\n').length; - const rootNode: TreeNode = { element: rootElement, start: 0, @@ -146,19 +159,16 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS 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 rootElement; + return {dom: rootElement, tree: rootNode}; } const markdownRanges = ungroupRanges(ranges); - lines = mergeLinesWithMultilineTags(lines, markdownRanges); let lastRangeEndIndex = 0; @@ -175,7 +185,6 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } lastRangeEndIndex = line.start; - const lineMarkdownRanges = line.markdownRanges; // go through all markdown ranges in the line while (lineMarkdownRanges.length > 0) { @@ -194,7 +203,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } // create markdown span element - const span = document.createElement('span'); + const span = document.createElement('span') as HTMLMarkdownElement; span.setAttribute('data-type', range.type); if (!disableInlineStyles) { addStyleToBlock(span, range.type, markdownStyle); @@ -209,6 +218,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } 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) { @@ -217,13 +227,16 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS addTextToElement(currentParentNode, textAfterRange); } lastRangeEndIndex = currentParentNode.start + currentParentNode.length; + currentParentNode.parentNode.element.value = currentParentNode.element.value || ''; currentParentNode = currentParentNode.parentNode || rootNode; } } } + + rootNode.element.value = (rootNode.element.value || '') + (currentParentNode.element.value || ''); } - return rootElement; + return {dom: rootElement, tree: rootNode}; } function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: MarkdownTextInputElement) { @@ -256,7 +269,6 @@ function updateInputStructure( } const ranges = global.parseExpensiMarkToRanges(text); const markdownRanges: MarkdownRange[] = ranges as MarkdownRange[]; - let tree: TreeNode | null = null; if (!text || targetElement.innerHTML === '
' || (targetElement && targetElement.innerHTML === '\n')) { targetElement.innerHTML = ''; targetElement.innerText = ''; @@ -264,18 +276,18 @@ function updateInputStructure( // We don't want to parse text with single '\n', because contentEditable represents it as invisible
if (text) { - const dom = parseRangesToHTMLNodes(text, markdownRanges, markdownStyle); + const {dom, tree} = parseRangesToHTMLNodes(text, markdownRanges, markdownStyle); if (targetElement.innerHTML !== dom.innerHTML) { targetElement.innerHTML = ''; targetElement.innerText = ''; - targetElement.innerHTML = dom.innerHTML || ''; - - tree = buildTree(targetElement, text); - targetElement.tree = tree; + Array.from(dom.children).forEach((child) => { + targetElement.appendChild(child); + }); } - moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, target); + targetElement.tree = tree; + moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, targetElement); } return {text, cursorPosition: cursorPosition || 0}; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index bf75fc38..d8631a48 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -1,9 +1,10 @@ +import type {HTMLMarkdownElement} from '../../MarkdownTextInput.web'; import type {MarkdownRange, MarkdownType} from './parserUtils'; type NodeType = MarkdownType | 'line' | 'text' | 'br' | 'root'; type TreeNode = Omit & { - element: HTMLElement; + element: HTMLMarkdownElement; parentNode: TreeNode | null; childNodes: TreeNode[]; type: NodeType; @@ -11,8 +12,8 @@ type TreeNode = Omit & { isGeneratingNewline: boolean; }; -function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: NodeType, length: number | null = null) { - const contentLength = length || (element.nodeName === 'BR' || type === 'br' ? 1 : element.innerText?.length) || 0; +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; @@ -20,7 +21,7 @@ function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: Nod const lastParentChild = parentTreeNode.childNodes[parentChildrenCount - 1]; if (lastParentChild) { startIndex = lastParentChild.start + lastParentChild.length; - startIndex += lastParentChild.isGeneratingNewline ? 1 : 0; + startIndex += lastParentChild.isGeneratingNewline || element.style.display === 'block' ? 1 : 0; } } @@ -40,7 +41,7 @@ function addNodeToTree(element: HTMLElement, parentTreeNode: TreeNode, type: Nod return item; } -function buildTree(rootElement: HTMLElement, text: string) { +function buildTree(rootElement: HTMLMarkdownElement, text: string) { function getElementType(element: HTMLElement): NodeType { if (element.nodeName === 'BR') { return 'br'; @@ -69,7 +70,7 @@ function buildTree(rootElement: HTMLElement, text: string) { } Array.from(treeNode.element.children).forEach((childElement) => { - const newTreeNode = addNodeToTree(childElement as HTMLElement, treeNode, getElementType(childElement as HTMLElement)); + const newTreeNode = addNodeToTree(childElement as HTMLMarkdownElement, treeNode, getElementType(childElement as HTMLMarkdownElement)); stack.push(newTreeNode); }); } @@ -120,7 +121,7 @@ function getTreeNodeByIndex(treeRoot: TreeNode, index: number): TreeNode | null } el = child; i = 0; - } else if ((child.isGeneratingNewline || newLineGenerated) && index === child.start + child.length) { + } else if ((child.isGeneratingNewline || newLineGenerated || i === el.childNodes.length - 1) && index === child.start + child.length) { newLineGenerated = true; if (child.childNodes.length === 0) { return child; From 0bc882a58c3067860741818ac924e70918e3ab5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 10:07:03 +0200 Subject: [PATCH 56/73] Fix getTreeNodeByIndex function --- src/web/utils/treeUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index d8631a48..2fe86bf1 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -127,7 +127,7 @@ function getTreeNodeByIndex(treeRoot: TreeNode, index: number): TreeNode | null return child; } el = child; - i = el.childNodes.length - 1; + i = 0; } else { i++; } From cc8e30725027fe66d12c9dfaba84f83abaf6c208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 10:59:26 +0200 Subject: [PATCH 57/73] Fix replacing whole content of the input --- src/web/utils/inputUtils.ts | 18 +++++++++++++----- src/web/utils/treeUtils.ts | 3 +++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 4a37279a..3162f612 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -49,13 +49,14 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement) { const root = target.tree; - if (root.childNodes.length === 0 || (root.childNodes.length === 1 && root.childNodes?.[0]?.type === 'line')) { + // early return when writing in empty input + if (root.childNodes.length === 0) { return root.element.textContent ?? ''; } const stack: TreeNode[] = [root]; let text = ''; - let ShouldInsertNewlineAfterParagraph = false; + let shouldInsertNewlineAfterParagraph = false; while (stack.length > 0) { const node = stack.pop(); if (!node) { @@ -64,12 +65,18 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement) { switch (node.type) { case 'line': - if (ShouldInsertNewlineAfterParagraph) { + // Insert new line after every line + if (shouldInsertNewlineAfterParagraph) { text += '\n'; - ShouldInsertNewlineAfterParagraph = false; + shouldInsertNewlineAfterParagraph = false; } if (node.element.textContent !== '') { - ShouldInsertNewlineAfterParagraph = true; + shouldInsertNewlineAfterParagraph = true; + } + + // Add text in case the span was removed and text is directly in paragraph + if (node.childNodes.length === 0 && !!node.element.textContent) { + text += node.element.textContent; } break; case 'br': @@ -79,6 +86,7 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement) { text += `\n`; } } else if (node.element?.textContent) { + // If the br span element has text content next to the br tag, add it to the text text += node.element?.textContent; } break; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 2fe86bf1..11b3d3a5 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -70,6 +70,9 @@ function buildTree(rootElement: HTMLMarkdownElement, text: string) { } Array.from(treeNode.element.children).forEach((childElement) => { + if (childElement.nodeName === 'BR' && !childElement.getAttribute('data-id')) { + return; + } const newTreeNode = addNodeToTree(childElement as HTMLMarkdownElement, treeNode, getElementType(childElement as HTMLMarkdownElement)); stack.push(newTreeNode); }); From 3da3989cd47525b5fe9e015249d693c40fa11349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 11:41:11 +0200 Subject: [PATCH 58/73] Fix set cursor position when content changes --- src/web/utils/cursorUtils.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index ce3810de..03fac49b 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -2,9 +2,16 @@ import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; import type {TreeNode} from './treeUtils'; -function setCursorPosition(target: MarkdownTextInputElement, start: number, end: number | null = null) { +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 || start < 0 || (end && end < 0)) { + if (!target.tree || target !== document.activeElement) { + return; + } + + const start = Math.max(0, Math.min(startIndex, target.value.length)); + const end = endIndex ? Math.max(0, Math.min(endIndex, target.tree.length)) : null; + + if (start < 0 || (end && end < 0)) { return; } From ccb251e5373946ae6927f28a1ace38d1fb445180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 11:41:34 +0200 Subject: [PATCH 59/73] Fix removing selection on paste --- src/web/utils/cursorUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index 03fac49b..0c38e644 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -43,6 +43,7 @@ function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, const selection = window.getSelection(); if (selection) { + selection.removeAllRanges(); selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } From e759170cc6c13f90e06f3f6aab0dfde2f1066433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 12:03:09 +0200 Subject: [PATCH 60/73] Fix set cursor position on paste --- src/web/utils/cursorUtils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index 0c38e644..be75c87f 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -8,10 +8,9 @@ function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, return; } - const start = Math.max(0, Math.min(startIndex, target.value.length)); + 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 (start < 0 || (end && end < 0)) { + if (end && end < start) { return; } From 4db4441101895ae8523a9b8bab24955d27cac3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 13:08:46 +0200 Subject: [PATCH 61/73] Fix dissapearing cursor bug --- src/web/utils/cursorUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index be75c87f..e0b4e23b 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -42,7 +42,6 @@ function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, const selection = window.getSelection(); if (selection) { - selection.removeAllRanges(); selection.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); } From 0480cb82e4db5318b8e7048931252f9e71a348ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 16 Jul 2024 20:40:11 +0200 Subject: [PATCH 62/73] Fix pasting text into empty input --- src/web/utils/parserUtils.ts | 13 ++++++++----- src/web/utils/treeUtils.ts | 23 +++++++++++++++++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 2fde2a6a..283ce68a 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -130,10 +130,6 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number addStyleToBlock(p, 'line', {}); } - if (text) { - appendValueToElement(p as unknown as HTMLMarkdownElement, node, text); - } - const pNode = appendNode(p as unknown as HTMLMarkdownElement, node, 'line', length); if (text === '') { @@ -180,6 +176,11 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // preparing line paragraph element for markdown text currentParentNode = addParagraph(rootNode, null, line.length, disableInlineStyles); + rootElement.value = (rootElement.value || '') + line.text; + if (lines.length > 1) { + rootElement.value = `${rootElement.value || ''}\n`; + } + if (line.markdownRanges.length === 0) { addTextToElement(currentParentNode, line.text); } @@ -227,7 +228,9 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS addTextToElement(currentParentNode, textAfterRange); } lastRangeEndIndex = currentParentNode.start + currentParentNode.length; - currentParentNode.parentNode.element.value = currentParentNode.element.value || ''; + if (currentParentNode.parentNode.type !== 'root') { + currentParentNode.parentNode.element.value = currentParentNode.element.value || ''; + } currentParentNode = currentParentNode.parentNode || rootNode; } } diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 11b3d3a5..fddd261c 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -69,12 +69,27 @@ function buildTree(rootElement: HTMLMarkdownElement, text: string) { break; } - Array.from(treeNode.element.children).forEach((childElement) => { - if (childElement.nodeName === 'BR' && !childElement.getAttribute('data-id')) { + Array.from(treeNode.element.childNodes).forEach((childNode) => { + let newTreeNode: TreeNode | null = null; + if (treeNode.element.getAttribute('data-type') === 'line' && childNode.nodeName === 'BR') { return; } - const newTreeNode = addNodeToTree(childElement as HTMLMarkdownElement, treeNode, getElementType(childElement as HTMLMarkdownElement)); - stack.push(newTreeNode); + + if (treeNode.type === 'root' && childNode.nodeType === Node.TEXT_NODE) { + const p = document.createElement('p') as unknown as HTMLMarkdownElement; + p.textContent = childNode.nodeValue; + newTreeNode = addNodeToTree(p, treeNode, 'line'); + } else if (treeNode.type === 'root' && childNode.nodeName === 'DIV') { + const p = document.createElement('p') as unknown as HTMLMarkdownElement; + p.innerHTML = (childNode as HTMLElement)?.innerHTML; + newTreeNode = addNodeToTree(p, treeNode, 'line'); + } else if (childNode.nodeType !== Node.TEXT_NODE) { + newTreeNode = addNodeToTree(childNode as HTMLMarkdownElement, treeNode, getElementType(childNode as HTMLMarkdownElement)); + } + + if (newTreeNode) { + stack.push(newTreeNode); + } }); } From 5faac50d37c08b5d846ce257f40378cfe075ad6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Wed, 17 Jul 2024 15:47:26 +0200 Subject: [PATCH 63/73] Fix newline deletion --- src/web/utils/inputUtils.ts | 5 ++++- src/web/utils/treeUtils.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 3162f612..0d7d4488 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -82,7 +82,10 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement) { case 'br': if (node.element.nodeName === 'BR') { const parentType = getParentType(node); - if ((parentType === 'line' && node.parentNode?.element?.textContent === '') || parentType !== 'line') { + if ( + (node.orderIndex.split(',')[0] !== (target.tree.childNodes.length - 1).toString() && parentType === 'line' && node.parentNode?.element?.textContent === '') || + parentType !== 'line' + ) { text += `\n`; } } else if (node.element?.textContent) { diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index fddd261c..3ef1cb7c 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -71,7 +71,7 @@ function buildTree(rootElement: HTMLMarkdownElement, text: string) { Array.from(treeNode.element.childNodes).forEach((childNode) => { let newTreeNode: TreeNode | null = null; - if (treeNode.element.getAttribute('data-type') === 'line' && childNode.nodeName === 'BR') { + if (treeNode.element.getAttribute('data-type') === 'line' && childNode.nodeName === 'BR' && treeNode.element.value.length < 1) { return; } From 14d0eedaf04c16dfac343cf8a636bf60d6fa85b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 18 Jul 2024 09:58:11 +0200 Subject: [PATCH 64/73] Improve parseInnerHTMLToText function and fix pasted text correct value generation --- src/MarkdownTextInput.web.tsx | 3 - src/web/utils/inputUtils.ts | 100 +++++++++++++--------------------- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index b44b0530..13b61599 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -16,7 +16,6 @@ import {StyleSheet} from 'react-native'; import {updateInputStructure} from './web/utils/parserUtils'; import BrowserUtils from './web/utils/browserUtils'; import InputHistory from './web/InputHistory'; -import {buildTree} from './web/utils/treeUtils'; import type {TreeNode} from './web/utils/treeUtils'; import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './web/utils/cursorUtils'; import './web/MarkdownTextInput.css'; @@ -280,8 +279,6 @@ const MarkdownTextInput = React.forwardRef( const isPasteInputType = inputType === 'pasteText'; updateTextColor(divRef.current, e.target.textContent ?? ''); - const tree = buildTree(divRef.current, divRef.current.value); - divRef.current.tree = tree; const previousText = divRef.current.value; const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement); diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 0d7d4488..220f91ea 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -1,5 +1,4 @@ import type {CSSProperties} from 'react'; -import type {TreeNode} from './treeUtils'; import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; const ZERO_WIDTH_SPACE = '\u200B'; @@ -33,86 +32,65 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL return styles.height ? `${styles.height}px` : 'auto'; } -function parseInnerHTMLToText(target: MarkdownTextInputElement) { - function getParentType(node: TreeNode) { - let currentNode = node; - while (['text', 'br'].includes(currentNode.type)) { - if (currentNode.parentNode) { - currentNode = currentNode.parentNode; - } else { - return null; - } +const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { + function getTopParentNode(node: ChildNode) { + let currentParentNode = node.parentNode; + while (currentParentNode && ['text', 'br', 'line'].includes(currentParentNode.parentElement?.getAttribute('data-type') || '')) { + currentParentNode = currentParentNode?.parentNode || null; } - - return currentNode.type; + return currentParentNode; } - const root = target.tree; + const stack: ChildNode[] = [target]; + let text = ''; + let shouldAddNewline = false; - // early return when writing in empty input - if (root.childNodes.length === 0) { - return root.element.textContent ?? ''; + const n = target.childNodes.length; + const lastNode = target.childNodes[n - 1]; + if (lastNode?.nodeName === 'DIV' && (lastNode as HTMLElement)?.innerHTML === '
') { + target.removeChild(lastNode); } - const stack: TreeNode[] = [root]; - let text = ''; - let shouldInsertNewlineAfterParagraph = false; while (stack.length > 0) { const node = stack.pop(); if (!node) { break; } - switch (node.type) { - case 'line': - // Insert new line after every line - if (shouldInsertNewlineAfterParagraph) { - text += '\n'; - shouldInsertNewlineAfterParagraph = false; - } - if (node.element.textContent !== '') { - shouldInsertNewlineAfterParagraph = true; - } + const isTopComponent = node.parentElement?.contentEditable === 'true'; + if (isTopComponent) { + if (shouldAddNewline) { + text += '\n'; + shouldAddNewline = false; + } - // Add text in case the span was removed and text is directly in paragraph - if (node.childNodes.length === 0 && !!node.element.textContent) { - text += node.element.textContent; - } - break; - case 'br': - if (node.element.nodeName === 'BR') { - const parentType = getParentType(node); - if ( - (node.orderIndex.split(',')[0] !== (target.tree.childNodes.length - 1).toString() && parentType === 'line' && node.parentNode?.element?.textContent === '') || - parentType !== 'line' - ) { - text += `\n`; - } - } else if (node.element?.textContent) { - // If the br span element has text content next to the br tag, add it to the text - text += node.element?.textContent; - } - break; - case 'text': - text += node.element.textContent; - break; - default: - break; + if (!shouldAddNewline) { + shouldAddNewline = true; + } } - let i = node.childNodes.length - 1; - while (i > -1) { - const child = node.childNodes[i]; - if (!child) { - break; + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent; + } else if (node.nodeName === 'BR') { + const parentNode = getTopParentNode(node); + if (parentNode && parentNode.nodeName !== 'DIV' && parentNode.nodeName !== 'P') { + text += '\n'; } + } else { + let i = node.childNodes.length - 1; + while (i > -1) { + const child = node.childNodes[i]; + if (!child) { + break; + } - stack.push(child); - i--; + stack.push(child); + i--; + } } } return text; -} +}; export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText}; From 0c204f5c7a38f5f6c9e65d5be6a696d946af88f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 18 Jul 2024 10:55:31 +0200 Subject: [PATCH 65/73] Remove buildTree function --- src/web/utils/treeUtils.ts | 57 +------------------------------------- 1 file changed, 1 insertion(+), 56 deletions(-) diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index 3ef1cb7c..fc31ddad 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -41,61 +41,6 @@ function addNodeToTree(element: HTMLMarkdownElement, parentTreeNode: TreeNode, t return item; } -function buildTree(rootElement: HTMLMarkdownElement, text: string) { - function getElementType(element: HTMLElement): NodeType { - if (element.nodeName === 'BR') { - return 'br'; - } - if (element.nodeName === 'P') { - return 'line'; - } - - return (element.getAttribute('data-type') as NodeType) || 'text'; - } - const rootTreeNode: TreeNode = { - element: rootElement, - parentNode: null, - childNodes: [], - start: 0, - length: text.replace(/\n/g, '\\n').length, - type: 'root', - orderIndex: '', - isGeneratingNewline: false, - }; - const stack = [rootTreeNode]; - while (stack.length > 0) { - const treeNode = stack.pop(); - if (!treeNode) { - break; - } - - Array.from(treeNode.element.childNodes).forEach((childNode) => { - let newTreeNode: TreeNode | null = null; - if (treeNode.element.getAttribute('data-type') === 'line' && childNode.nodeName === 'BR' && treeNode.element.value.length < 1) { - return; - } - - if (treeNode.type === 'root' && childNode.nodeType === Node.TEXT_NODE) { - const p = document.createElement('p') as unknown as HTMLMarkdownElement; - p.textContent = childNode.nodeValue; - newTreeNode = addNodeToTree(p, treeNode, 'line'); - } else if (treeNode.type === 'root' && childNode.nodeName === 'DIV') { - const p = document.createElement('p') as unknown as HTMLMarkdownElement; - p.innerHTML = (childNode as HTMLElement)?.innerHTML; - newTreeNode = addNodeToTree(p, treeNode, 'line'); - } else if (childNode.nodeType !== Node.TEXT_NODE) { - newTreeNode = addNodeToTree(childNode as HTMLMarkdownElement, treeNode, getElementType(childNode as HTMLMarkdownElement)); - } - - if (newTreeNode) { - stack.push(newTreeNode); - } - }); - } - - return rootTreeNode; -} - function findHTMLElementInTree(treeRoot: TreeNode, element: HTMLElement): TreeNode | null { if (element.hasAttribute('contenteditable')) { return treeRoot; @@ -153,6 +98,6 @@ function getTreeNodeByIndex(treeRoot: TreeNode, index: number): TreeNode | null return null; } -export {addNodeToTree, findHTMLElementInTree, getTreeNodeByIndex, buildTree}; +export {addNodeToTree, findHTMLElementInTree, getTreeNodeByIndex}; export type {TreeNode, NodeType}; From de12aca7556dce9b344bbbc048146fdf5861e63f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 18 Jul 2024 20:27:21 +0200 Subject: [PATCH 66/73] Fix tests --- src/web/utils/parserUtils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 283ce68a..39719684 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -177,7 +177,7 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS // preparing line paragraph element for markdown text currentParentNode = addParagraph(rootNode, null, line.length, disableInlineStyles); rootElement.value = (rootElement.value || '') + line.text; - if (lines.length > 1) { + if (lines.length > 0) { rootElement.value = `${rootElement.value || ''}\n`; } @@ -235,8 +235,6 @@ function parseRangesToHTMLNodes(text: string, ranges: MarkdownRange[], markdownS } } } - - rootNode.element.value = (rootNode.element.value || '') + (currentParentNode.element.value || ''); } return {dom: rootElement, tree: rootNode}; From 8648205133ba914d76441ce39e8f1b0bbca3882f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 19 Jul 2024 18:19:41 +0200 Subject: [PATCH 67/73] Replacing text by text cursor position --- src/MarkdownTextInput.web.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 13b61599..70d86ef0 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -364,7 +364,13 @@ const MarkdownTextInput = React.forwardRef( return; } - pasteContent.current = `${divRef.current.value.substring(0, contentSelection.current.start)}${text}${divRef.current.value.substring(contentSelection.current.end)}`; + 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); From eb1502a5d123f68534138b495c49b05f37790bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 19 Jul 2024 18:55:15 +0200 Subject: [PATCH 68/73] Fix cursor positioning on custom text pasting (E/App) --- src/MarkdownTextInput.web.tsx | 15 +++++++++++---- src/web/utils/parserUtils.ts | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 70d86ef0..a24747f6 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -133,7 +133,14 @@ const MarkdownTextInput = React.forwardRef( }, []); const parseText = useCallback( - (target: MarkdownTextInputElement, 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}; } @@ -141,7 +148,7 @@ const MarkdownTextInput = React.forwardRef( if (text === null) { return {text: divRef.current.value, cursorPosition: null}; } - const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline); + const parsedText = updateInputStructure(target, text, cursorPosition, customMarkdownStyles, !multiline, shouldForceDOMUpdate); divRef.current.value = parsedText.text; if (history.current && shouldAddToHistory) { @@ -295,7 +302,7 @@ const MarkdownTextInput = React.forwardRef( const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); let newInputUpdate: ParseTextResult; - switch (nativeEvent.inputType) { + switch (inputType) { case 'historyUndo': newInputUpdate = undo(divRef.current); break; @@ -303,7 +310,7 @@ const MarkdownTextInput = React.forwardRef( newInputUpdate = redo(divRef.current); break; default: - newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition); + newInputUpdate = parseText(divRef.current, parsedText, processedMarkdownStyle, newCursorPosition, true, !inputType); } const {text, cursorPosition} = newInputUpdate; updateTextColor(divRef.current, text); diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 39719684..376a7655 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -258,6 +258,7 @@ function updateInputStructure( cursorPositionIndex: number | null, markdownStyle: PartialMarkdownStyle = {}, alwaysMoveCursorToTheEnd = false, + shouldForceDOMUpdate = false, ) { const targetElement = target; @@ -279,7 +280,7 @@ function updateInputStructure( if (text) { const {dom, tree} = parseRangesToHTMLNodes(text, markdownRanges, markdownStyle); - if (targetElement.innerHTML !== dom.innerHTML) { + if (shouldForceDOMUpdate || targetElement.innerHTML !== dom.innerHTML) { targetElement.innerHTML = ''; targetElement.innerText = ''; Array.from(dom.children).forEach((child) => { From 348630375c0794ae9dd251a5b6c807c7c500f569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 19 Jul 2024 19:52:04 +0200 Subject: [PATCH 69/73] Add function comments --- src/MarkdownTextInput.web.tsx | 3 +-- src/web/utils/inputUtils.ts | 23 ++++++++++++----------- src/web/utils/parserUtils.ts | 12 +++++++----- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index a24747f6..b3d393e6 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -283,11 +283,10 @@ const MarkdownTextInput = React.forwardRef( } const nativeEvent = e.nativeEvent as MarkdownNativeEvent; const inputType = nativeEvent.inputType; - const isPasteInputType = inputType === 'pasteText'; updateTextColor(divRef.current, e.target.textContent ?? ''); const previousText = divRef.current.value; - const parsedText = isPasteInputType ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement); + const parsedText = inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement); if (pasteContent.current) { pasteContent.current = null; diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 220f91ea..8443ec93 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -32,7 +32,9 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL return styles.height ? `${styles.height}px` : 'auto'; } -const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { +// 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') || '')) { @@ -44,9 +46,8 @@ const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { const stack: ChildNode[] = [target]; let text = ''; let shouldAddNewline = false; - - const n = target.childNodes.length; - const lastNode = target.childNodes[n - 1]; + 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); } @@ -57,23 +58,24 @@ const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { 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) { if (shouldAddNewline) { text += '\n'; shouldAddNewline = false; } - - if (!shouldAddNewline) { - shouldAddNewline = true; - } + 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.nodeName !== 'DIV' && parentNode.nodeName !== 'P') { + 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 { @@ -83,7 +85,6 @@ const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { if (!child) { break; } - stack.push(child); i--; } @@ -91,6 +92,6 @@ const parseInnerHTMLToText = (target: MarkdownTextInputElement): string => { } return text; -}; +} export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText}; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 376a7655..7b9f7985 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -51,6 +51,7 @@ function splitTextIntoLines(text: string): Paragraph[] { 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); @@ -83,11 +84,12 @@ function mergeLinesWithMultilineTags(lines: Paragraph[], ranges: MarkdownRange[] return mergedLines; } -function appendValueToElement(element: HTMLMarkdownElement, parentNode: TreeNode, value: string) { +/** 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 node = parentNode; + const parentNode = parentTreeNode; targetElement.value = value; - node.element.value = (node.element.value || '') + value; + parentNode.element.value = (parentNode.element.value || '') + value; } function appendNode(element: HTMLMarkdownElement, parentTreeNode: TreeNode, type: NodeType, length: number) { @@ -111,7 +113,6 @@ function addTextToElement(node: TreeNode, text: string) { 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); @@ -133,6 +134,7 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number 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); @@ -141,6 +143,7 @@ function addParagraph(node: TreeNode, text: string | null = null, length: number 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; @@ -251,7 +254,6 @@ function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, curso setCursorPosition(target, cursorPosition); } } - function updateInputStructure( target: MarkdownTextInputElement, text: string, From a1bf047fbe7463e26f6a5d5f107203daec61a8d8 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Fri, 2 Aug 2024 12:21:30 +0200 Subject: [PATCH 70/73] fix: windows emoji picker selection --- src/MarkdownTextInput.web.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index b3d393e6..f11698d5 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -293,13 +293,15 @@ const MarkdownTextInput = React.forwardRef( } const prevSelection = contentSelection.current ?? {start: 0, end: 0}; + const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); + if (compositionRef.current && !BrowserUtils.isMobile) { divRef.current.value = parsedText; compositionRef.current = false; + contentSelection.current.end = newCursorPosition; return; } - const newCursorPosition = Math.max(Math.max(contentSelection.current.end, 0) + (parsedText.length - previousText.length), 0); let newInputUpdate: ParseTextResult; switch (inputType) { case 'historyUndo': From 4f76598ebd4f946a91fae24d1a229720ad57c343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 19 Aug 2024 13:05:05 +0200 Subject: [PATCH 71/73] Fix app crashes on Safari after sending the message with emoji --- src/web/utils/treeUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index fc31ddad..d85146db 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -42,11 +42,11 @@ function addNodeToTree(element: HTMLMarkdownElement, parentTreeNode: TreeNode, t } function findHTMLElementInTree(treeRoot: TreeNode, element: HTMLElement): TreeNode | null { - if (element.hasAttribute('contenteditable')) { + if (element.hasAttribute?.('contenteditable')) { return treeRoot; } - if (!element || !element.hasAttribute('data-id')) { + if (!element || !element.hasAttribute?.('data-id')) { return null; } const indexes = element.getAttribute('data-id')?.split(','); From ea8c20478ecc1340be2effe1569687e4ad1dbcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 20 Aug 2024 10:15:56 +0200 Subject: [PATCH 72/73] Fix replacing multiline text by pasting content --- src/web/utils/inputUtils.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 8443ec93..6e7043f4 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -61,11 +61,16 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement): string { // 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) { - if (shouldAddNewline) { - text += '\n'; + // 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; } - shouldAddNewline = true; } if (node.nodeType === Node.TEXT_NODE) { From 4e895e95ba6d2d42cb586f70f5ad60a3c5fd8508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Tue, 20 Aug 2024 11:07:05 +0200 Subject: [PATCH 73/73] Add text normalization to fix problems with \r\n --- src/MarkdownTextInput.web.tsx | 10 +++++----- src/web/utils/inputUtils.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 9080fd45..01de8eb1 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -19,7 +19,7 @@ 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, parseInnerHTMLToText} from './web/utils/inputUtils'; +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'); @@ -286,7 +286,7 @@ const MarkdownTextInput = React.forwardRef( updateTextColor(divRef.current, e.target.textContent ?? ''); const previousText = divRef.current.value; - const parsedText = inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement); + const parsedText = normalizeValue(inputType === 'pasteText' ? pasteContent.current || '' : parseInnerHTMLToText(e.target as MarkdownTextInputElement)); if (pasteContent.current) { pasteContent.current = null; @@ -596,9 +596,9 @@ const MarkdownTextInput = React.forwardRef( parseText(divRef.current, divRef.current.value, processedMarkdownStyle); return; } - - divRef.current.value = value; - parseText(divRef.current, value, processedMarkdownStyle); + const normalizedValue = normalizeValue(value); + divRef.current.value = normalizedValue; + parseText(divRef.current, normalizedValue, processedMarkdownStyle); updateTextColor(divRef.current, value); }, [multiline, processedMarkdownStyle, value], diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index 6e7043f4..0e006990 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -32,6 +32,10 @@ function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfL 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' @@ -96,7 +100,7 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement): string { } } - return text; + return text.replaceAll('\r\n', '\n'); } -export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText}; +export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText, normalizeValue};