import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import {addNodeToTree, createRootTreeNode, updateTreeElementRefs} from './treeUtils'; import type {NodeType, TreeNode} from './treeUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; import {addStyleToBlock, extendBlockStructure, getFirstBlockMarkdownRange, isBlockMarkdownType, isMultilineMarkdownType} from './blockUtils'; import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; import {getAnimationCurrentTimes, updateAnimationsTime} from './animationUtils'; import {sortRanges, ungroupRanges} from '../../rangeUtils'; type Paragraph = { text: string; start: number; length: number; markdownRanges: MarkdownRange[]; }; function splitTextIntoLines(text: string): Paragraph[] { let lineStartIndex = 0; const lines: Paragraph[] = text.split('\n').map((line) => { const lineObject: Paragraph = { text: line, start: lineStartIndex, length: line.length, markdownRanges: [], }; lineStartIndex += line.length + 1; // Adding 1 for the newline character return lineObject; }); return lines; } /** * Merges lines with multiline markdown tags (like `pre`) into a single line. * The main line will contain the text and all markdown ranges from the other lines. */ function mergeLinesWithMultilineTags(lines: Paragraph[], currentLine: Paragraph, range: MarkdownRange, correspondingLineIndexes: number[]) { const mainLine = currentLine; currentLine.markdownRanges.push(range); correspondingLineIndexes.forEach((lineIndex) => { const otherLine = lines[lineIndex] as Paragraph; mainLine.text += `\n${otherLine.text}`; mainLine.length += otherLine.length + 1; mainLine.markdownRanges.push(...otherLine.markdownRanges); }); if (correspondingLineIndexes.length > 0 && correspondingLineIndexes[0] !== undefined) { lines.splice(correspondingLineIndexes[0], correspondingLineIndexes.length); } } /** * Splits a markdown range that spans multiple lines into separate lines. */ function splitRangeIntoSeparateLines(lines: Paragraph[], currentLine: Paragraph, range: MarkdownRange, correspondingLineIndexes: number[]) { const mainLineRangeLength = currentLine.start + currentLine.length - range.start; currentLine.markdownRanges.push({ ...range, length: mainLineRangeLength, }); let rangeLength = range.length - mainLineRangeLength; correspondingLineIndexes.forEach((lineIndex) => { const otherLine = lines[lineIndex] as Paragraph; let currentLength = otherLine.length; if (rangeLength <= currentLength) { currentLength = rangeLength - 1; } if (currentLength > 0) { lines[lineIndex]?.markdownRanges.push({ ...range, start: otherLine.start, length: currentLength, }); } rangeLength -= currentLength; }); } /** * For singleline markdown types, the function splits markdown ranges that spread beyond the line length into separate lines. * For multiline markdown types (like `pre`), it merges them and corresponding text into one line. */ function normalizeLines(lines: Paragraph[], ranges: MarkdownRange[]) { const mergedLines = [...lines]; const lineIndexes = mergedLines.map((_line, index) => index); ranges.forEach((range) => { const beginLineIndex = mergedLines.findLastIndex((line) => line.start <= range.start); const endLineIndex = mergedLines.findIndex((line) => line.start + line.length >= range.start + range.length); const correspondingLineIndexes = lineIndexes.slice(beginLineIndex, endLineIndex + 1); if (correspondingLineIndexes.length > 0) { const mainLineIndex = correspondingLineIndexes[0] as number; const mainLine = mergedLines[mainLineIndex] as Paragraph; const otherLineIndexes = correspondingLineIndexes.slice(1); if (isMultilineMarkdownType(range.type)) { mergeLinesWithMultilineTags(mergedLines, mainLine, range, otherLineIndexes); } else if (otherLineIndexes.length > 0) { splitRangeIntoSeparateLines(mergedLines, mainLine, range, otherLineIndexes); } else { mainLine.markdownRanges.push(range); } } }); return mergedLines; } /** Adds a value prop to the element and appends the value to the parent node element */ function appendValueToElement(element: HTMLMarkdownElement, parentTreeNode: TreeNode, value: string) { const targetElement = element; const parentNode = parentTreeNode; targetElement.value = value; parentNode.element.value = (parentNode.element.value || '') + value; } function appendNode(element: HTMLMarkdownElement, parentTreeNode: TreeNode, type: NodeType, length: number) { const node = addNodeToTree(element, parentTreeNode, type, length); parentTreeNode.element.appendChild(element); return node; } function addBrElement(node: TreeNode) { const span = document.createElement('span') as HTMLMarkdownElement; span.setAttribute('data-type', 'br'); appendValueToElement(span, node, '\n'); const spanNode = appendNode(span, node, 'br', 1); appendNode(document.createElement('br') as unknown as HTMLMarkdownElement, spanNode, 'br', 1); return spanNode; } function addTextToElement(node: TreeNode, text: string, isMultiline = true) { const lines = text.split('\n'); lines.forEach((line, index) => { if (line !== '') { const span = document.createElement('span') as HTMLMarkdownElement; appendValueToElement(span, node, line); span.setAttribute('data-type', 'text'); span.appendChild(document.createTextNode(line)); appendNode(span, node, 'text', line.length); const parentType = span.parentElement?.dataset.type; if (!isMultiline && parentType && ['pre', 'code', 'mention-here', 'mention-user', 'mention-report'].includes(parentType)) { // this is a fix to background colors being shifted downwards in a singleline input addStyleToBlock(span, 'text', {}, false); } } // Only add BR elements for multiline inputs or when there are actual line breaks if (isMultiline && (index < lines.length - 1 || (index === 0 && line === ''))) { addBrElement(node); } }); } function addParagraph(node: TreeNode, text: string | null, length: number, disableInlineStyles = false) { const p = document.createElement('p'); p.setAttribute('data-type', 'line'); if (!disableInlineStyles) { addStyleToBlock(p, 'line', {}); } const pNode = appendNode(p as unknown as HTMLMarkdownElement, node, 'line', length); if (text === '') { // If the line is empty, we still need to add a br element to keep the line height addBrElement(pNode); } else if (text) { addTextToElement(pNode, text); } return pNode; } function addBlockWrapper(targetNode: TreeNode, length: number, markdownStyle: PartialMarkdownStyle) { const span = document.createElement('span') as HTMLMarkdownElement; span.setAttribute('data-type', 'block'); addStyleToBlock(span, 'block', markdownStyle); return appendNode(span, targetNode, 'block', length); } /** Builds HTML DOM structure based on passed text and markdown ranges */ function parseRangesToHTMLNodes( text: string, ranges: MarkdownRange[], isMultiline = true, markdownStyle: PartialMarkdownStyle = {}, disableInlineStyles = false, currentInput: MarkdownTextInputElement | null = null, inlineImagesProps: InlineImagesInputProps = {}, ) { const rootElement: HTMLMarkdownElement = document.createElement('span') as HTMLMarkdownElement; const textLength = text.length; const rootNode: TreeNode = createRootTreeNode(rootElement, textLength); let currentParentNode: TreeNode = rootNode; let lines = splitTextIntoLines(text); if (ranges.length === 0) { lines.forEach((line) => { addParagraph(rootNode, line.text, line.length, disableInlineStyles); }); return {dom: rootElement, tree: rootNode}; } // Sort all ranges by start position, length, and by tag hierarchy so the styles and text are applied in correct order const sortedRanges = sortRanges(ranges); const markdownRanges = ungroupRanges(sortedRanges); lines = normalizeLines(lines, markdownRanges); let lastRangeEndIndex = 0; while (lines.length > 0) { const line = lines.shift(); if (!line) { break; } // preparing line paragraph element for markdown text currentParentNode = addParagraph(rootNode, null, line.length, disableInlineStyles); rootElement.value = (rootElement.value || '') + line.text; if (lines.length > 0) { rootElement.value = `${rootElement.value || ''}\n`; } if (line.markdownRanges.length === 0) { addTextToElement(currentParentNode, line.text, isMultiline); } let wasBlockGenerated = false; lastRangeEndIndex = line.start; const lineMarkdownRanges = line.markdownRanges; // go through all markdown ranges in the line while (lineMarkdownRanges.length > 0) { const range = lineMarkdownRanges.shift(); if (!range) { break; } const endOfCurrentRange = range.start + range.length; const nextRangeStartIndex = lineMarkdownRanges.length > 0 && !!lineMarkdownRanges[0] ? lineMarkdownRanges[0].start || 0 : textLength; // wrap all elements before the first block type markdown range with a span element const blockRange = getFirstBlockMarkdownRange([range, ...lineMarkdownRanges]); if (!wasBlockGenerated && blockRange) { currentParentNode = addBlockWrapper(currentParentNode, line.text.substring(lastRangeEndIndex - line.start, blockRange.start + blockRange.length - line.start).length, markdownStyle); wasBlockGenerated = true; } // add text before the markdown range const textBeforeRange = line.text.substring(lastRangeEndIndex - line.start, range.start - line.start); if (textBeforeRange) { addTextToElement(currentParentNode, textBeforeRange, isMultiline); } // create markdown span element const span = document.createElement('span') as HTMLMarkdownElement; span.setAttribute('data-type', range.type); if (!disableInlineStyles) { addStyleToBlock(span, range.type, markdownStyle, isMultiline); } const spanNode = appendNode(span, currentParentNode, range.type, range.length); if (isMultiline && !disableInlineStyles && currentInput) { currentParentNode = extendBlockStructure(currentInput, currentParentNode, range, lineMarkdownRanges, text, markdownStyle, inlineImagesProps); } if (lineMarkdownRanges.length > 0 && nextRangeStartIndex < endOfCurrentRange && range.type !== 'syntax') { // tag nesting currentParentNode = spanNode; lastRangeEndIndex = range.start; } else { // adding markdown tag addTextToElement(spanNode, text.substring(range.start, endOfCurrentRange), isMultiline); currentParentNode.element.value = (currentParentNode.element.value || '') + (spanNode.element.value || ''); lastRangeEndIndex = endOfCurrentRange; // tag unnesting and adding text after the tag while (currentParentNode.parentNode !== null && nextRangeStartIndex >= currentParentNode.start + currentParentNode.length) { const textAfterRange = line.text.substring(lastRangeEndIndex - line.start, currentParentNode.start - line.start + currentParentNode.length); if (textAfterRange) { addTextToElement(currentParentNode, textAfterRange, isMultiline); } lastRangeEndIndex = currentParentNode.start + currentParentNode.length; if (currentParentNode.parentNode.type !== 'root') { currentParentNode.parentNode.element.value = currentParentNode.element.value || ''; } if (isBlockMarkdownType(currentParentNode.type)) { wasBlockGenerated = false; } currentParentNode = currentParentNode.parentNode || rootNode; } } } } return {dom: rootElement, tree: rootNode}; } function moveCursor(isFocused: boolean, alwaysMoveCursorToTheEnd: boolean, cursorPosition: number | null, target: MarkdownTextInputElement, shouldScrollIntoView = false) { if (!isFocused) { return; } if (alwaysMoveCursorToTheEnd || cursorPosition === null) { moveCursorToEnd(target); } else if (cursorPosition !== null) { setCursorPosition(target, cursorPosition, null, shouldScrollIntoView); } } function updateInputStructure( parserFunction: (input: string) => MarkdownRange[], target: MarkdownTextInputElement, text: string, cursorPositionIndex: number | null, isMultiline = true, markdownStyle: PartialMarkdownStyle = {}, alwaysMoveCursorToTheEnd = false, shouldForceDOMUpdate = false, shouldScrollIntoView = false, inlineImagesProps: InlineImagesInputProps = {}, ) { const targetElement = target; // in case the cursorPositionIndex is larger than text length, cursorPosition will be null, i.e: move the caret to the end let cursorPosition: number | null = cursorPositionIndex !== null && cursorPositionIndex <= text.length ? cursorPositionIndex : null; const isFocused = document.activeElement === target; if (isFocused && cursorPositionIndex === null) { const selection = getCurrentCursorPosition(target); cursorPosition = selection ? selection.start : null; } const markdownRanges = parserFunction(text); if (!text || targetElement.innerHTML === '
' || (targetElement && targetElement.innerHTML === '\n')) { targetElement.innerHTML = ''; targetElement.innerText = ''; } // We don't want to parse text with single '\n', because contentEditable represents it as invisible
if (text) { const {dom, tree} = parseRangesToHTMLNodes(text, markdownRanges, isMultiline, markdownStyle, false, targetElement, inlineImagesProps); if (shouldForceDOMUpdate || targetElement.innerHTML !== dom.innerHTML) { const animationTimes = getAnimationCurrentTimes(targetElement); targetElement.innerHTML = ''; targetElement.innerText = ''; targetElement.innerHTML = dom.innerHTML; updateAnimationsTime(targetElement, animationTimes); } updateTreeElementRefs(tree, targetElement); targetElement.tree = tree; moveCursor(isFocused, alwaysMoveCursorToTheEnd, cursorPosition, targetElement, shouldScrollIntoView); } else { targetElement.tree = createRootTreeNode(targetElement); } return {text, cursorPosition: cursorPosition || 0}; } export {updateInputStructure, parseRangesToHTMLNodes, normalizeLines}; export type {Paragraph};