diff --git a/src/MarkdownTextInput.web.tsx b/src/MarkdownTextInput.web.tsx index 05316223..5b6187b4 100644 --- a/src/MarkdownTextInput.web.tsx +++ b/src/MarkdownTextInput.web.tsx @@ -11,7 +11,7 @@ import type { TextInputContentSizeChangeEventData, GestureResponderEvent, } from 'react-native'; -import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect} from 'react'; +import React, {useEffect, useRef, useCallback, useMemo, useLayoutEffect, useState} from 'react'; import type {CSSProperties, MutableRefObject, ReactEventHandler, FocusEventHandler, MouseEvent, KeyboardEvent, SyntheticEvent, ClipboardEventHandler, TouchEvent} from 'react'; import {StyleSheet} from 'react-native'; import {updateInputStructure} from './web/utils/parserUtils'; @@ -21,8 +21,9 @@ import {getCurrentCursorPosition, removeSelection, setCursorPosition} from './we import './web/MarkdownTextInput.css'; import type {MarkdownStyle} from './MarkdownTextInputDecoratorViewNativeComponent'; import {getElementHeight, getPlaceholderValue, isEventComposing, normalizeValue, parseInnerHTMLToText} from './web/utils/inputUtils'; -import {parseToReactDOMStyle, processMarkdownStyle} from './web/utils/webStyleUtils'; +import {parseToReactDOMStyle, configureCustomWebStylesheet, handleCustomStyles, idGenerator, processMarkdownStyle} from './web/utils/webStyleUtils'; import {forceRefreshAllImages} from './web/inputElements/inlineImage'; +import type {PartialMarkdownStyle} from './styleUtils'; import type {MarkdownRange, InlineImagesInputProps} from './commonTypes'; const useClientEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -62,6 +63,7 @@ let focusTimeout: NodeJS.Timeout | null = null; type MarkdownTextInputElement = HTMLDivElement & HTMLInputElement & { tree: TreeNode; + uniqueId: string; selection: Selection; imageElements: HTMLImageElement[]; }; @@ -124,7 +126,7 @@ const MarkdownTextInput = React.forwardRef(null); const currentlyFocusedField = useRef(null); const contentSelection = useRef(null); - const className = `react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`; + const [className, setClassName] = useState(`react-native-live-markdown-input-${multiline ? 'multiline' : 'singleline'}`); const history = useRef(); const dimensions = useRef(null); const pasteContent = useRef(null); @@ -380,6 +382,14 @@ const MarkdownTextInput = React.forwardRef 0) { + const preBlock = preBlocks.pop() as HTMLElement; + preBlock.setAttribute('data-content', parseInnerHTMLToText(preBlock, 0)); + } + handleCustomStyles(divRef.current, processedMarkdownStyle); + } return; } let newInputUpdate: ParseTextResult; @@ -743,6 +753,11 @@ const MarkdownTextInput = React.forwardRef') { text += '\n'; } else if (node.tag.startsWith(' *:last-child { + display: none; +} + +*[data-type='line'] *[data-type='pre'] + *[data-type='syntax'] { + display: grid; + line-height: 1.3; +} + @keyframes react-native-live-markdown-spin { 0% { transform: rotate(0deg); diff --git a/src/web/utils/blockUtils.ts b/src/web/utils/blockUtils.ts index e9837569..72590c40 100644 --- a/src/web/utils/blockUtils.ts +++ b/src/web/utils/blockUtils.ts @@ -1,11 +1,19 @@ +import type {InlineImagesInputProps} from '../../commonTypes'; import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; -import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; +import {parseStringWithUnitToNumber} from '../../styleUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {addInlineImagePreview} from '../inputElements/inlineImage'; +import BrowserUtils from './browserUtils'; +import type {MarkdownRange} from './parserUtils'; import type {NodeType, TreeNode} from './treeUtils'; function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownStyle: PartialMarkdownStyle) { const node = targetElement; + + const defaultPrePadding = markdownStyle.pre?.padding ?? 2; + const preHorizontalPadding = parseStringWithUnitToNumber(markdownStyle.pre?.paddingHorizontal ?? defaultPrePadding).toString(); + const preVerticalPadding = parseStringWithUnitToNumber(markdownStyle.pre?.paddingVertical ?? defaultPrePadding).toString(); + switch (type) { case 'line': Object.assign(node.style, { @@ -44,10 +52,22 @@ function addStyleToBlock(targetElement: HTMLElement, type: NodeType, markdownSty }); break; case 'code': - Object.assign(node.style, markdownStyle.code); + Object.assign(node.style, {...markdownStyle.code, lineHeight: 1.5}); break; case 'pre': - Object.assign(node.style, markdownStyle.pre); + Object.assign(node.style, { + ...markdownStyle.pre, + backgroundColor: 'transparent', + padding: 0, + }); + Object.assign((node.parentNode as HTMLElement).style, { + padding: `${preVerticalPadding}px ${preHorizontalPadding}px`, + 'line-height': BrowserUtils.isMobile ? 1.3 : 'inherit', + position: 'relative', + width: 'fit-content', + maxWidth: '100%', + boxSizing: 'border-box', + }); break; case 'blockquote': @@ -110,4 +130,12 @@ function extendBlockStructure( return targetNode; } -export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange}; +function getTopParentTreeNode(node: TreeNode) { + let currentParentNode = node.parentNode; + while (currentParentNode && ['text', 'br', 'line', 'syntax'].includes(currentParentNode.parentNode?.type || '')) { + currentParentNode = currentParentNode?.parentNode || null; + } + return currentParentNode; +} + +export {addStyleToBlock, extendBlockStructure, isBlockMarkdownType, getFirstBlockMarkdownRange, getTopParentTreeNode}; diff --git a/src/web/utils/cursorUtils.ts b/src/web/utils/cursorUtils.ts index adc0a1b9..f6e0bbc7 100644 --- a/src/web/utils/cursorUtils.ts +++ b/src/web/utils/cursorUtils.ts @@ -1,4 +1,5 @@ import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; +import {getTopParentTreeNode} from './blockUtils'; import {findHTMLElementInTree, getTreeNodeByIndex} from './treeUtils'; import type {TreeNode} from './treeUtils'; @@ -17,8 +18,15 @@ function setCursorPosition(target: MarkdownTextInputElement, startIndex: number, const range = document.createRange(); range.selectNodeContents(target); - const startTreeNode = getTreeNodeByIndex(target.tree, start); - const endTreeNode = end && startTreeNode && (end < startTreeNode.start || end >= startTreeNode.start + startTreeNode.length) ? getTreeNodeByIndex(target.tree, end) : startTreeNode; + let startTreeNode = getTreeNodeByIndex(target.tree, start); + let endTreeNode = end && startTreeNode && (end < startTreeNode.start || end >= startTreeNode.start + startTreeNode.length) ? getTreeNodeByIndex(target.tree, end) : startTreeNode; + + const parentLine = startTreeNode?.type === 'br' && getTopParentTreeNode(startTreeNode); + if (parentLine && parentLine?.childNodes?.some((e) => e.type === 'pre')) { + startTreeNode = getTreeNodeByIndex(target.tree, start - 1); + endTreeNode = startTreeNode; + } + if (!startTreeNode || !endTreeNode) { console.error('Invalid start or end tree node'); return; diff --git a/src/web/utils/inputUtils.ts b/src/web/utils/inputUtils.ts index d52dc6d8..279f7290 100644 --- a/src/web/utils/inputUtils.ts +++ b/src/web/utils/inputUtils.ts @@ -37,7 +37,7 @@ function normalizeValue(value: 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, cursorPosition: number, inputType?: string): string { +function parseInnerHTMLToText(target: MarkdownTextInputElement | HTMLElement, cursorPosition: number, inputType?: string): 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; @@ -104,7 +104,7 @@ function parseInnerHTMLToText(target: MarkdownTextInputElement, cursorPosition: 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')) { + if ('value' in target && text === target?.value && inputType?.includes('delete')) { text = text.slice(0, cursorPosition - 1) + text.slice(cursorPosition); } return text; diff --git a/src/web/utils/parserUtils.ts b/src/web/utils/parserUtils.ts index 694b6601..5e767880 100644 --- a/src/web/utils/parserUtils.ts +++ b/src/web/utils/parserUtils.ts @@ -3,8 +3,9 @@ import {addNodeToTree, createRootTreeNode, updateTreeElementRefs} from './treeUt import type {NodeType, TreeNode} from './treeUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import {getCurrentCursorPosition, moveCursorToEnd, setCursorPosition} from './cursorUtils'; +import {handleCustomStyles} from './webStyleUtils'; import {addStyleToBlock, extendBlockStructure, getFirstBlockMarkdownRange, isBlockMarkdownType} from './blockUtils'; -import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; +import type {InlineImagesInputProps, MarkdownRange, MarkdownType} from '../../commonTypes'; import {getAnimationCurrentTimes, updateAnimationsTime} from './animationUtils'; import {sortRanges, ungroupRanges} from '../../rangeUtils'; @@ -206,13 +207,12 @@ function parseRangesToHTMLNodes( // create markdown span element const span = document.createElement('span') as HTMLMarkdownElement; span.setAttribute('data-type', range.type); + const spanNode = appendNode(span, currentParentNode, range.type, range.length); if (!disableInlineStyles) { addStyleToBlock(span, range.type, markdownStyle); } - const spanNode = appendNode(span, currentParentNode, range.type, range.length); - if (isMultiline && !disableInlineStyles && currentInput) { currentParentNode = extendBlockStructure(currentInput, currentParentNode, range, lineMarkdownRanges, text, markdownStyle, inlineImagesProps); } @@ -291,7 +291,7 @@ function updateInputStructure( if (text) { const {dom, tree} = parseRangesToHTMLNodes(text, markdownRanges, isMultiline, markdownStyle, false, targetElement, inlineImagesProps); - if (shouldForceDOMUpdate || targetElement.innerHTML !== dom.innerHTML) { + if (shouldForceDOMUpdate || targetElement.innerHTML.replaceAll(/ data-content="([^"]*)"/g, '') !== dom.innerHTML) { const animationTimes = getAnimationCurrentTimes(targetElement); targetElement.innerHTML = ''; targetElement.innerText = ''; @@ -307,7 +307,10 @@ function updateInputStructure( targetElement.tree = createRootTreeNode(targetElement); } + handleCustomStyles(target, markdownStyle); return {text, cursorPosition: cursorPosition || 0}; } export {updateInputStructure, parseRangesToHTMLNodes}; + +export type {MarkdownRange, MarkdownType}; diff --git a/src/web/utils/treeUtils.ts b/src/web/utils/treeUtils.ts index c4cbd866..667f8ee1 100644 --- a/src/web/utils/treeUtils.ts +++ b/src/web/utils/treeUtils.ts @@ -1,5 +1,6 @@ import type {HTMLMarkdownElement} from '../../MarkdownTextInput.web'; import type {MarkdownRange, MarkdownType} from '../../commonTypes'; +import {parseInnerHTMLToText} from './inputUtils'; type NodeType = MarkdownType | 'line' | 'text' | 'br' | 'block' | 'root'; @@ -74,6 +75,10 @@ function updateTreeElementRefs(treeRoot: TreeNode, element: HTMLMarkdownElement) if (currentElement) { node.element = currentElement; } + + if (currentElement?.dataset.type === 'pre') { + currentElement.setAttribute('data-content', parseInnerHTMLToText(currentElement as HTMLElement, 0)); + } } return treeRoot; diff --git a/src/web/utils/webStyleUtils.ts b/src/web/utils/webStyleUtils.ts index e0f84510..9d4e004e 100644 --- a/src/web/utils/webStyleUtils.ts +++ b/src/web/utils/webStyleUtils.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type {TextStyle} from 'react-native'; import type {MarkdownStyle} from '../../MarkdownTextInputDecoratorViewNativeComponent'; -import {mergeMarkdownStyleWithDefault} from '../../styleUtils'; +import {mergeMarkdownStyleWithDefault, parseStringWithUnitToNumber} from '../../styleUtils'; +import type {PartialMarkdownStyle} from '../../styleUtils'; +import type {MarkdownTextInputElement} from '../../MarkdownTextInput.web'; let createReactDOMStyle: (style: any) => any; try { @@ -51,4 +53,126 @@ function parseToReactDOMStyle(style: TextStyle): any { return createReactDOMStyle(preprocessStyle(style)); } -export {parseToReactDOMStyle, processMarkdownStyle}; +const CUSTOM_WEB_STYLES_ID = 'LiveMarkdownCustomWebStyles'; + +function* generateUniqueId() { + let idCounter = 0; + while (true) { + yield `live-markdown-input-${idCounter++}`; + } +} + +const idGenerator = generateUniqueId(); + +function configureCustomWebStylesheet() { + if (document.getElementById(CUSTOM_WEB_STYLES_ID) !== null) { + return; + } + + const customStyleTag = document.createElement('style'); + customStyleTag.id = CUSTOM_WEB_STYLES_ID; + + document.head.appendChild(customStyleTag); +} + +function handleCustomStyles(target: MarkdownTextInputElement, markdownStyle: PartialMarkdownStyle) { + const styleTag = document.getElementById(CUSTOM_WEB_STYLES_ID) as HTMLStyleElement; + if (!styleTag) { + return; + } + generateCodeBlocksRules(target, styleTag, markdownStyle); +} + +type Rule = {selector: string; properties: Record}; + +function addStylesheetRules(rules: Rule[], styleSheet: CSSStyleSheet) { + rules.forEach((rule) => { + const {selector, properties} = rule; + let propertiesStr = ''; + + Object.keys(properties).forEach((prop) => { + const value = properties[prop]; + propertiesStr += `${prop}: ${value};\n`; + }); + + styleSheet.insertRule(`${selector}{${propertiesStr}}`, styleSheet.cssRules.length); + }); +} + +function property(e: HTMLElement, p: string) { + return parseFloat(window.getComputedStyle(e).getPropertyValue(p).replace('px', '')); +} + +function generateCodeBlocksRules(target: MarkdownTextInputElement, styleTag: HTMLStyleElement, markdownStyle: PartialMarkdownStyle) { + const line = target.querySelector('*[data-type="line"]:has(> *[data-type="pre"]) > span:first-child'); + if (!line) { + return; + } + + const lineHeight = line.getBoundingClientRect()?.height; + const preStyles = markdownStyle.pre; + const padding = preStyles?.padding ?? 2; + const horizontalPadding = parseStringWithUnitToNumber(preStyles?.paddingHorizontal ?? padding); + const verticalPadding = parseStringWithUnitToNumber(preStyles?.paddingVertical ?? padding); + + const contentWidth = + target.offsetWidth - property(target, 'border-left-width') - property(target, 'border-left-width') - property(target, 'padding-left') - property(target, 'padding-right'); + + const rules: Rule[] = [ + { + selector: `.${target.uniqueId} *[data-type='pre']::before`, + properties: { + top: `${Math.floor(lineHeight) - 1}px`, + padding: `${verticalPadding.toString()}px ${horizontalPadding.toString()}px`, + 'background-color': `${(preStyles?.backgroundColor as string) ?? 'lightgray'}`, + 'border-radius': `${preStyles?.borderRadius?.toString() ?? '4'}px`, + 'border-color': `${preStyles?.borderColor ?? 'grey'}`, + }, + }, + { + selector: `.${target.uniqueId} *[data-type='line'] *[data-type='syntax']:has(+ *[data-type='pre'])`, + properties: { + transform: `translate(-${horizontalPadding}px, -${verticalPadding}px)`, + }, + }, + { + selector: `.${target.uniqueId} *[data-type='line'] *[data-type='pre'] + *[data-type='syntax']`, + properties: { + transform: `translate(-${horizontalPadding}px, ${verticalPadding}px)`, + }, + }, + { + selector: `.${target.uniqueId} *[data-type='line'] *[data-type='pre'] + *[data-type='syntax'] + *[data-type='text']`, + properties: { + transform: `translate(-${horizontalPadding}px, ${verticalPadding}px)`, + }, + }, + { + selector: `.${target.uniqueId} *[data-type='line']:has(> *[data-type='pre']) > *:nth-child(n+4)`, + properties: { + display: 'inline-block', + transform: `translate(-${horizontalPadding}px, ${verticalPadding}px)`, + }, + }, + ]; + + const preBlocks = [...document.querySelectorAll('*[data-type="pre"]')]; + for (let i = 0; i < preBlocks.length; i++) { + const preBlock = preBlocks[i] as HTMLElement; + const preBlockWidth = preBlock.getBoundingClientRect().width; + + rules.push({ + selector: `.${target.uniqueId} *:nth-child(${i + 1} of [data-type='line']:has(> *[data-type='pre'])) > *[data-type='pre']::before`, + properties: { + 'min-width': `min(calc(100% + 2.5px), ${preBlockWidth + horizontalPadding * 2 + 2}px)`, + 'max-width': `min(${preBlockWidth + horizontalPadding * 2 + 2}px, ${contentWidth}px)`, + }, + }); + } + + if (styleTag.sheet) { + addStylesheetRules(rules, styleTag.sheet); + } +} + +export {parseToReactDOMStyle, processMarkdownStyle, configureCustomWebStylesheet, idGenerator, handleCustomStyles};