Skip to content

Commit 42c8f57

Browse files
committed
refactor: functions cleanup
1 parent 92af405 commit 42c8f57

11 files changed

Lines changed: 719 additions & 1050 deletions

File tree

apps/example-web/src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ function App() {
236236
?.getHTML()
237237
.then((html) => {
238238
setEnrichedTextValue(html);
239+
// temporary for making the testing easier
239240
// ref.current?.setValue('');
240241
})
241242
.catch((error: unknown) => {
@@ -334,6 +335,15 @@ function App() {
334335

335336
<div className="container enriched-text-container">
336337
<h1 className="app-title">Enriched Text</h1>
338+
<EnrichedText
339+
style={enrichedTextStyle}
340+
htmlStyle={WEB_DEFAULT_HTML_STYLE}
341+
numberOfLines={2}
342+
ellipsizeMode="middle"
343+
>
344+
{enrichedTextValue}
345+
</EnrichedText>
346+
{/*temporary code to make testing easier*/}
337347
<EnrichedText
338348
style={enrichedTextStyle}
339349
htmlStyle={WEB_DEFAULT_HTML_STYLE}

apps/example-web/src/testScreens/TestEllipsize.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,9 @@ type EllipsizeMode = NonNullable<EnrichedTextProps['ellipsizeMode']>;
1010

1111
const ELLIPSIZE_MODES: EllipsizeMode[] = ['head', 'middle', 'tail', 'clip'];
1212

13-
const INITIAL_VALUE =
14-
'<html><p>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.</p></html>';
15-
1613
export function TestEllipsize() {
17-
const [htmlInput, setHtmlInput] = useState(INITIAL_VALUE);
18-
const [value, setValue] = useState(INITIAL_VALUE);
14+
const [htmlInput, setHtmlInput] = useState('');
15+
const [value, setValue] = useState('');
1916
const [numberOfLines, setNumberOfLines] = useState(2);
2017
const [ellipsizeMode, setEllipsizeMode] = useState<EllipsizeMode>('tail');
2118

@@ -94,8 +91,6 @@ export function TestEllipsize() {
9491
}
9592

9693
const enrichedTextStyle: TextStyle = {
97-
// a definite width is required so the clamp can measure line breaks even
98-
// before any content is rendered (otherwise the box collapses to 0 width)
9994
width: 360,
10095
paddingVertical: 8,
10196
paddingHorizontal: 8,

docs/TEXT_API_REFERENCE.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ How the text should be truncated when `numberOfLines` is set and the text overfl
6060
> [!NOTE]
6161
> On Android, when numberOfLines is set to a value higher than 1, only tail value will work correctly.
6262
63-
> [!NOTE]
64-
> On Web, `middle` is not implemented and falls back to the default `tail`. The natively supported modes are `head`, `tail` and `clip`.
65-
6663
### `numberOfLines`
6764

6865
Limits the number of displayed lines. Set to `0` for unlimited lines.

docs/WEB.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ See [Web Keyboard Shortcuts](./INPUT_API_REFERENCE.md#web-keyboard-shortcuts) fo
3838
### What works
3939

4040
- Customizing the styling using props: `style`, `htmlStyle`, `selectionColor`.
41-
- Truncation via `numberOfLines` and `ellipsizeMode` (`head`, `tail`, `clip`). The mode `middle` is not implemented and falls back to `tail`.
41+
- Truncation via `numberOfLines` and `ellipsizeMode` (`head`, `middle`, `tail`, `clip`).
4242

4343
### Unsupported
4444

src/web/EnrichedText.tsx

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
memo,
3-
useLayoutEffect,
4-
useMemo,
5-
useRef,
6-
useState,
7-
type CSSProperties,
8-
} from 'react';
1+
import { memo, useMemo, useRef, useState, type CSSProperties } from 'react';
92
import type { EnrichedTextProps } from '../types';
103
import './EnrichedText.css';
114
import { enrichedTextStyleToCSSProperties } from './styleConversion/enrichedTextStyleToCSSProperties';
@@ -21,10 +14,7 @@ import { prepareHtmlForWeb } from './normalization/prepareHtmlForWeb';
2114
import { INLINE_IMAGE_CSS_VARIABLES } from './styleConversion/inlineImageCSSVariables';
2215
import { useImageErrorFallback } from './useImageErrorFallback';
2316
import { usePressInteractions } from './usePressInteractions';
24-
import { headEllipsize } from './ellipsizeMode/headEllipsize';
25-
import { tailEllipsize } from './ellipsizeMode/tailEllipsize';
26-
import { clip } from './ellipsizeMode/clip';
27-
import { middleEllipsize } from './ellipsizeMode/middleEllipsize';
17+
import { useEllipsizeMode } from './ellipsizeMode/useEllipsizeMode';
2818

2919
export const EnrichedText = memo(
3020
({
@@ -82,33 +72,13 @@ export const EnrichedText = memo(
8272
[textStyle, themingStyle, cssVars]
8373
);
8474

85-
// a single layout effect picks the truncation strategy so the hook is
86-
// never called conditionally; the strategies themselves are plain functions
87-
useLayoutEffect(() => {
88-
const container = containerRef.current;
89-
if (!container) return;
90-
91-
// 0 (or less) means no limit - render the full content
92-
if (numberOfLines <= 0) {
93-
setClampedHtml(finalHtml);
94-
return;
95-
}
96-
97-
switch (ellipsizeMode) {
98-
case 'head':
99-
headEllipsize(container, finalHtml, numberOfLines, setClampedHtml);
100-
break;
101-
case 'clip':
102-
clip(container, finalHtml, numberOfLines, setClampedHtml);
103-
break;
104-
case 'tail':
105-
tailEllipsize(container, finalHtml, numberOfLines, setClampedHtml);
106-
break;
107-
case 'middle':
108-
middleEllipsize(container, finalHtml, numberOfLines, setClampedHtml);
109-
break;
110-
}
111-
}, [containerRef, finalHtml, ellipsizeMode, numberOfLines]);
75+
useEllipsizeMode({
76+
containerRef,
77+
finalHtml,
78+
ellipsizeMode,
79+
numberOfLines,
80+
setClampedHtml,
81+
});
11282

11383
usePressInteractions(containerRef);
11484
useImageErrorFallback(containerRef);

src/web/ellipsizeMode/clip.ts

Lines changed: 29 additions & 213 deletions
Original file line numberDiff line numberDiff line change
@@ -1,237 +1,53 @@
1-
import { ENRICHED_TEXT_CLASSNAME } from '../constants/classNames';
2-
3-
const BLOCK_TAGS = new Set([
4-
'P',
5-
'H1',
6-
'H2',
7-
'H3',
8-
'H4',
9-
'H5',
10-
'H6',
11-
'LI',
12-
'BLOCKQUOTE',
13-
'CODEBLOCK',
14-
]);
15-
1+
import {
2+
createSandbox,
3+
eatBackwardUntilFits,
4+
removeAfterTarget,
5+
scanLines,
6+
} from './utils';
7+
8+
// Truncate the content to `numberOfLines` by hard-cutting the overflow, with no
9+
// ellipsis. We render the full HTML into a hidden sandbox and do a single
10+
// forward line scan to learn where each rendered line begins. If the content
11+
// fits, we keep it as it is. Otherwise we take the first node on the first
12+
// forbidden line (line N+1) as the target, drop everything "to the right" of it
13+
// in the document context, then trim backwards one unit at a time
14+
// (character / img / br / empty block) until what remains fits within line N.
15+
// This works almost the same as tail, only without re-anchoring an ellipsis as we shrink.
1616
export function clip(
1717
container: HTMLDivElement,
1818
finalHtml: string,
1919
numberOfLines: number,
2020
setClampedHtml: (clampedHtml: string) => void
2121
) {
22-
const NUMBER_OF_LINES = numberOfLines;
23-
24-
// setup the hidden sandbox
25-
const sandbox = document.createElement('div');
26-
const computedStyle = window.getComputedStyle(container);
27-
28-
sandbox.style.position = 'absolute';
29-
sandbox.style.visibility = 'hidden';
30-
sandbox.style.top = '-9999px';
31-
32-
// copy exact CSS properties that affect text wrapping
33-
sandbox.style.cssText = container.style.cssText;
34-
sandbox.style.width = computedStyle.width;
35-
sandbox.style.boxSizing = computedStyle.boxSizing;
36-
sandbox.style.fontFamily = computedStyle.fontFamily;
37-
sandbox.style.fontSize = computedStyle.fontSize;
38-
sandbox.style.lineHeight = computedStyle.lineHeight;
39-
sandbox.style.letterSpacing = computedStyle.letterSpacing;
40-
sandbox.style.padding = computedStyle.padding;
41-
42-
sandbox.className = ENRICHED_TEXT_CLASSNAME;
43-
sandbox.innerHTML = finalHtml;
44-
document.body.appendChild(sandbox);
45-
46-
const walkerFilter = {
47-
acceptNode: (n: Node) => {
48-
if (n.nodeType === Node.TEXT_NODE) return NodeFilter.FILTER_ACCEPT;
49-
if (n.nodeName === 'IMG' || n.nodeName === 'BR')
50-
return NodeFilter.FILTER_ACCEPT;
51-
52-
// let the walker see the empty blocks
53-
if (n.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(n.nodeName)) {
54-
const el = n as HTMLElement;
55-
const textEmpty = !el.textContent?.trim();
56-
const hasImg = !!el.querySelector('img');
57-
if (textEmpty && !hasImg) return NodeFilter.FILTER_ACCEPT;
58-
}
22+
const { sandbox, lineTolerance } = createSandbox(container, finalHtml);
5923

60-
return NodeFilter.FILTER_SKIP;
61-
},
62-
};
63-
64-
const walker = document.createTreeWalker(
24+
// forward scan - we find the first element that overflows to the forbidden line
25+
const { lineStarts, lineBottoms, lastLine } = scanLines(
6526
sandbox,
66-
NodeFilter.SHOW_ALL,
67-
walkerFilter
27+
lineTolerance
6828
);
69-
const range = document.createRange();
7029

71-
let currentLine = 1;
72-
let lastBottom: number | null = null;
7330
// the node that starts the overflow
74-
let targetNode: Node | null = null;
75-
76-
// forward scan - we find the first element that overflows to the forbidden line
77-
let node: Node | null;
78-
while ((node = walker.nextNode())) {
79-
if (node.nodeType === Node.TEXT_NODE) {
80-
const textNode = node as Text;
81-
const text = textNode.nodeValue || '';
82-
83-
for (let i = 0; i < text.length; i++) {
84-
range.setStart(textNode, i);
85-
range.setEnd(textNode, i + 1);
86-
const rect = range.getBoundingClientRect();
87-
88-
if (rect.height === 0) continue;
89-
90-
if (lastBottom !== null && rect.bottom > lastBottom + 4) {
91-
currentLine++;
92-
}
93-
94-
if (currentLine > NUMBER_OF_LINES) {
95-
targetNode = textNode;
96-
break;
97-
}
98-
lastBottom = rect.bottom;
99-
}
100-
} else if (
101-
node.nodeName === 'IMG' ||
102-
node.nodeName === 'BR' ||
103-
(node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName))
104-
) {
105-
const el = node as HTMLElement;
106-
const rect = el.getBoundingClientRect();
107-
108-
if (rect.height === 0) continue;
109-
110-
if (lastBottom !== null && rect.bottom > lastBottom + 4) {
111-
currentLine++;
112-
}
113-
114-
if (currentLine > NUMBER_OF_LINES) {
115-
targetNode = el;
116-
break;
117-
}
118-
lastBottom = rect.bottom;
119-
}
120-
121-
if (targetNode) break;
122-
}
31+
const targetNode =
32+
lastLine > numberOfLines ? lineStarts[numberOfLines + 1] : undefined;
12333

12434
// we remove content until everything fits within the given number of lines
12535
if (targetNode) {
12636
// remove all content on "the right" of the targetNode
127-
let current: Node | null = targetNode;
128-
while (current && current !== sandbox) {
129-
let sibling = current.nextSibling;
130-
while (sibling) {
131-
const next = sibling.nextSibling;
132-
sibling.parentNode?.removeChild(sibling);
133-
sibling = next;
134-
}
135-
current = current.parentNode;
136-
}
137-
138-
if (targetNode.nodeName === 'IMG') {
139-
targetNode.parentNode?.removeChild(targetNode);
140-
}
37+
removeAfterTarget(targetNode.node, sandbox);
14138

14239
// remove content backwards until it fits - unlike tail, clip adds no ellipsis
143-
let isOverflowing = true;
144-
while (isOverflowing) {
145-
const backwardWalker = document.createTreeWalker(
146-
sandbox,
147-
NodeFilter.SHOW_ALL,
148-
walkerFilter
149-
);
150-
151-
let lastNode: Node | null = null;
152-
while (backwardWalker.nextNode()) {
153-
lastNode = backwardWalker.currentNode;
154-
}
155-
156-
if (!lastNode) break;
157-
158-
// we have to handle inline images and <br> separately
159-
if (lastNode.nodeName === 'IMG' || lastNode.nodeName === 'BR') {
160-
range.selectNodeContents(lastNode);
161-
const rect = range.getBoundingClientRect();
162-
163-
// check if the image or <br> pushed us onto the forbidden line
164-
if (lastBottom !== null && rect.bottom > lastBottom + 4) {
165-
// it overflowed
166-
lastNode.parentNode?.removeChild(lastNode);
167-
} else {
168-
// it fits
169-
isOverflowing = false;
170-
}
171-
}
172-
// handle empty blocks (like an empty <li>)
173-
else if (
174-
lastNode.nodeType === Node.ELEMENT_NODE &&
175-
BLOCK_TAGS.has(lastNode.nodeName)
176-
) {
177-
const rect = (lastNode as HTMLElement).getBoundingClientRect();
178-
179-
if (lastBottom !== null && rect.bottom > lastBottom + 4) {
180-
lastNode.parentNode?.removeChild(lastNode);
181-
} else {
182-
isOverflowing = false;
183-
}
184-
}
185-
// handling normal text nodes
186-
else {
187-
const lastTextNode = lastNode as Text;
188-
let text = lastTextNode.nodeValue || '';
189-
190-
if (text.trim().length === 0) {
191-
let parent = lastTextNode.parentNode;
192-
lastTextNode.parentNode?.removeChild(lastTextNode);
193-
194-
// if text removal resulted in an empty block, we have to remove it
195-
while (parent && parent !== sandbox) {
196-
const el = parent as HTMLElement;
197-
const textEmpty = !el.textContent?.trim();
198-
const hasImg = !!el.querySelector('img');
199-
const hasBr = !!el.querySelector('br');
200-
201-
if (
202-
parent.childNodes.length === 0 ||
203-
(textEmpty && !hasImg && !hasBr)
204-
) {
205-
const p = parent.parentNode;
206-
parent.parentNode?.removeChild(parent);
207-
parent = p;
208-
} else {
209-
break;
210-
}
211-
}
212-
continue;
213-
}
214-
215-
// Measure the current text node
216-
range.setStart(lastTextNode, 0);
217-
range.setEnd(lastTextNode, lastTextNode.nodeValue!.length);
218-
const rect = range.getBoundingClientRect();
219-
220-
// check if it still overflows
221-
if (lastBottom !== null && rect.bottom > lastBottom + 4) {
222-
// it overflows, delete one character from the end
223-
lastTextNode.nodeValue = text.slice(0, -1);
224-
} else {
225-
// it fits perfectly
226-
isOverflowing = false;
227-
}
228-
}
229-
}
40+
eatBackwardUntilFits(
41+
sandbox,
42+
lineBottoms[numberOfLines] ?? null,
43+
lineTolerance,
44+
false
45+
);
23046

23147
setClampedHtml(sandbox.innerHTML);
23248
} else {
23349
setClampedHtml(finalHtml);
23450
}
23551

236-
document.body.removeChild(sandbox);
52+
container.removeChild(sandbox);
23753
}

0 commit comments

Comments
 (0)