From 29c9d2aeb6737a4ec1028d5d5c1959a712d1be15 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Mon, 29 Jun 2026 09:45:57 +0200 Subject: [PATCH 01/14] feat: tail ellipsize --- apps/example-web/src/App.tsx | 2 +- src/web/EnrichedText.tsx | 9 +- src/web/ellipsizeMode/useTailEllipsize.ts | 202 ++++++++++++++++++++++ 3 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 src/web/ellipsizeMode/useTailEllipsize.ts diff --git a/apps/example-web/src/App.tsx b/apps/example-web/src/App.tsx index 8fa6e035..f462eed8 100644 --- a/apps/example-web/src/App.tsx +++ b/apps/example-web/src/App.tsx @@ -236,7 +236,7 @@ function App() { ?.getHTML() .then((html) => { setEnrichedTextValue(html); - ref.current?.setValue(''); + // ref.current?.setValue(''); }) .catch((error: unknown) => { setEnrichedTextValue(''); diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index c26efb58..b2c395b6 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useRef, type CSSProperties } from 'react'; +import { memo, useMemo, useRef, useState, type CSSProperties } from 'react'; import type { EnrichedTextProps } from '../types'; import './EnrichedText.css'; import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties'; @@ -14,13 +14,14 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; +import { useTailEllipsize } from './ellipsizeMode/useTailEllipsize'; export const EnrichedText = memo( ({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => { const containerRef = useRef(null); + const [clampedHtml, setClampedHtml] = useState(null); const sanitizedHtml = useMemo(() => sanitizeHtml(children), [children]); - const finalHtml = useMemo( () => prepareHtmlForWeb(sanitizedHtml), [sanitizedHtml] @@ -64,6 +65,8 @@ export const EnrichedText = memo( [textStyle, themingStyle, cssVars] ); + useTailEllipsize(containerRef, finalHtml, setClampedHtml); + usePressInteractions(containerRef); useImageErrorFallback(containerRef); @@ -74,7 +77,7 @@ export const EnrichedText = memo( ref={containerRef} style={finalStyle} className={ENRICHED_TEXT_CLASSNAME} - dangerouslySetInnerHTML={{ __html: finalHtml }} + dangerouslySetInnerHTML={{ __html: clampedHtml ?? '' }} /> ); diff --git a/src/web/ellipsizeMode/useTailEllipsize.ts b/src/web/ellipsizeMode/useTailEllipsize.ts new file mode 100644 index 00000000..29b84834 --- /dev/null +++ b/src/web/ellipsizeMode/useTailEllipsize.ts @@ -0,0 +1,202 @@ +import { useLayoutEffect } from 'react'; +import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames'; + +export function useTailEllipsize( + containerRef: React.RefObject, + finalHtml: string, + setClampedHtml: (clampedHtml: string) => void +) { + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const NUMBER_OF_LINES = 2; + + // setup the hidden sandbox + const sandbox = document.createElement('div'); + const computedStyle = window.getComputedStyle(container); + + sandbox.style.position = 'absolute'; + sandbox.style.visibility = 'hidden'; + sandbox.style.top = '-9999px'; + + // copy exact CSS properties that affect text wrapping + sandbox.style.cssText = container.style.cssText; + sandbox.style.width = computedStyle.width; + sandbox.style.boxSizing = computedStyle.boxSizing; + sandbox.style.fontFamily = computedStyle.fontFamily; + sandbox.style.fontSize = computedStyle.fontSize; + sandbox.style.lineHeight = computedStyle.lineHeight; + sandbox.style.letterSpacing = computedStyle.letterSpacing; + sandbox.style.padding = computedStyle.padding; + + sandbox.className = ENRICHED_TEXT_CLASSNAME; + sandbox.innerHTML = finalHtml; + document.body.appendChild(sandbox); + + const walkerFilter = { + acceptNode: (n: Node) => { + if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; + if (n.nodeName === 'IMG') return NodeFilter.FILTER_ACCEPT; + return NodeFilter.FILTER_SKIP; + }, + }; + + const walker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + const range = document.createRange(); + + let currentLine = 1; + let lastBottom: number | null = null; + let targetNode: Node | null = null; + + // forward scan - we find the first element that overflows to the forbidden line + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + const textNode = node as Text; + const text = textNode.nodeValue || ''; + + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + currentLine++; + } + + if (currentLine > NUMBER_OF_LINES) { + targetNode = textNode; + break; + } + lastBottom = rect.bottom; + } + } else if (node.nodeName === 'IMG') { + const imgElement = node as HTMLImageElement; + const rect = imgElement.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + currentLine++; + } + + if (currentLine > NUMBER_OF_LINES) { + targetNode = imgElement; + break; + } + lastBottom = rect.bottom; + } + + if (targetNode) break; + } + + // we remove content until everything fits within the given number of lines + if (targetNode) { + // remove all content on "the right" of the targetNode + let current: Node | null = targetNode; + while (current && current !== sandbox) { + let sibling = current.nextSibling; + while (sibling) { + const next = sibling.nextSibling; + sibling.parentNode?.removeChild(sibling); + sibling = next; + } + current = current.parentNode; + } + + if (targetNode.nodeName === 'IMG') { + targetNode.parentNode?.removeChild(targetNode); + } + + // now we append the ellipsis "...", + // but now it might overflow again, so we remove the content backwards until it fits + let isOverflowing = true; + while (isOverflowing) { + const backwardWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + + let lastNode: Node | null = null; + while (backwardWalker.nextNode()) { + lastNode = backwardWalker.currentNode; + } + + if (!lastNode) break; + + // we have to handle inline images separately + if (lastNode.nodeName === 'IMG') { + // create the ellipsis "..." after an image + const ellipsisNode = document.createTextNode('...'); + lastNode.parentNode?.insertBefore(ellipsisNode, lastNode.nextSibling); + + range.selectNodeContents(ellipsisNode); + const rect = range.getBoundingClientRect(); + + // check if the image + ellipsis pushed us onto the forbidden line + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + // it overflowed + ellipsisNode.parentNode?.removeChild(ellipsisNode); + lastNode.parentNode?.removeChild(lastNode); + } else { + // it fits + isOverflowing = false; + } + } + // handling normal text nodes + else { + const lastTextNode = lastNode as Text; + let text = lastTextNode.nodeValue || ''; + + if (text.endsWith('...')) text = text.slice(0, -3); + + if (text.trim().length === 0) { + let parent = lastTextNode.parentNode; + lastTextNode.parentNode?.removeChild(lastTextNode); + + while ( + parent && + parent !== sandbox && + parent.childNodes.length === 0 + ) { + const p = parent.parentNode; + parent.parentNode?.removeChild(parent); + parent = p; + } + continue; + } + + // append the ellipsis + lastTextNode.nodeValue = text + '...'; + + range.setStart(lastTextNode, lastTextNode.nodeValue.length - 1); + range.setEnd(lastTextNode, lastTextNode.nodeValue.length); + const rect = range.getBoundingClientRect(); + + // check if it overflows with the ellipsis + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + // it overflows, delete one more character + lastTextNode.nodeValue = text.slice(0, -1); + } else { + // it fits + isOverflowing = false; + } + } + } + + setClampedHtml(sandbox.innerHTML); + } else { + setClampedHtml(finalHtml); + } + + document.body.removeChild(sandbox); + }, [containerRef, finalHtml, setClampedHtml]); +} From 272b51dd2da3b22d473765c324e14279f3f6d5c6 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 30 Jun 2026 17:57:32 +0200 Subject: [PATCH 02/14] feat: head ellipsize --- src/web/EnrichedText.tsx | 5 +- src/web/ellipsizeMode/useHeadEllipsize.ts | 369 ++++++++++++++++++++++ src/web/ellipsizeMode/useTailEllipsize.ts | 80 ++++- 3 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 src/web/ellipsizeMode/useHeadEllipsize.ts diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index b2c395b6..261bffd3 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -14,7 +14,7 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; -import { useTailEllipsize } from './ellipsizeMode/useTailEllipsize'; +import { useHeadEllipsize } from './ellipsizeMode/useHeadEllipsize'; export const EnrichedText = memo( ({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => { @@ -65,7 +65,8 @@ export const EnrichedText = memo( [textStyle, themingStyle, cssVars] ); - useTailEllipsize(containerRef, finalHtml, setClampedHtml); + // useTailEllipsize(containerRef, finalHtml, setClampedHtml); + useHeadEllipsize(containerRef, finalHtml, setClampedHtml); usePressInteractions(containerRef); useImageErrorFallback(containerRef); diff --git a/src/web/ellipsizeMode/useHeadEllipsize.ts b/src/web/ellipsizeMode/useHeadEllipsize.ts new file mode 100644 index 00000000..4351b9eb --- /dev/null +++ b/src/web/ellipsizeMode/useHeadEllipsize.ts @@ -0,0 +1,369 @@ +import { useLayoutEffect } from 'react'; +import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames'; + +const BLOCK_TAGS = new Set([ + 'P', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'LI', + 'BLOCKQUOTE', + 'CODEBLOCK', +]); + +export function useHeadEllipsize( + containerRef: React.RefObject, + finalHtml: string, + setClampedHtml: (clampedHtml: string) => void +) { + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const NUMBER_OF_LINES = 2; + + const sandbox = document.createElement('div'); + const computedStyle = window.getComputedStyle(container); + + sandbox.style.position = 'absolute'; + sandbox.style.visibility = 'hidden'; + sandbox.style.top = '-9999px'; + + sandbox.style.cssText = container.style.cssText; + sandbox.style.width = computedStyle.width; + sandbox.style.boxSizing = computedStyle.boxSizing; + sandbox.style.fontFamily = computedStyle.fontFamily; + sandbox.style.fontSize = computedStyle.fontSize; + sandbox.style.lineHeight = computedStyle.lineHeight; + sandbox.style.letterSpacing = computedStyle.letterSpacing; + sandbox.style.padding = computedStyle.padding; + sandbox.style.wordBreak = computedStyle.wordBreak; + sandbox.style.overflowWrap = computedStyle.overflowWrap; + + sandbox.className = ENRICHED_TEXT_CLASSNAME; + sandbox.innerHTML = finalHtml; + + container.appendChild(sandbox); + + const walkerFilter = { + acceptNode: (n: Node) => { + if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; + if (n.nodeName === 'IMG' || n.nodeName === 'BR') + return NodeFilter.FILTER_ACCEPT; + + // let the walker see the empty blocks + if (n.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(n.nodeName)) { + const el = n as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + const hasBr = !!el.querySelector('br'); + if (textEmpty && !hasImg && !hasBr) return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + }; + + const getBlockParent = (node: Node | null): Element | null => { + let current = node?.parentElement; + while (current && current !== sandbox) { + if (BLOCK_TAGS.has(current.tagName)) return current; + current = current.parentElement; + } + return sandbox; + }; + + const removeAndCleanUp = (nd: Node) => { + let parent: ParentNode | null = nd.parentNode; + if (parent) { + parent.removeChild(nd); + while (parent && parent !== sandbox && parent.childNodes.length === 0) { + const p: ParentNode | null = parent.parentNode; + p?.removeChild(parent); + parent = p; + } + } + }; + + // forward scan - we gather data about nodes that start each new line + const walker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + const range = document.createRange(); + + const lineStarts: Array<{ node: Node; index: number }> = []; + let currentLine = 0; + let lastBottom: number | null = null; + + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + const textNode = node as Text; + const text = textNode.nodeValue || ''; + + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom === null || rect.bottom > lastBottom + 4) { + currentLine++; + lineStarts[currentLine] = { node: textNode, index: i }; + } + lastBottom = rect.bottom; + } + } else if ( + node.nodeName === 'IMG' || + node.nodeName === 'BR' || + BLOCK_TAGS.has(node.nodeName) + ) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + + if (rect.height === 0 && node.nodeName !== 'BR') continue; + + if (lastBottom === null || rect.bottom > lastBottom + 4) { + currentLine++; + lineStarts[currentLine] = { node: el, index: 0 }; + } + lastBottom = rect.bottom; + } + } + + const originalLastLine = currentLine; + + // we need to truncate as number of lines overflows the given maximum + if (originalLastLine > NUMBER_OF_LINES) { + let targetNode: Node | null = null; + let overflowStartNode: Node | null = null; + + // if true, it means that there are lines that we can safely delete + // between the first valid ones that we leave untouched + // and the last two lines that will become the line with the ellipsis + if (originalLastLine > NUMBER_OF_LINES + 1) { + const lastMark = lineStarts[originalLastLine]!; + // the second last line + let safeMark = lineStarts[originalLastLine - 1]!; + + // if they are in different blocks, it means we can safely + // delete also the second to last line + if (getBlockParent(safeMark.node) !== getBlockParent(lastMark.node)) { + safeMark = lastMark; + } + + // we need to perform two splits to safely delete only a part of block's content + if (safeMark.node.nodeType === Node.TEXT_NODE && safeMark.index > 0) { + const t = safeMark.node as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(safeMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, safeMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } else { + targetNode = safeMark.node; + } + + const startMark = lineStarts[NUMBER_OF_LINES]!; + overflowStartNode = startMark.node; + if ( + overflowStartNode.nodeType === Node.TEXT_NODE && + startMark.index > 0 + ) { + const t = overflowStartNode as Text; + const leftSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(leftSplit, t.nextSibling); + overflowStartNode = leftSplit; + } + + // we delete the lines + const rmWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + let past = false; + const toRm: Node[] = []; + while (rmWalker.nextNode()) { + if (rmWalker.currentNode === overflowStartNode) past = true; + else if (rmWalker.currentNode === targetNode) break; + + if (past) toRm.push(rmWalker.currentNode); + } + toRm.forEach(removeAndCleanUp); + } else { + // this branch handles the case where exactly one line overflows, + // which means we have no lines to safely delete before those two last lines + + const startMark = lineStarts[NUMBER_OF_LINES]!; // the second to last line + const tailMark = lineStarts[originalLastLine]!; + + // if the second to last line is in a different block than the last one, + // we can safely delete it + if (getBlockParent(startMark.node) !== getBlockParent(tailMark.node)) { + overflowStartNode = startMark.node; + + // we perform the same two splits we needed in the previous 'if' branch + if ( + overflowStartNode.nodeType === Node.TEXT_NODE && + startMark.index > 0 + ) { + const t = overflowStartNode as Text; + const leftSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(leftSplit, t.nextSibling); + overflowStartNode = leftSplit; + } + + targetNode = tailMark.node; + if (targetNode.nodeType === Node.TEXT_NODE && tailMark.index > 0) { + const t = targetNode as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(tailMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, tailMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } + + // delete the line + const rmWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + let past = false; + const toRm: Node[] = []; + while (rmWalker.nextNode()) { + if (rmWalker.currentNode === overflowStartNode) past = true; + else if (rmWalker.currentNode === targetNode) break; + + if (past) toRm.push(rmWalker.currentNode); + } + toRm.forEach(removeAndCleanUp); + } else { + // the last two lines are in the same block - needs character by character truncation + targetNode = startMark.node; + + // the split is also needed here so we can insert a
if needed + if (targetNode.nodeType === Node.TEXT_NODE && startMark.index > 0) { + const t = targetNode as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } + } + } + + const ellipsisNode = document.createTextNode('...'); + + if (targetNode) { + // if we have an empty block, eg.
  • , we append the ellipsis inside it + if ( + targetNode.nodeType === Node.ELEMENT_NODE && + BLOCK_TAGS.has(targetNode.nodeName) + ) { + targetNode.appendChild(ellipsisNode); + } else { + const prevWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + prevWalker.currentNode = targetNode; + const prevNode = prevWalker.previousNode(); + + // we need a
    to make sure the ellipsis sits at the start of the line + if ( + prevNode && + prevNode.nodeName !== 'BR' && + getBlockParent(prevNode) === getBlockParent(targetNode) + ) { + const brNode = document.createElement('br'); + targetNode.parentNode?.insertBefore(brNode, targetNode); + } + + targetNode.parentNode?.insertBefore(ellipsisNode, targetNode); + } + } + + // now we are left with at most two lines that we have to merge into one - the ellipsis line + + // helper to check if everything on those two last lines fit into one + const checkFits = () => { + range.selectNodeContents(ellipsisNode); + const targetBottom = range.getBoundingClientRect().bottom; + + let lastNode: Node | null = null; + const w = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + w.currentNode = ellipsisNode; + while (w.nextNode()) lastNode = w.currentNode; + + if (!lastNode) return true; + + if (lastNode.nodeType === Node.TEXT_NODE) { + if ((lastNode as Text).length === 0) return true; + range.selectNodeContents(lastNode); + return range.getBoundingClientRect().bottom <= targetBottom + 4; + } else { + return ( + (lastNode as HTMLElement).getBoundingClientRect().bottom <= + targetBottom + 4 + ); + } + }; + + // truncating until those two last lines fit + while (true) { + if (checkFits()) break; + + const tw = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + tw.currentNode = ellipsisNode; + const nextNode = tw.nextNode(); + + if (!nextNode) break; + + if ( + nextNode.nodeName === 'IMG' || + nextNode.nodeName === 'BR' || + BLOCK_TAGS.has(nextNode.nodeName) + ) { + removeAndCleanUp(nextNode); + } else { + const t = nextNode as Text; + if (t.length > 1) t.nodeValue = t.nodeValue!.slice(1); + else removeAndCleanUp(t); + } + } + + setClampedHtml(sandbox.innerHTML); + } else { + setClampedHtml(finalHtml); + } + + container.removeChild(sandbox); + }, [containerRef, finalHtml, setClampedHtml]); +} diff --git a/src/web/ellipsizeMode/useTailEllipsize.ts b/src/web/ellipsizeMode/useTailEllipsize.ts index 29b84834..903c9a8b 100644 --- a/src/web/ellipsizeMode/useTailEllipsize.ts +++ b/src/web/ellipsizeMode/useTailEllipsize.ts @@ -1,6 +1,19 @@ import { useLayoutEffect } from 'react'; import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames'; +const BLOCK_TAGS = new Set([ + 'P', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'LI', + 'BLOCKQUOTE', + 'CODEBLOCK', +]); + export function useTailEllipsize( containerRef: React.RefObject, finalHtml: string, @@ -37,7 +50,17 @@ export function useTailEllipsize( const walkerFilter = { acceptNode: (n: Node) => { if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; - if (n.nodeName === 'IMG') return NodeFilter.FILTER_ACCEPT; + if (n.nodeName === 'IMG' || n.nodeName === 'BR') + return NodeFilter.FILTER_ACCEPT; + + // let the walker see the empty blocks + if (n.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(n.nodeName)) { + const el = n as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + if (textEmpty && !hasImg) return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; }, }; @@ -51,6 +74,7 @@ export function useTailEllipsize( let currentLine = 1; let lastBottom: number | null = null; + // the node that starts the overflow let targetNode: Node | null = null; // forward scan - we find the first element that overflows to the forbidden line @@ -77,9 +101,13 @@ export function useTailEllipsize( } lastBottom = rect.bottom; } - } else if (node.nodeName === 'IMG') { - const imgElement = node as HTMLImageElement; - const rect = imgElement.getBoundingClientRect(); + } else if ( + node.nodeName === 'IMG' || + node.nodeName === 'BR' || + (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName)) + ) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); if (rect.height === 0) continue; @@ -88,7 +116,7 @@ export function useTailEllipsize( } if (currentLine > NUMBER_OF_LINES) { - targetNode = imgElement; + targetNode = el; break; } lastBottom = rect.bottom; @@ -151,6 +179,23 @@ export function useTailEllipsize( isOverflowing = false; } } + // handle empty blocks (like an empty
  • ) + else if ( + lastNode.nodeType === Node.ELEMENT_NODE && + BLOCK_TAGS.has(lastNode.nodeName) + ) { + const ellipsisNode = document.createTextNode('...'); + lastNode.appendChild(ellipsisNode); + + range.selectNodeContents(ellipsisNode); + const rect = range.getBoundingClientRect(); + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + lastNode.parentNode?.removeChild(lastNode); + } else { + isOverflowing = false; + } + } // handling normal text nodes else { const lastTextNode = lastNode as Text; @@ -162,14 +207,23 @@ export function useTailEllipsize( let parent = lastTextNode.parentNode; lastTextNode.parentNode?.removeChild(lastTextNode); - while ( - parent && - parent !== sandbox && - parent.childNodes.length === 0 - ) { - const p = parent.parentNode; - parent.parentNode?.removeChild(parent); - parent = p; + // if text removal resulted in an empty block, we have to remove it + while (parent && parent !== sandbox) { + const el = parent as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + const hasBr = !!el.querySelector('br'); + + if ( + parent.childNodes.length === 0 || + (textEmpty && !hasImg && !hasBr) + ) { + const p = parent.parentNode; + parent.parentNode?.removeChild(parent); + parent = p; + } else { + break; + } } continue; } From bc2133f2a011acba0b4efae2cd281bab80626580 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Tue, 30 Jun 2026 23:20:10 +0200 Subject: [PATCH 03/14] feat: clip ellipsize --- src/web/EnrichedText.tsx | 7 +- src/web/ellipsizeMode/useClip.ts | 256 ++++++++++++++ src/web/ellipsizeMode/useMiddleEllipsize.ts | 369 ++++++++++++++++++++ 3 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 src/web/ellipsizeMode/useClip.ts create mode 100644 src/web/ellipsizeMode/useMiddleEllipsize.ts diff --git a/src/web/EnrichedText.tsx b/src/web/EnrichedText.tsx index 261bffd3..07d91ce1 100644 --- a/src/web/EnrichedText.tsx +++ b/src/web/EnrichedText.tsx @@ -14,7 +14,9 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb'; import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables'; import { useImageErrorFallback } from './useImageErrorFallback'; import { usePressInteractions } from './usePressInteractions'; -import { useHeadEllipsize } from './ellipsizeMode/useHeadEllipsize'; +// import { useHeadEllipsize } from './ellipsizeMode/useHeadEllipsize'; +import { useClip } from './ellipsizeMode/useClip'; +// import { useTailEllipsize } from './ellipsizeMode/useTailEllipsize'; export const EnrichedText = memo( ({ children, htmlStyle, style, selectionColor }: EnrichedTextProps) => { @@ -66,7 +68,8 @@ export const EnrichedText = memo( ); // useTailEllipsize(containerRef, finalHtml, setClampedHtml); - useHeadEllipsize(containerRef, finalHtml, setClampedHtml); + // useHeadEllipsize(containerRef, finalHtml, setClampedHtml); + useClip(containerRef, finalHtml, setClampedHtml); usePressInteractions(containerRef); useImageErrorFallback(containerRef); diff --git a/src/web/ellipsizeMode/useClip.ts b/src/web/ellipsizeMode/useClip.ts new file mode 100644 index 00000000..7f2c0f10 --- /dev/null +++ b/src/web/ellipsizeMode/useClip.ts @@ -0,0 +1,256 @@ +import { useLayoutEffect } from 'react'; +import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames'; + +const BLOCK_TAGS = new Set([ + 'P', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'LI', + 'BLOCKQUOTE', + 'CODEBLOCK', +]); + +export function useClip( + containerRef: React.RefObject, + finalHtml: string, + setClampedHtml: (clampedHtml: string) => void +) { + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const NUMBER_OF_LINES = 2; + + // setup the hidden sandbox + const sandbox = document.createElement('div'); + const computedStyle = window.getComputedStyle(container); + + sandbox.style.position = 'absolute'; + sandbox.style.visibility = 'hidden'; + sandbox.style.top = '-9999px'; + + // copy exact CSS properties that affect text wrapping + sandbox.style.cssText = container.style.cssText; + sandbox.style.width = computedStyle.width; + sandbox.style.boxSizing = computedStyle.boxSizing; + sandbox.style.fontFamily = computedStyle.fontFamily; + sandbox.style.fontSize = computedStyle.fontSize; + sandbox.style.lineHeight = computedStyle.lineHeight; + sandbox.style.letterSpacing = computedStyle.letterSpacing; + sandbox.style.padding = computedStyle.padding; + + sandbox.className = ENRICHED_TEXT_CLASSNAME; + sandbox.innerHTML = finalHtml; + document.body.appendChild(sandbox); + + const walkerFilter = { + acceptNode: (n: Node) => { + if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; + if (n.nodeName === 'IMG' || n.nodeName === 'BR') + return NodeFilter.FILTER_ACCEPT; + + // let the walker see the empty blocks + if (n.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(n.nodeName)) { + const el = n as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + if (textEmpty && !hasImg) return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + }; + + const walker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + const range = document.createRange(); + + let currentLine = 1; + let lastBottom: number | null = null; + // the node that starts the overflow + let targetNode: Node | null = null; + + // forward scan - we find the first element that overflows to the forbidden line + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + const textNode = node as Text; + const text = textNode.nodeValue || ''; + + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + currentLine++; + } + + if (currentLine > NUMBER_OF_LINES) { + targetNode = textNode; + break; + } + lastBottom = rect.bottom; + } + } else if ( + node.nodeName === 'IMG' || + node.nodeName === 'BR' || + (node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName)) + ) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + currentLine++; + } + + if (currentLine > NUMBER_OF_LINES) { + targetNode = el; + break; + } + lastBottom = rect.bottom; + } + + if (targetNode) break; + } + + // we remove content until everything fits within the given number of lines + if (targetNode) { + // remove all content on "the right" of the targetNode + let current: Node | null = targetNode; + while (current && current !== sandbox) { + let sibling = current.nextSibling; + while (sibling) { + const next = sibling.nextSibling; + sibling.parentNode?.removeChild(sibling); + sibling = next; + } + current = current.parentNode; + } + + if (targetNode.nodeName === 'IMG') { + targetNode.parentNode?.removeChild(targetNode); + } + + // now we append the ellipsis "...", + // but now it might overflow again, so we remove the content backwards until it fits + let isOverflowing = true; + while (isOverflowing) { + const backwardWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + + let lastNode: Node | null = null; + while (backwardWalker.nextNode()) { + lastNode = backwardWalker.currentNode; + } + + if (!lastNode) break; + + // we have to handle inline images separately + if (lastNode.nodeName === 'IMG') { + // create the ellipsis "..." after an image + const ellipsisNode = document.createTextNode('...'); + lastNode.parentNode?.insertBefore(ellipsisNode, lastNode.nextSibling); + + range.selectNodeContents(ellipsisNode); + const rect = range.getBoundingClientRect(); + + // check if the image + ellipsis pushed us onto the forbidden line + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + // it overflowed + ellipsisNode.parentNode?.removeChild(ellipsisNode); + lastNode.parentNode?.removeChild(lastNode); + } else { + // it fits + isOverflowing = false; + } + } + // handle empty blocks (like an empty
  • ) + else if ( + lastNode.nodeType === Node.ELEMENT_NODE && + BLOCK_TAGS.has(lastNode.nodeName) + ) { + const ellipsisNode = document.createTextNode('...'); + lastNode.appendChild(ellipsisNode); + + range.selectNodeContents(ellipsisNode); + const rect = range.getBoundingClientRect(); + + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + lastNode.parentNode?.removeChild(lastNode); + } else { + isOverflowing = false; + } + } + // handling normal text nodes + else { + const lastTextNode = lastNode as Text; + let text = lastTextNode.nodeValue || ''; + + if (text.endsWith('...')) text = text.slice(0, -3); + + if (text.trim().length === 0) { + let parent = lastTextNode.parentNode; + lastTextNode.parentNode?.removeChild(lastTextNode); + + // if text removal resulted in an empty block, we have to remove it + while (parent && parent !== sandbox) { + const el = parent as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + const hasBr = !!el.querySelector('br'); + + if ( + parent.childNodes.length === 0 || + (textEmpty && !hasImg && !hasBr) + ) { + const p = parent.parentNode; + parent.parentNode?.removeChild(parent); + parent = p; + } else { + break; + } + } + continue; + } + + // append the ellipsis + lastTextNode.nodeValue = text + '...'; + + range.setStart(lastTextNode, lastTextNode.nodeValue.length - 1); + range.setEnd(lastTextNode, lastTextNode.nodeValue.length); + const rect = range.getBoundingClientRect(); + + // check if it overflows with the ellipsis + if (lastBottom !== null && rect.bottom > lastBottom + 4) { + // it overflows, delete one more character + lastTextNode.nodeValue = text.slice(0, -1); + } else { + // it fits + isOverflowing = false; + } + } + } + + setClampedHtml(sandbox.innerHTML); + } else { + setClampedHtml(finalHtml); + } + + document.body.removeChild(sandbox); + }, [containerRef, finalHtml, setClampedHtml]); +} diff --git a/src/web/ellipsizeMode/useMiddleEllipsize.ts b/src/web/ellipsizeMode/useMiddleEllipsize.ts new file mode 100644 index 00000000..0a71ca3a --- /dev/null +++ b/src/web/ellipsizeMode/useMiddleEllipsize.ts @@ -0,0 +1,369 @@ +import { useLayoutEffect } from 'react'; +import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames'; + +const BLOCK_TAGS = new Set([ + 'P', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'LI', + 'BLOCKQUOTE', + 'CODEBLOCK', +]); + +export function useMiddleEllipsize( + containerRef: React.RefObject, + finalHtml: string, + setClampedHtml: (clampedHtml: string) => void +) { + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + const NUMBER_OF_LINES = 2; + + const sandbox = document.createElement('div'); + const computedStyle = window.getComputedStyle(container); + + sandbox.style.position = 'absolute'; + sandbox.style.visibility = 'hidden'; + sandbox.style.top = '-9999px'; + + sandbox.style.cssText = container.style.cssText; + sandbox.style.width = computedStyle.width; + sandbox.style.boxSizing = computedStyle.boxSizing; + sandbox.style.fontFamily = computedStyle.fontFamily; + sandbox.style.fontSize = computedStyle.fontSize; + sandbox.style.lineHeight = computedStyle.lineHeight; + sandbox.style.letterSpacing = computedStyle.letterSpacing; + sandbox.style.padding = computedStyle.padding; + sandbox.style.wordBreak = computedStyle.wordBreak; + sandbox.style.overflowWrap = computedStyle.overflowWrap; + + sandbox.className = ENRICHED_TEXT_CLASSNAME; + sandbox.innerHTML = finalHtml; + + container.appendChild(sandbox); + + const walkerFilter = { + acceptNode: (n: Node) => { + if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT; + if (n.nodeName === 'IMG' || n.nodeName === 'BR') + return NodeFilter.FILTER_ACCEPT; + + // let the walker see the empty blocks + if (n.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(n.nodeName)) { + const el = n as HTMLElement; + const textEmpty = !el.textContent?.trim(); + const hasImg = !!el.querySelector('img'); + const hasBr = !!el.querySelector('br'); + if (textEmpty && !hasImg && !hasBr) return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + }; + + const getBlockParent = (node: Node | null): Element | null => { + let current = node?.parentElement; + while (current && current !== sandbox) { + if (BLOCK_TAGS.has(current.tagName)) return current; + current = current.parentElement; + } + return sandbox; + }; + + const removeAndCleanUp = (nd: Node) => { + let parent: ParentNode | null = nd.parentNode; + if (parent) { + parent.removeChild(nd); + while (parent && parent !== sandbox && parent.childNodes.length === 0) { + const p: ParentNode | null = parent.parentNode; + p?.removeChild(parent); + parent = p; + } + } + }; + + // forward scan - we gather data about nodes that start each new line + const walker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + const range = document.createRange(); + + const lineStarts: Array<{ node: Node; index: number }> = []; + let currentLine = 0; + let lastBottom: number | null = null; + + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.nodeType === Node.TEXT_NODE) { + const textNode = node as Text; + const text = textNode.nodeValue || ''; + + for (let i = 0; i < text.length; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const rect = range.getBoundingClientRect(); + + if (rect.height === 0) continue; + + if (lastBottom === null || rect.bottom > lastBottom + 4) { + currentLine++; + lineStarts[currentLine] = { node: textNode, index: i }; + } + lastBottom = rect.bottom; + } + } else if ( + node.nodeName === 'IMG' || + node.nodeName === 'BR' || + BLOCK_TAGS.has(node.nodeName) + ) { + const el = node as HTMLElement; + const rect = el.getBoundingClientRect(); + + if (rect.height === 0 && node.nodeName !== 'BR') continue; + + if (lastBottom === null || rect.bottom > lastBottom + 4) { + currentLine++; + lineStarts[currentLine] = { node: el, index: 0 }; + } + lastBottom = rect.bottom; + } + } + + const originalLastLine = currentLine; + + // we need to truncate as number of lines overflows the given maximum + if (originalLastLine > NUMBER_OF_LINES) { + let targetNode: Node | null = null; + let overflowStartNode: Node | null = null; + + // if true, it means that there are lines that we can safely delete + // between the first valid ones that we leave untouched + // and the last two lines that will become the line with the ellipsis + if (originalLastLine > NUMBER_OF_LINES + 1) { + const lastMark = lineStarts[originalLastLine]!; + // the second last line + let safeMark = lineStarts[originalLastLine - 1]!; + + // if they are in different blocks, it means we can safely + // delete also the second to last line + if (getBlockParent(safeMark.node) !== getBlockParent(lastMark.node)) { + safeMark = lastMark; + } + + // we need to perform two splits to safely delete only a part of block's content + if (safeMark.node.nodeType === Node.TEXT_NODE && safeMark.index > 0) { + const t = safeMark.node as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(safeMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, safeMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } else { + targetNode = safeMark.node; + } + + const startMark = lineStarts[NUMBER_OF_LINES]!; + overflowStartNode = startMark.node; + if ( + overflowStartNode.nodeType === Node.TEXT_NODE && + startMark.index > 0 + ) { + const t = overflowStartNode as Text; + const leftSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(leftSplit, t.nextSibling); + overflowStartNode = leftSplit; + } + + // we delete the lines + const rmWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + let past = false; + const toRm: Node[] = []; + while (rmWalker.nextNode()) { + if (rmWalker.currentNode === overflowStartNode) past = true; + else if (rmWalker.currentNode === targetNode) break; + + if (past) toRm.push(rmWalker.currentNode); + } + toRm.forEach(removeAndCleanUp); + } else { + // this branch handles the case where exactly one line overflows, + // which means we have no lines to safely delete before those two last lines + + const startMark = lineStarts[NUMBER_OF_LINES]!; // the second to last line + const tailMark = lineStarts[originalLastLine]!; + + // if the second to last line is in a different block than the last one, + // we can safely delete it + if (getBlockParent(startMark.node) !== getBlockParent(tailMark.node)) { + overflowStartNode = startMark.node; + + // we perform the same two splits we needed in the previous 'if' branch + if ( + overflowStartNode.nodeType === Node.TEXT_NODE && + startMark.index > 0 + ) { + const t = overflowStartNode as Text; + const leftSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(leftSplit, t.nextSibling); + overflowStartNode = leftSplit; + } + + targetNode = tailMark.node; + if (targetNode.nodeType === Node.TEXT_NODE && tailMark.index > 0) { + const t = targetNode as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(tailMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, tailMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } + + // delete the line + const rmWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + let past = false; + const toRm: Node[] = []; + while (rmWalker.nextNode()) { + if (rmWalker.currentNode === overflowStartNode) past = true; + else if (rmWalker.currentNode === targetNode) break; + + if (past) toRm.push(rmWalker.currentNode); + } + toRm.forEach(removeAndCleanUp); + } else { + // the last two lines are in the same block - needs character by character truncation + targetNode = startMark.node; + + // the split is also needed here so we can insert a
    if needed + if (targetNode.nodeType === Node.TEXT_NODE && startMark.index > 0) { + const t = targetNode as Text; + const rightSplit = document.createTextNode( + t.nodeValue!.slice(startMark.index) + ); + t.nodeValue = t.nodeValue!.slice(0, startMark.index); + t.parentNode?.insertBefore(rightSplit, t.nextSibling); + targetNode = rightSplit; + } + } + } + + const ellipsisNode = document.createTextNode('...'); + + if (targetNode) { + // if we have an empty block, eg.
  • , we append the ellipsis inside it + if ( + targetNode.nodeType === Node.ELEMENT_NODE && + BLOCK_TAGS.has(targetNode.nodeName) + ) { + targetNode.appendChild(ellipsisNode); + } else { + const prevWalker = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + prevWalker.currentNode = targetNode; + const prevNode = prevWalker.previousNode(); + + // we need a
    to make sure the ellipsis sits at the start of the line + if ( + prevNode && + prevNode.nodeName !== 'BR' && + getBlockParent(prevNode) === getBlockParent(targetNode) + ) { + const brNode = document.createElement('br'); + targetNode.parentNode?.insertBefore(brNode, targetNode); + } + + targetNode.parentNode?.insertBefore(ellipsisNode, targetNode); + } + } + + // now we are left with at most two lines that we have to merge into one - the ellipsis line + + // helper to check if everything on those two last lines fit into one + const checkFits = () => { + range.selectNodeContents(ellipsisNode); + const targetBottom = range.getBoundingClientRect().bottom; + + let lastNode: Node | null = null; + const w = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + w.currentNode = ellipsisNode; + while (w.nextNode()) lastNode = w.currentNode; + + if (!lastNode) return true; + + if (lastNode.nodeType === Node.TEXT_NODE) { + if ((lastNode as Text).length === 0) return true; + range.selectNodeContents(lastNode); + return range.getBoundingClientRect().bottom <= targetBottom + 4; + } else { + return ( + (lastNode as HTMLElement).getBoundingClientRect().bottom <= + targetBottom + 4 + ); + } + }; + + // truncating until those two last lines fit + while (true) { + if (checkFits()) break; + + const tw = document.createTreeWalker( + sandbox, + NodeFilter.SHOW_ALL, + walkerFilter + ); + tw.currentNode = ellipsisNode; + const nextNode = tw.nextNode(); + + if (!nextNode) break; + + if ( + nextNode.nodeName === 'IMG' || + nextNode.nodeName === 'BR' || + BLOCK_TAGS.has(nextNode.nodeName) + ) { + removeAndCleanUp(nextNode); + } else { + const t = nextNode as Text; + if (t.length > 1) t.nodeValue = t.nodeValue!.slice(1); + else removeAndCleanUp(t); + } + } + + setClampedHtml(sandbox.innerHTML); + } else { + setClampedHtml(finalHtml); + } + + container.removeChild(sandbox); + }, [containerRef, finalHtml, setClampedHtml]); +} From 3ea1138486aabe72df8ce6537565980b93618a91 Mon Sep 17 00:00:00 2001 From: Krystian Sienkiewicz Date: Wed, 1 Jul 2026 00:02:47 +0200 Subject: [PATCH 04/14] feat: ellipsizeMode e2e tests --- .../ellipsize-multi-block-3-lines-clip.png | Bin 0 -> 12532 bytes .../ellipsize-multi-block-3-lines-head.png | Bin 0 -> 10271 bytes .../ellipsize-multi-block-3-lines-middle.png | Bin 0 -> 10271 bytes .../ellipsize-multi-block-3-lines-tail.png | Bin 0 -> 12633 bytes .../ellipsize-paragraph-1-line-clip.png | Bin 0 -> 3774 bytes .../ellipsize-paragraph-1-line-head.png | Bin 0 -> 4022 bytes .../ellipsize-paragraph-1-line-middle.png | Bin 0 -> 4022 bytes .../ellipsize-paragraph-1-line-tail.png | Bin 0 -> 3648 bytes .../ellipsize-paragraph-2-lines-clip.png | Bin 0 -> 6382 bytes .../ellipsize-paragraph-2-lines-head.png | Bin 0 -> 6728 bytes .../ellipsize-paragraph-2-lines-middle.png | Bin 0 -> 6728 bytes .../ellipsize-paragraph-2-lines-tail.png | Bin 0 -> 6330 bytes .../ellipsize-paragraph-no-clamp.png | Bin 0 -> 10952 bytes .playwright/tests/ellipsizeVisual.spec.ts | 126 ++++++ apps/example-web/src/RouteSelector.tsx | 5 + .../src/testScreens/TestEllipsize.tsx | 108 +++++ src/index.tsx | 1 + src/web/EnrichedText.tsx | 56 ++- src/web/ellipsizeMode/clip.ts | 237 +++++++++++ src/web/ellipsizeMode/headEllipsize.ts | 364 +++++++++++++++++ src/web/ellipsizeMode/middleEllipsize.ts | 364 +++++++++++++++++ src/web/ellipsizeMode/tailEllipsize.ts | 251 ++++++++++++ src/web/ellipsizeMode/useClip.ts | 256 ------------ src/web/ellipsizeMode/useHeadEllipsize.ts | 369 ------------------ src/web/ellipsizeMode/useMiddleEllipsize.ts | 369 ------------------ src/web/ellipsizeMode/useTailEllipsize.ts | 256 ------------ 26 files changed, 1504 insertions(+), 1258 deletions(-) create mode 100644 .playwright/screenshots/ellipsize-multi-block-3-lines-clip.png create mode 100644 .playwright/screenshots/ellipsize-multi-block-3-lines-head.png create mode 100644 .playwright/screenshots/ellipsize-multi-block-3-lines-middle.png create mode 100644 .playwright/screenshots/ellipsize-multi-block-3-lines-tail.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-1-line-clip.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-1-line-head.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-1-line-middle.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-1-line-tail.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-2-lines-clip.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-2-lines-head.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-2-lines-middle.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-2-lines-tail.png create mode 100644 .playwright/screenshots/ellipsize-paragraph-no-clamp.png create mode 100644 .playwright/tests/ellipsizeVisual.spec.ts create mode 100644 apps/example-web/src/testScreens/TestEllipsize.tsx create mode 100644 src/web/ellipsizeMode/clip.ts create mode 100644 src/web/ellipsizeMode/headEllipsize.ts create mode 100644 src/web/ellipsizeMode/middleEllipsize.ts create mode 100644 src/web/ellipsizeMode/tailEllipsize.ts delete mode 100644 src/web/ellipsizeMode/useClip.ts delete mode 100644 src/web/ellipsizeMode/useHeadEllipsize.ts delete mode 100644 src/web/ellipsizeMode/useMiddleEllipsize.ts delete mode 100644 src/web/ellipsizeMode/useTailEllipsize.ts diff --git a/.playwright/screenshots/ellipsize-multi-block-3-lines-clip.png b/.playwright/screenshots/ellipsize-multi-block-3-lines-clip.png new file mode 100644 index 0000000000000000000000000000000000000000..41bae095becfee682a2426c19b1ebdcc70c77cd1 GIT binary patch literal 12532 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/ellipsize-multi-block-3-lines-middle.png b/.playwright/screenshots/ellipsize-multi-block-3-lines-middle.png new file mode 100644 index 0000000000000000000000000000000000000000..8f481af674fdb119ac92bd0d92c3478a8655e3dd GIT binary patch literal 10271 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/ellipsize-multi-block-3-lines-tail.png b/.playwright/screenshots/ellipsize-multi-block-3-lines-tail.png new file mode 100644 index 0000000000000000000000000000000000000000..359aceed0bf3a8905a6164c2ae246292abb12d49 GIT binary patch literal 12633 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/ellipsize-paragraph-1-line-clip.png b/.playwright/screenshots/ellipsize-paragraph-1-line-clip.png new file mode 100644 index 0000000000000000000000000000000000000000..56ba59f5dab7de59e869c62a22e3f92055d05d26 GIT binary patch literal 3774 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 literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/ellipsize-paragraph-1-line-middle.png b/.playwright/screenshots/ellipsize-paragraph-1-line-middle.png new file mode 100644 index 0000000000000000000000000000000000000000..a9362e105f0b1545753ea1f8ba99730e83d7774f GIT binary patch literal 4022 zcmb7H^;gtQAEh2pLQ)z;SwdhDSh|#2mSz!QLV7|hEG!Zwn7kGi7PbvAe)a$d=x-sDl2}-zc1rRx z+FqHzmnp80s2HH}CNe2Pq0pKBI968Hd^?N2 z#Kc6jQ1!;YCG>MH78VxnvA;zH1O%F(9*lH!bjGX_alp26es_M*Y9vRt*Kr4d52FVL zCDMK}$lZeoMEGBBB1_7zqS;3;zIDzZ0@MZuK^sR!uzB9 z?@{}G-1SilMjgF5cRUB@wk&nL-J2I zv!&mt)On`=)h?;z@hh|x68ZPdMih5>L?{yPTsI*{lh^6!&nTXtp_Y{`)jyur?LXGqht>bVT~vzHqgwkC_4SSY6_Ek=#_nii&n2B;ceDbHAHa(n@aMzkBk5(Y4WZG?MYmDo z51T3H%RWcllo~C6dKun4G2N+~G2x!^KAW@NPBVYxR$e{Gg@X8=FL}}+e5<-m#6(6V zF{QQR%5Es3&(|veI;n<^KeoQuNn?f~+2sIA433Sae-(NTo`1{Cqjx{(acEfb03LOF zxt$}jU|;ioTHm^~sy9i``=g}b?EDR86ydi7+h6uOUpi{LK6IZ@YnZn!0oM!*9KQTJ z`MQtEGxI@FuHVg{KB?++fJL`OR9V-UwOqZxLuT!)l!6U|q1uxMRUyMWBc^;}3`g<1i;amY<`jodYQp?me`gR4j_v;Uxyt|$4Bydm z5(3$;%mb1ncoGTehq_r zl<7s-{^MlDd%x^?fL7V&XM}XJMe{*@O)*`+CqOmAsrB#rdm*Z+CICgFipowxN;Sbm zu*xEo`NJ=dJf~C`3wcb?->bz(Az!sk(}C5xS4fM_T&MQidQLl6SMi(UE`64^O1#t% z%{?>7@9i-;Qitqg0Ht4uG+vCA3IGyuSkX7QM>RI#dEb+Ox3y9t!z|b0$3JlaLdF5} zT?S3s0M^l4DxWQu&V9(B>4$!;<$JaJEq$(OzdRb>!0~bK7T5u@$MA4j`?XpRt14dy z9*Ihh_uh9JBWG+s+3dMT62B{L6#tN0iLM6X?hN8H*uVWNbv7Gfsw#0%3q|s!bAGb| zO~{rtq|MpapsZ*bEs%6pX+gIPs}tj_aiV5uW>G6w*EyT~*rGHuLLRM+)Bd05fJmne zopkUi$8d;vayd{BHqdCrC#cQ2Yv8&-^}A$KD$9mW*>V(nT$Xd^!*{|4huj;dHnXPT zbqNyF3ODU3&g3^vA`daW)-=<`UTdKe@cIx^X|L)5c1lwE&cA?#g~r;)_hIpHO0TnXeo=#|lxnK*$T>Y*YjYyHe*d!`Y zn=ZJ=K-s30l{>S;85~9Lg6T0tPAz|+lJh=Ctxk<=_+hBz;jTDh$HTix80abaV!m{P z93q*ko*!^~)Rwi|613o#TFH}khd#DX# z5XUJxz{S!OcxdAIapD%Cj)d3U<3iJVa?TRF1)n|!)3OLu9+N|9zXoaZhL!jKcR_^+ zUF68|O)`O&;wwKFjS#C1_Yii{Wt{a~@%wCG`+O$zpW}peCDACx`}XzBn#ZfYV1|6^`a#V}>oa@$98U$C@4c}b8SkuNLwe0f1PUiuoovK|0 zKHUM4KUZz;1#hh?!9GP?vZ$wC!*Iz~;^0tJCQ=wg+ZgJzL<1>^eV|+H;dQ4c&P4+Q>%0g|TKt-0sRC`7po3r3DB! z&m6o+SiU8Hg4pMjlsDP>TEawwYYZk-c~NBHhJYsQeJ#PnA^y%PB}Iy zxw9Cy;Ql-a3=u6*LM8VsB$s#h-3iMYm6n6-xHPM)^hrOff{=0}g09 z8>G<}v(TBqdmeZ)fvW--cT;Fh=oNUt#aYDBwT>_d@W)zhAs59*oaH|^)%ON48PaV3 zk<0FS%KH^=ekm`+a}KI|=jC}UP=oKc zCQ5%JiarDV80B)k{$ltVLfgiLH$rzhlv2fvVn|d!^)Xd2s27XeN9#I;VOVZ zlWE(K!wM<=@!}r|Q->;9v@B3gUAUR>ov>=n zpRYjKx>#A|W3$GXNb7D<27Pa>Me;SAo`#9#$90}||JakYzV}lW7javj1TP?Vd|r%- zAE#e!cl==bh#+tF4(|vb2u)Q%eA?@a*P|CNwTDtoohrw~nNmb0yVr&!bec{;I`qB2 zFFezM>r*iW)^W=BSm&5M>Z08Ke^@K1)x%{VplmGngx=(c(3{Mbn~*xOf>R2PsdP-AvzruB4E?g_Gz3J$ z44ZpF$@NPRY;7;oPJ8Rb!=&?y zq=n6X9+vd^z)ocW7EWxBg_dwXZc?iXa<)zK-Hnu>@KNi_rQX*p(Iq5@%};=yReHP{ zY)`tIR86Xn38Y<=u&3foo3RVom(iuPcYgL$&1T4@ zRXg^oHxe4JvTsbJX8VS&h;WEGTI<&oe{eemekjG8g$n4TD2{WIE%EN20QHg_ZoGjF zTaz>4zC|)~H~FhmKTWoDGkkf+H0ZmoNTK3N_Z~dGEMeX>v{{5vFLxGSC3b!OLe$fd zGz*7*H_Fex;&%n86SpO4c0VAGdTjZ8MD+PEkYQ91LdMq`VrSu0mRCmfN+SY%2?uo< z-PWgRRPkSeTXls`F)>$z2E=jN|Dp;nmQ!@~;uu}58-Ye)x=zJBahaK#d}(DCnQnMY zr-k93OIVm(SQ0F(szuTfNwbcM>!0dZ_Q~aF-J8P-&87g2F-kiO$f7Y$$%L z=HCg8$I)tNF;xc>bCCBtmue>0#fMP%mg)<>iqxSKA9sn{P~a-rvWG=ppC82w(|r#` z#)X7zXG=ywh}5oa`wtSIVlV3_4!`a`+kC|6-bidudfAZ+;F#G7|nuPWKl(SD;!|q5feWD0Qm<)nM%o_tnk5k zb#@3^a4P>A5@|=ht|}9sVBlG9PjXQ~;Q>$9?fxEW^E;0Jns}w7UGxX6ox+BUOc}<} z)Z!je$`FUKqh+gGJ4(_Mb3yj}IMs7ti{$jl(sJqOgnGrM#^r~l>#XUbb4UAo4Xf=m zwE4Afk3N?;;LL0(mzGLLRU~n$Ojpc2F3=Dj8#5$1p39!@8_2tnA=pQHWQc!koi)a2 z*ijTLRzHmzx4*Hu26E5-+>%~-$VZ}6Hz>(@)%cf9yukZ~a`%7B}#2&@CP6;X08N@71Xn6vGUq7`0rwZrBp{hj%T75|AAnL{K! z6b@Uro5^+}m@3hZAdOmo@WzsQur&ympx&pD31XOs9A#HDZLW!aMT$c24Utd^1vt!Y zF;PwS_nwKvuS`{_mhz!NIK#PFurXmhJ@Oy!kgF8V!6ZW&!wsRRGlt!SAf_bk_JqCM z&3r4*X~(3kkRQt~jnLAr&z#~vo!R#KCi#$_e#!@zL&`8tQ6_%58Fqj0_#>%3=f^sJ z?A6{C@LvJ(<`H2J=w1>G?%);+;rrP9X3Pv7R1b#_A;`IoG}TDgS2BnR!v^m^83HA3 wLqFaBSJ`gd14Z}!eR4VC5*XnBE4y*AtTo+aK5U`i05e!h3hMIZvTuX`2R<3u-v9sr literal 0 HcmV?d00001 diff --git a/.playwright/screenshots/ellipsize-paragraph-1-line-tail.png b/.playwright/screenshots/ellipsize-paragraph-1-line-tail.png new file mode 100644 index 0000000000000000000000000000000000000000..773bc0d0fd01620df36299b8416522cbe65920d7 GIT binary patch literal 3648 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 y3>J-9|KD4e6oJ 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 literal 0 HcmV?d00001 diff --git a/.playwright/tests/ellipsizeVisual.spec.ts b/.playwright/tests/ellipsizeVisual.spec.ts new file mode 100644 index 00000000..8860197a --- /dev/null +++ b/.playwright/tests/ellipsizeVisual.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; + +test.setTimeout(90_000); + +type EllipsizeMode = 'head' | 'middle' | 'tail' | 'clip'; + +const sel = { + root: '[data-testid="test-ellipsize-root"]', + display: '[data-testid="test-ellipsize-display"]', + displayInner: '[data-testid="test-ellipsize-display"] .et-view', + htmlInput: '[data-testid="test-ellipsize-html-input"]', + setValueButton: '[data-testid="test-ellipsize-set-value-button"]', + numberOfLinesInput: '[data-testid="test-ellipsize-number-of-lines-input"]', + numberOfLinesOutput: '[data-testid="test-ellipsize-number-of-lines-output"]', + modeButton: (mode: EllipsizeMode) => + `[data-testid="test-ellipsize-mode-${mode}"]`, + modeOutput: '[data-testid="test-ellipsize-mode-output"]', + valueOutput: '[data-testid="test-ellipsize-value-output"]', +} as const; + +const LONG_PARAGRAPH = + '

    This is a fairly long paragraph that should wrap across several lines so the truncation has something to chew on. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    '; + +const MULTI_BLOCK = + '

    Title that is long enough to wrap onto more than one line in the box

    First paragraph with some content.

    • Alpha item
    • Beta item
    • Gamma item
    '; + +function displayLocator(page: Page): Locator { + return page.locator(sel.display); +} + +async function gotoTestEllipsize(page: Page): Promise { + await page.goto('/test-ellipsize'); + await page.waitForSelector(sel.displayInner); +} + +async function setValue(page: Page, html: string): Promise { + await page.fill(sel.htmlInput, html); + await page.click(sel.setValueButton); + await expect + .poll(async () => (await page.locator(sel.valueOutput).textContent()) ?? '') + .toBe(html); +} + +async function setNumberOfLines(page: Page, lines: number): Promise { + await page.fill(sel.numberOfLinesInput, String(lines)); + await expect + .poll( + async () => + (await page.locator(sel.numberOfLinesOutput).textContent())?.trim() ?? + '' + ) + .toBe(String(lines)); +} + +async function setMode(page: Page, mode: EllipsizeMode): Promise { + await page.click(sel.modeButton(mode)); + await expect + .poll( + async () => + (await page.locator(sel.modeOutput).textContent())?.trim() ?? '' + ) + .toBe(mode); +} + +const MODES: EllipsizeMode[] = ['head', 'middle', 'tail', 'clip']; + +// Cases run for every ellipsize mode. Add a new entry here to have it +// exercised across all four modes (it.each-style table). +type EllipsizeCase = { + name: string; + // used to build the per-mode screenshot name: `ellipsize--.png` + slug: string; + html: string; + numberOfLines: number; +}; + +const SHARED_CASES: EllipsizeCase[] = [ + { + name: 'long paragraph clamped to 2 lines', + slug: 'paragraph-2-lines', + html: LONG_PARAGRAPH, + numberOfLines: 2, + }, + { + name: 'long paragraph clamped to 1 line', + slug: 'paragraph-1-line', + html: LONG_PARAGRAPH, + numberOfLines: 1, + }, + { + name: 'multi-block content clamped to 3 lines', + slug: 'multi-block-3-lines', + html: MULTI_BLOCK, + numberOfLines: 3, + }, +]; + +for (const mode of MODES) { + test.describe(`EnrichedText ellipsize - ${mode}`, () => { + for (const c of SHARED_CASES) { + test(c.name, async ({ page }) => { + await gotoTestEllipsize(page); + await setValue(page, c.html); + await setNumberOfLines(page, c.numberOfLines); + await setMode(page, mode); + + await expect(displayLocator(page)).toHaveScreenshot( + `ellipsize-${c.slug}-${mode}.png` + ); + }); + } + }); +} + +// mode-independent: with no line limit the full content is rendered +test.describe('EnrichedText ellipsize - no clamp', () => { + test('numberOfLines 0 renders the full content', async ({ page }) => { + await gotoTestEllipsize(page); + await setValue(page, LONG_PARAGRAPH); + await setNumberOfLines(page, 0); + + await expect(displayLocator(page)).toHaveScreenshot( + 'ellipsize-paragraph-no-clamp.png' + ); + }); +}); diff --git a/apps/example-web/src/RouteSelector.tsx b/apps/example-web/src/RouteSelector.tsx index c31df8dd..e40322d8 100644 --- a/apps/example-web/src/RouteSelector.tsx +++ b/apps/example-web/src/RouteSelector.tsx @@ -5,6 +5,7 @@ import { TestSetSelection } from './testScreens/TestSetSelection'; import { VisualRegression } from './testScreens/VisualRegression'; import { TestSubmitProps } from './testScreens/TestSubmitProps'; import { TestEnrichedText } from './testScreens/TestEnrichedText'; +import { TestEllipsize } from './testScreens/TestEllipsize'; import { useEffect, useState } from 'react'; export default function RouteSelector() { @@ -45,5 +46,9 @@ export default function RouteSelector() { return ; } + if (path === '/test-ellipsize') { + return ; + } + return ; } diff --git a/apps/example-web/src/testScreens/TestEllipsize.tsx b/apps/example-web/src/testScreens/TestEllipsize.tsx new file mode 100644 index 00000000..2b317ef0 --- /dev/null +++ b/apps/example-web/src/testScreens/TestEllipsize.tsx @@ -0,0 +1,108 @@ +import { useState, type ChangeEvent } from 'react'; +import { + EnrichedText, + type EnrichedTextProps, +} from 'react-native-enriched-html'; +import type { TextStyle } from 'react-native'; +import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle'; + +type EllipsizeMode = NonNullable; + +const ELLIPSIZE_MODES: EllipsizeMode[] = ['head', 'middle', 'tail', 'clip']; + +const INITIAL_VALUE = + '

    This is a fairly long paragraph that should wrap across several lines so the truncation has something to chew on. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

    '; + +export function TestEllipsize() { + const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE); + const [value, setValue] = useState(INITIAL_VALUE); + const [numberOfLines, setNumberOfLines] = useState(2); + const [ellipsizeMode, setEllipsizeMode] = useState('tail'); + + return ( +
    +
    + + {value} + +
    + +
    + + ) => { + setNumberOfLines(Number(e.target.value)); + }} + /> +
    + +
    + {ELLIPSIZE_MODES.map((mode) => ( + + ))} +
    + +