diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 5cb0d0676..d6edf7d8f 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 73d719948..93319e706 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 1cda6599a..127a0228e 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 49583b9fb..840309e06 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 000000000..9a9e4e204 --- /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};