import type {HTMLMarkdownElement, MarkdownTextInputElement} from '../../MarkdownTextInput.web'; import type {InlineImagesInputProps, MarkdownRange} from '../../commonTypes'; import {parseStringWithUnitToNumber} from '../../styleUtils'; import type {PartialMarkdownStyle} from '../../styleUtils'; import type {TreeNode} from '../utils/treeUtils'; import {createLoadingIndicator} from './loadingIndicator'; const INLINE_IMAGE_PREVIEW_DEBOUNCE_TIME_MS = 300; const INLINE_IMAGE_MIN_HEIGHT = 40; const inlineImageDefaultStyles = { position: 'absolute', bottom: 0, left: 0, }; type DebouncePreviewItem = { timeout: NodeJS.Timeout; url: string; }; const timeoutMap = new Map(); function getImagePreviewElement(targetElement: HTMLMarkdownElement) { return Array.from(targetElement?.childNodes || []).find((el) => (el as HTMLElement)?.contentEditable === 'false') as HTMLMarkdownElement | undefined; } function getContainerPaddingBottom(imageHeight: number, imagePaddingTop: number) { return `${Math.max(INLINE_IMAGE_MIN_HEIGHT, imageHeight) + imagePaddingTop}px`; } function scaleImageDimensions(imgElement: HTMLImageElement) { const {height, width} = imgElement; const {maxHeight, maxWidth, minHeight, minWidth} = imgElement.style || {}; const maxHeightValue = parseStringWithUnitToNumber(maxHeight); const maxWidthValue = parseStringWithUnitToNumber(maxWidth); const minHeightValue = parseStringWithUnitToNumber(minHeight); const minWidthValue = parseStringWithUnitToNumber(minWidth); // Calculate the initial aspect ratio const aspectRatio = width / height; // Define scaled dimensions initializing with original dimensions. let scaledWidth = width; let scaledHeight = height; // Check and apply maxWidth and maxHeight constraints if (maxWidthValue && scaledWidth > maxWidthValue) { scaledWidth = maxWidthValue; scaledHeight = scaledWidth / aspectRatio; } if (maxHeight && scaledHeight > maxHeightValue) { scaledHeight = maxHeightValue; scaledWidth = scaledHeight * aspectRatio; } // Double-check dimensions after first scaling if (scaledWidth > maxWidthValue) { scaledWidth = maxWidthValue; scaledHeight = scaledWidth / aspectRatio; } // Check and apply minWidth and minHeight constraints if (minWidthValue && scaledWidth < minWidthValue) { scaledWidth = minWidthValue; scaledHeight = scaledWidth / aspectRatio; } if (minHeightValue && scaledHeight < minHeightValue) { scaledHeight = minHeightValue; scaledWidth = scaledHeight * aspectRatio; } // Double-check dimensions after second scaling if (scaledHeight < minHeightValue) { scaledHeight = minHeightValue; scaledWidth = scaledHeight * aspectRatio; } return {height: scaledHeight, width: scaledWidth}; } function handleOnLoad( currentInput: MarkdownTextInputElement, target: HTMLMarkdownElement, imageHref: string, markdownStyle: PartialMarkdownStyle, imageContainer: HTMLSpanElement, err?: string | Event, ) { let targetElement = target; // Update the target element if the input structure was changed while the image was loading and its content hasn't changed if (!targetElement.isConnected) { const currentElement = currentInput.querySelector(`[data-type="block"][data-id="${target.getAttribute('data-id')}"]`) as HTMLMarkdownElement; const currentElementURL = getImagePreviewElement(currentElement)?.getAttribute('data-url'); const targetElementURL = getImagePreviewElement(targetElement)?.getAttribute('data-url'); if (currentElementURL && targetElementURL && currentElementURL === targetElementURL) { targetElement = currentElement; } else { return; // Prevent adding expired image previews to the input structure } } // Verify if the current spinner is for the loaded image. If not, it means that the response came after the user changed the image url const currentSpinner = currentInput.querySelector(`[data-type="spinner"][data-url="${imageHref}"]`); // Remove the spinner if (currentSpinner) { currentSpinner.remove(); } const img = imageContainer.firstChild as HTMLImageElement; const {minHeight, minWidth, maxHeight, maxWidth, borderRadius} = markdownStyle.inlineImage || {}; const imgStyle = { minHeight, minWidth, maxHeight, maxWidth, borderRadius, }; // Set the image styles Object.assign(imageContainer.style, { ...inlineImageDefaultStyles, ...(err && { ...imgStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', }), }); Object.assign(img.style, !err && imgStyle); targetElement.appendChild(imageContainer); const currentInputElement = currentInput; if (currentInput.imageElements) { currentInputElement.imageElements.push(img); } else { currentInputElement.imageElements = [img]; } const scaledImageHeight = scaleImageDimensions(img).height; Object.assign(imageContainer.style, { height: `${scaledImageHeight}px`, }); // Set paddingBottom to the height of the image so it's displayed under the block const imageMarginTop = parseStringWithUnitToNumber(`${markdownStyle.inlineImage?.marginTop}`); Object.assign(targetElement.style, { paddingBottom: getContainerPaddingBottom(scaledImageHeight, imageMarginTop), }); } function createImageElement(currentInput: MarkdownTextInputElement, targetNode: TreeNode, url: string, markdownStyle: PartialMarkdownStyle) { if (timeoutMap.has(targetNode.orderIndex)) { const mapItem = timeoutMap.get(targetNode.orderIndex); // Check if the image URL has been changed, if not, early return so the image can be loaded asynchronously const currentElement = currentInput.querySelector(`[data-type="block"][data-id="${targetNode.orderIndex}"]`) as HTMLMarkdownElement; if (mapItem?.url === url && currentElement && getImagePreviewElement(currentElement)) { return; } clearTimeout(mapItem?.timeout); timeoutMap.delete(targetNode.orderIndex); } const timeout = setTimeout(() => { const imageContainer = document.createElement('span'); imageContainer.contentEditable = 'false'; imageContainer.setAttribute('data-type', 'inline-container'); const img = new Image(); imageContainer.appendChild(img); img.contentEditable = 'false'; img.onload = () => handleOnLoad(currentInput, targetNode.element, url, markdownStyle, imageContainer); img.onerror = (err) => handleOnLoad(currentInput, targetNode.element, url, markdownStyle, imageContainer, err); img.src = url; timeoutMap.delete(targetNode.orderIndex); }, INLINE_IMAGE_PREVIEW_DEBOUNCE_TIME_MS); timeoutMap.set(`${currentInput.uniqueId}-${targetNode.orderIndex}`, { timeout, url, }); } /** Adds already loaded image element from current input content to the tree node */ function updateImageTreeNode(targetNode: TreeNode, newElement: HTMLMarkdownElement, imageMarginTop = 0) { const paddingBottom = getContainerPaddingBottom(parseStringWithUnitToNumber(newElement.style.height), imageMarginTop); targetNode.element.appendChild(newElement.cloneNode(true)); let currentParent = targetNode.element; while (currentParent.parentElement && !['line', 'block'].includes(currentParent.getAttribute('data-type') || '')) { currentParent = currentParent.parentElement as HTMLMarkdownElement; } Object.assign(currentParent.style, { paddingBottom, }); return targetNode; } /** The main function that adds inline image preview to the node */ function addInlineImagePreview( currentInput: MarkdownTextInputElement, targetNode: TreeNode, text: string, ranges: MarkdownRange[], markdownStyle: PartialMarkdownStyle, inlineImagesProps: InlineImagesInputProps, ) { const {addAuthTokenToImageURLCallback, imagePreviewAuthRequiredURLs} = inlineImagesProps; const linkRange = ranges.find((r) => r.type === 'link'); let imageHref = ''; if (linkRange) { imageHref = text.substring(linkRange.start, linkRange.start + linkRange.length); if (addAuthTokenToImageURLCallback && imagePreviewAuthRequiredURLs && imagePreviewAuthRequiredURLs.find((url) => imageHref.startsWith(url))) { imageHref = addAuthTokenToImageURLCallback(imageHref); } } const imageMarginTop = parseStringWithUnitToNumber(`${markdownStyle.inlineImage?.marginTop}`); const imageMarginBottom = parseStringWithUnitToNumber(`${markdownStyle.inlineImage?.marginBottom}`); // If the inline image markdown with the same href exists in the current input, use it instead of creating new one. // Prevents from image flickering and layout jumps const alreadyLoadedPreview = currentInput.imageElements?.find((el) => el?.src === imageHref); const loadedImageContainer = alreadyLoadedPreview?.parentElement; if (loadedImageContainer && loadedImageContainer.getAttribute('data-type') === 'inline-container') { return updateImageTreeNode(targetNode, loadedImageContainer as HTMLMarkdownElement, imageMarginTop); } // Add a loading spinner const spinner = createLoadingIndicator(imageHref, markdownStyle); if (spinner) { targetNode.element.appendChild(spinner); } Object.assign(targetNode.element.style, { display: 'block', marginBottom: `${imageMarginBottom}px`, paddingBottom: markdownStyle.loadingIndicatorContainer?.height || markdownStyle.loadingIndicator?.height || (!!markdownStyle.loadingIndicator && '30px') || undefined, }); createImageElement(currentInput, targetNode, imageHref, markdownStyle); return targetNode; } function forceRefreshAllImages(currentInput: MarkdownTextInputElement, markdownStyle: PartialMarkdownStyle) { currentInput?.querySelectorAll('img').forEach((img) => { // force image reload only if broken image icon is displayed if (img.naturalWidth > 0) { return; } const url = img.src; const imgElement = img; imgElement.src = ''; imgElement.onload = () => handleOnLoad(currentInput, img.parentElement?.parentElement as HTMLMarkdownElement, url, markdownStyle, img.parentElement as HTMLMarkdownElement); imgElement.src = `${url}#`; }); } // eslint-disable-next-line import/prefer-default-export export {addInlineImagePreview, forceRefreshAllImages};