import type {CSSProperties} from 'react';
import type {MarkdownNativeEvent, MarkdownTextInputElement} from '../../MarkdownTextInput.web';
import {isChildOfMarkdownElement, isChildOfMultilineMarkdownElement} from './blockUtils';
import BrowserUtils from './browserUtils';
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 | MarkdownNativeEvent) {
return nativeEvent.isComposing || nativeEvent.keyCode === 229;
}
function getPlaceholderValue(placeholder: string | undefined) {
if (!placeholder) {
return ZERO_WIDTH_SPACE;
}
return placeholder.length ? placeholder : ZERO_WIDTH_SPACE;
}
function getElementHeight(node: HTMLDivElement, styles: CSSProperties, numberOfLines: number | undefined) {
if (numberOfLines) {
const tempElement = document.createElement('div');
tempElement.setAttribute('contenteditable', 'true');
Object.assign(tempElement.style, styles);
tempElement.textContent = Array(numberOfLines).fill('A').join('\n');
if (node.parentElement) {
node.parentElement.appendChild(tempElement);
const height = tempElement.clientHeight;
node.parentElement.removeChild(tempElement);
return height ? `${height}px` : 'auto';
}
}
return styles.height ? `${styles.height}px` : 'auto';
}
function normalizeValue(value: string) {
return value.replaceAll('\r\n', '\n');
}
/**
* Returns the parent of a given node that is higher in the hierarchy and is of a different type than 'text', 'br' or 'line'
*/
function getTopParentNode(node: ChildNode) {
let currentParentNode = node.parentNode;
while (currentParentNode && ['text', 'br', 'line'].includes(currentParentNode.parentElement?.getAttribute('data-type') || '')) {
currentParentNode = currentParentNode?.parentNode || null;
}
return currentParentNode;
}
/**
* On Firefox, when breaking one codeblock, its syntax and the
after it can be merged into the closing syntax of the previous codeblock.
*/
function didTwoCodeblocksMerge(node: ChildNode | null) {
if (!node || !BrowserUtils.isFirefox) {
return;
}
// To identify that two codeblock has merged, we check if current line ends with
tag, that previously was second codeblock's opening syntax line break
const hasPartOfBrokenCodeblock = node.lastChild?.lastChild?.lastChild?.lastChild?.nodeName === 'BR';
return BrowserUtils.isFirefox && (node.lastChild as HTMLElement)?.getAttribute('data-type') === 'codeblock' && hasPartOfBrokenCodeblock;
}
/**
* 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, cursorPosition: number, inputType?: string, isMultiline = true): string {
const stack: ChildNode[] = [target];
let text = '';
let shouldAddNewline = false;
const lastNode = target.childNodes[target.childNodes.length - 1];
// Remove the last
element if it's the last child of the target element. Fixes the issue with adding extra newline when pasting into the empty input.
if (lastNode?.nodeName === 'DIV' && (lastNode as HTMLElement)?.innerHTML === '
') {
target.removeChild(lastNode);
}
while (stack.length > 0) {
const node = stack.pop() as HTMLElement;
if (!node) {
break;
}
// If we are operating on the nodes that are children of the MarkdownTextInputElement, we need to add a newline after each
const isTopComponent = node.parentElement?.contentEditable === 'true';
if (isTopComponent) {
// When inputType is undefined, the first part of the replaced text is added as a text node.
// Because of it, we need to prevent adding new lines in this case
if (!isMultiline || (!inputType && node.nodeType === Node.TEXT_NODE)) {
shouldAddNewline = false;
} else {
const firstChild = node.firstChild as HTMLElement;
const containsEmptyBlockElement = firstChild?.getAttribute?.('data-type') === 'block' && firstChild.textContent === '';
if (firstChild && shouldAddNewline && !containsEmptyBlockElement && !didTwoCodeblocksMerge(node.previousSibling)) {
text += '\n';
shouldAddNewline = false;
}
shouldAddNewline = true;
}
}
if (node.nodeType === Node.TEXT_NODE) {
let hasAddedNewline = false;
// Fix for codeblocks: Removing last codeblock newline, moves codeblock syntax too far into the codeblock content
// skipping one
after the codeblock syntax. We need to force parsing it before the text node is added.
if (node.parentElement && !node.parentElement.getAttribute?.('data-type') && isChildOfMarkdownElement(node, 'pre')) {
text += '\n';
const nextBR = node.parentElement?.nextElementSibling?.firstElementChild ?? node.parentElement?.nextElementSibling;
if (nextBR && nextBR.tagName === 'BR') {
nextBR.remove();
}
hasAddedNewline = true;
}
// Parse text nodes into text
text += node.textContent;
// Fix for codeblocks: If we are adding text at the end of a multiline markdown type element, we need to add a newline
// because the new text can replace the last
element and it will not be added to the text.
if (
node.parentElement &&
node.parentNode?.parentElement?.nextSibling &&
!node.parentNode?.nextSibling &&
isChildOfMultilineMarkdownElement(node) &&
((!hasAddedNewline && isChildOfMarkdownElement(node, 'br')) || (!node.parentElement.getAttribute?.('data-type') && isChildOfMarkdownElement(node, 'syntax')))
) {
text += '\n';
}
} else if (node.nodeName === 'BR') {
const parentNode = getTopParentNode(node);
if (
(isMultiline &&
parentNode &&
parentNode.parentElement?.contentEditable !== 'true' &&
!!((node as HTMLElement).getAttribute('data-id') || (node.parentElement as HTMLElement).getAttribute('data-type') === 'br')) ||
(node.parentElement?.getAttribute('data-type') === 'text' && isChildOfMultilineMarkdownElement(node))
) {
// 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 - and now only for multiline inputs
text += '\n';
}
} else {
let i = node.childNodes.length - 1;
while (i > -1) {
const child = node.childNodes[i];
if (!child) {
break;
}
stack.push(child);
i--;
}
}
}
text = text.replaceAll('\r\n', '\n');
// Force letter removal if the input value haven't changed but input type is 'delete'
if (text === target.value && inputType?.includes('delete')) {
text = text.slice(0, cursorPosition - 1) + text.slice(cursorPosition);
}
return text;
}
export {isEventComposing, getPlaceholderValue, getElementHeight, parseInnerHTMLToText, normalizeValue};