@seed-design/react-middle-truncate 0.0.0-alpha-20260416134952
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/MiddleTruncate-12s-BfA2DiuS.cjs +26 -0
- package/lib/MiddleTruncate-12s-CnVju8qr.js +26 -0
- package/lib/index.cjs +5 -0
- package/lib/index.d.ts +27 -0
- package/lib/index.js +1 -0
- package/lib/useMiddleTruncate-12s-DT5caddu.js +124 -0
- package/lib/useMiddleTruncate-12s-PfK603TV.cjs +124 -0
- package/package.json +48 -0
- package/src/MiddleTruncate.tsx +30 -0
- package/src/index.ts +1 -0
- package/src/useMiddleTruncate.ts +169 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
3
|
+
var reactPrimitive = require('@seed-design/react-primitive');
|
|
4
|
+
var domUtils = require('@seed-design/dom-utils');
|
|
5
|
+
var react = require('react');
|
|
6
|
+
var reactComposeRefs = require('@radix-ui/react-compose-refs');
|
|
7
|
+
var useMiddleTruncate12s = require('./useMiddleTruncate-12s-PfK603TV.cjs');
|
|
8
|
+
|
|
9
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
10
|
+
|
|
11
|
+
const MiddleTruncate = /*#__PURE__*/ react.forwardRef(({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref)=>{
|
|
12
|
+
const api = useMiddleTruncate12s.useMiddleTruncate({
|
|
13
|
+
children,
|
|
14
|
+
end,
|
|
15
|
+
ellipsis,
|
|
16
|
+
maxLines,
|
|
17
|
+
onTruncate
|
|
18
|
+
});
|
|
19
|
+
return /*#__PURE__*/ jsxRuntime.jsx(reactPrimitive.Primitive.span, {
|
|
20
|
+
ref: reactComposeRefs.composeRefs(ref, api.contentRef),
|
|
21
|
+
...domUtils.mergeProps(api.contentProps, otherProps)
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
MiddleTruncate.displayName = "MiddleTruncate";
|
|
25
|
+
|
|
26
|
+
exports.MiddleTruncate = MiddleTruncate;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx } from 'react/jsx-runtime';
|
|
3
|
+
import { Primitive } from '@seed-design/react-primitive';
|
|
4
|
+
import { mergeProps } from '@seed-design/dom-utils';
|
|
5
|
+
import { forwardRef } from 'react';
|
|
6
|
+
import { composeRefs } from '@radix-ui/react-compose-refs';
|
|
7
|
+
import { u as useMiddleTruncate } from './useMiddleTruncate-12s-DT5caddu.js';
|
|
8
|
+
|
|
9
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
10
|
+
|
|
11
|
+
const MiddleTruncate = /*#__PURE__*/ forwardRef(({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref)=>{
|
|
12
|
+
const api = useMiddleTruncate({
|
|
13
|
+
children,
|
|
14
|
+
end,
|
|
15
|
+
ellipsis,
|
|
16
|
+
maxLines,
|
|
17
|
+
onTruncate
|
|
18
|
+
});
|
|
19
|
+
return /*#__PURE__*/ jsx(Primitive.span, {
|
|
20
|
+
ref: composeRefs(ref, api.contentRef),
|
|
21
|
+
...mergeProps(api.contentProps, otherProps)
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
MiddleTruncate.displayName = "MiddleTruncate";
|
|
25
|
+
|
|
26
|
+
export { MiddleTruncate as M };
|
package/lib/index.cjs
ADDED
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PrimitiveProps } from '@seed-design/react-primitive';
|
|
2
|
+
import * as react from 'react';
|
|
3
|
+
|
|
4
|
+
interface UseMiddleTruncateProps {
|
|
5
|
+
children: string;
|
|
6
|
+
/**
|
|
7
|
+
* Number of characters to preserve from the end of the text.
|
|
8
|
+
* @default 0
|
|
9
|
+
*/
|
|
10
|
+
end?: number;
|
|
11
|
+
/**
|
|
12
|
+
* @default "…"
|
|
13
|
+
*/
|
|
14
|
+
ellipsis?: string;
|
|
15
|
+
/**
|
|
16
|
+
* @default 1
|
|
17
|
+
*/
|
|
18
|
+
maxLines?: number;
|
|
19
|
+
onTruncate?: (isTruncated: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MiddleTruncateProps extends UseMiddleTruncateProps, PrimitiveProps, Omit<react.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
23
|
+
}
|
|
24
|
+
declare const MiddleTruncate: react.ForwardRefExoticComponent<MiddleTruncateProps & react.RefAttributes<HTMLSpanElement>>;
|
|
25
|
+
|
|
26
|
+
export { MiddleTruncate };
|
|
27
|
+
export type { MiddleTruncateProps };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { M as MiddleTruncate } from './MiddleTruncate-12s-CnVju8qr.js';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { elementProps } from '@seed-design/dom-utils';
|
|
3
|
+
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
6
|
+
|
|
7
|
+
function useMiddleTruncate(props) {
|
|
8
|
+
const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
|
|
9
|
+
const contentRef = useRef(null);
|
|
10
|
+
const [displayText, setDisplayText] = useState(null);
|
|
11
|
+
const compute = useCallback(()=>{
|
|
12
|
+
const el = contentRef.current;
|
|
13
|
+
if (!el || !children) {
|
|
14
|
+
setDisplayText(null);
|
|
15
|
+
onTruncate?.(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const parent = el.parentElement;
|
|
19
|
+
if (!parent) return;
|
|
20
|
+
const computed = getComputedStyle(parent);
|
|
21
|
+
const contentWidth = Math.floor(parent.clientWidth - (Number.parseFloat(computed.paddingLeft) || 0) - (Number.parseFloat(computed.paddingRight) || 0));
|
|
22
|
+
if (contentWidth <= 0) {
|
|
23
|
+
setDisplayText(null);
|
|
24
|
+
onTruncate?.(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const measurer = document.createElement("span");
|
|
28
|
+
measurer.style.cssText = [
|
|
29
|
+
"position:absolute",
|
|
30
|
+
"visibility:hidden",
|
|
31
|
+
"pointer-events:none",
|
|
32
|
+
"display:block",
|
|
33
|
+
`width:${contentWidth}px`,
|
|
34
|
+
`font:${computed.font}`,
|
|
35
|
+
`letter-spacing:${computed.letterSpacing}`,
|
|
36
|
+
"word-break:break-all",
|
|
37
|
+
`line-height:${computed.lineHeight}`,
|
|
38
|
+
"white-space:nowrap"
|
|
39
|
+
].join(";");
|
|
40
|
+
measurer.textContent = children;
|
|
41
|
+
document.body.appendChild(measurer);
|
|
42
|
+
const lineHeight = measurer.scrollHeight;
|
|
43
|
+
const maxHeight = lineHeight * maxLines;
|
|
44
|
+
measurer.style.whiteSpace = "normal";
|
|
45
|
+
// text fits without truncation
|
|
46
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
47
|
+
setDisplayText(children);
|
|
48
|
+
onTruncate?.(false);
|
|
49
|
+
measurer.remove();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// binary search
|
|
53
|
+
const safeEnd = Math.min(Math.abs(end), children.length);
|
|
54
|
+
const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
|
|
55
|
+
const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
|
|
56
|
+
// If ellipsis + endFragment alone overflows, reduce endFragment first
|
|
57
|
+
measurer.textContent = ellipsis + endFragment;
|
|
58
|
+
if (measurer.scrollHeight > maxHeight + 1) {
|
|
59
|
+
let eLow = 0;
|
|
60
|
+
let eHigh = endFragment.length;
|
|
61
|
+
while(eLow <= eHigh){
|
|
62
|
+
const eMid = Math.floor((eLow + eHigh) / 2);
|
|
63
|
+
measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
|
|
64
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
65
|
+
eLow = eMid + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
eHigh = eMid - 1;
|
|
69
|
+
}
|
|
70
|
+
const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
|
|
71
|
+
setDisplayText(ellipsis + trimmedEnd);
|
|
72
|
+
onTruncate?.(true);
|
|
73
|
+
measurer.remove();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let low = 0;
|
|
77
|
+
let high = startSource.length;
|
|
78
|
+
while(low <= high){
|
|
79
|
+
const mid = Math.floor((low + high) / 2);
|
|
80
|
+
measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
|
|
81
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
82
|
+
low = mid + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
high = mid - 1;
|
|
86
|
+
}
|
|
87
|
+
const startFragment = startSource.slice(0, Math.max(high, 0));
|
|
88
|
+
setDisplayText(startFragment + ellipsis + endFragment);
|
|
89
|
+
onTruncate?.(true);
|
|
90
|
+
measurer.remove();
|
|
91
|
+
}, [
|
|
92
|
+
children,
|
|
93
|
+
end,
|
|
94
|
+
ellipsis,
|
|
95
|
+
maxLines,
|
|
96
|
+
onTruncate
|
|
97
|
+
]);
|
|
98
|
+
useEffect(()=>{
|
|
99
|
+
compute();
|
|
100
|
+
}, [
|
|
101
|
+
compute
|
|
102
|
+
]);
|
|
103
|
+
// recompute on resize
|
|
104
|
+
useEffect(()=>{
|
|
105
|
+
const parent = contentRef.current?.parentElement;
|
|
106
|
+
if (!parent) return;
|
|
107
|
+
const observer = new ResizeObserver(()=>compute());
|
|
108
|
+
observer.observe(parent);
|
|
109
|
+
return ()=>observer.disconnect();
|
|
110
|
+
}, [
|
|
111
|
+
compute
|
|
112
|
+
]);
|
|
113
|
+
return {
|
|
114
|
+
contentRef,
|
|
115
|
+
contentProps: elementProps({
|
|
116
|
+
style: {
|
|
117
|
+
wordBreak: "break-all"
|
|
118
|
+
},
|
|
119
|
+
children: displayText ?? children
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { useMiddleTruncate as u };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
var domUtils = require('@seed-design/dom-utils');
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
6
|
+
|
|
7
|
+
function useMiddleTruncate(props) {
|
|
8
|
+
const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
|
|
9
|
+
const contentRef = react.useRef(null);
|
|
10
|
+
const [displayText, setDisplayText] = react.useState(null);
|
|
11
|
+
const compute = react.useCallback(()=>{
|
|
12
|
+
const el = contentRef.current;
|
|
13
|
+
if (!el || !children) {
|
|
14
|
+
setDisplayText(null);
|
|
15
|
+
onTruncate?.(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const parent = el.parentElement;
|
|
19
|
+
if (!parent) return;
|
|
20
|
+
const computed = getComputedStyle(parent);
|
|
21
|
+
const contentWidth = Math.floor(parent.clientWidth - (Number.parseFloat(computed.paddingLeft) || 0) - (Number.parseFloat(computed.paddingRight) || 0));
|
|
22
|
+
if (contentWidth <= 0) {
|
|
23
|
+
setDisplayText(null);
|
|
24
|
+
onTruncate?.(false);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const measurer = document.createElement("span");
|
|
28
|
+
measurer.style.cssText = [
|
|
29
|
+
"position:absolute",
|
|
30
|
+
"visibility:hidden",
|
|
31
|
+
"pointer-events:none",
|
|
32
|
+
"display:block",
|
|
33
|
+
`width:${contentWidth}px`,
|
|
34
|
+
`font:${computed.font}`,
|
|
35
|
+
`letter-spacing:${computed.letterSpacing}`,
|
|
36
|
+
"word-break:break-all",
|
|
37
|
+
`line-height:${computed.lineHeight}`,
|
|
38
|
+
"white-space:nowrap"
|
|
39
|
+
].join(";");
|
|
40
|
+
measurer.textContent = children;
|
|
41
|
+
document.body.appendChild(measurer);
|
|
42
|
+
const lineHeight = measurer.scrollHeight;
|
|
43
|
+
const maxHeight = lineHeight * maxLines;
|
|
44
|
+
measurer.style.whiteSpace = "normal";
|
|
45
|
+
// text fits without truncation
|
|
46
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
47
|
+
setDisplayText(children);
|
|
48
|
+
onTruncate?.(false);
|
|
49
|
+
measurer.remove();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// binary search
|
|
53
|
+
const safeEnd = Math.min(Math.abs(end), children.length);
|
|
54
|
+
const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
|
|
55
|
+
const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
|
|
56
|
+
// If ellipsis + endFragment alone overflows, reduce endFragment first
|
|
57
|
+
measurer.textContent = ellipsis + endFragment;
|
|
58
|
+
if (measurer.scrollHeight > maxHeight + 1) {
|
|
59
|
+
let eLow = 0;
|
|
60
|
+
let eHigh = endFragment.length;
|
|
61
|
+
while(eLow <= eHigh){
|
|
62
|
+
const eMid = Math.floor((eLow + eHigh) / 2);
|
|
63
|
+
measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
|
|
64
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
65
|
+
eLow = eMid + 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
eHigh = eMid - 1;
|
|
69
|
+
}
|
|
70
|
+
const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
|
|
71
|
+
setDisplayText(ellipsis + trimmedEnd);
|
|
72
|
+
onTruncate?.(true);
|
|
73
|
+
measurer.remove();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let low = 0;
|
|
77
|
+
let high = startSource.length;
|
|
78
|
+
while(low <= high){
|
|
79
|
+
const mid = Math.floor((low + high) / 2);
|
|
80
|
+
measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
|
|
81
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
82
|
+
low = mid + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
high = mid - 1;
|
|
86
|
+
}
|
|
87
|
+
const startFragment = startSource.slice(0, Math.max(high, 0));
|
|
88
|
+
setDisplayText(startFragment + ellipsis + endFragment);
|
|
89
|
+
onTruncate?.(true);
|
|
90
|
+
measurer.remove();
|
|
91
|
+
}, [
|
|
92
|
+
children,
|
|
93
|
+
end,
|
|
94
|
+
ellipsis,
|
|
95
|
+
maxLines,
|
|
96
|
+
onTruncate
|
|
97
|
+
]);
|
|
98
|
+
react.useEffect(()=>{
|
|
99
|
+
compute();
|
|
100
|
+
}, [
|
|
101
|
+
compute
|
|
102
|
+
]);
|
|
103
|
+
// recompute on resize
|
|
104
|
+
react.useEffect(()=>{
|
|
105
|
+
const parent = contentRef.current?.parentElement;
|
|
106
|
+
if (!parent) return;
|
|
107
|
+
const observer = new ResizeObserver(()=>compute());
|
|
108
|
+
observer.observe(parent);
|
|
109
|
+
return ()=>observer.disconnect();
|
|
110
|
+
}, [
|
|
111
|
+
compute
|
|
112
|
+
]);
|
|
113
|
+
return {
|
|
114
|
+
contentRef,
|
|
115
|
+
contentProps: domUtils.elementProps({
|
|
116
|
+
style: {
|
|
117
|
+
wordBreak: "break-all"
|
|
118
|
+
},
|
|
119
|
+
children: displayText ?? children
|
|
120
|
+
})
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
exports.useMiddleTruncate = useMiddleTruncate;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seed-design/react-middle-truncate",
|
|
3
|
+
"version": "0.0.0-alpha-20260416134952",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/daangn/seed-design.git",
|
|
7
|
+
"directory": "packages/react-headless/middle-truncate"
|
|
8
|
+
},
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"import": "./lib/index.js",
|
|
15
|
+
"require": "./lib/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./lib/index.cjs",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"clean": "rm -rf lib",
|
|
26
|
+
"build": "bunchee",
|
|
27
|
+
"lint:publish": "bun publint"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@testing-library/react": "^16.3.0",
|
|
31
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
32
|
+
"@types/react": "^19.1.6",
|
|
33
|
+
"react": "^19.1.0",
|
|
34
|
+
"react-dom": "^19.1.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": ">=18.0.0",
|
|
38
|
+
"react-dom": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
42
|
+
"@seed-design/dom-utils": "1.0.0",
|
|
43
|
+
"@seed-design/react-primitive": "0.0.0-alpha-20260414104312"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
2
|
+
|
|
3
|
+
"use client";
|
|
4
|
+
|
|
5
|
+
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
|
|
6
|
+
import { mergeProps } from "@seed-design/dom-utils";
|
|
7
|
+
import type * as React from "react";
|
|
8
|
+
import { forwardRef } from "react";
|
|
9
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
10
|
+
import type { UseMiddleTruncateProps } from "./useMiddleTruncate";
|
|
11
|
+
import { useMiddleTruncate } from "./useMiddleTruncate";
|
|
12
|
+
|
|
13
|
+
export interface MiddleTruncateProps
|
|
14
|
+
extends UseMiddleTruncateProps,
|
|
15
|
+
PrimitiveProps,
|
|
16
|
+
Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {}
|
|
17
|
+
|
|
18
|
+
export const MiddleTruncate = forwardRef<HTMLSpanElement, MiddleTruncateProps>(
|
|
19
|
+
({ children, end, ellipsis, maxLines, onTruncate, ...otherProps }, ref) => {
|
|
20
|
+
const api = useMiddleTruncate({ children, end, ellipsis, maxLines, onTruncate });
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Primitive.span
|
|
24
|
+
ref={composeRefs(ref, api.contentRef)}
|
|
25
|
+
{...mergeProps(api.contentProps, otherProps)}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
MiddleTruncate.displayName = "MiddleTruncate";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MiddleTruncate, type MiddleTruncateProps } from "./MiddleTruncate";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Visual behavior is verified in Storybook: docs/stories/MiddleTruncate.stories.tsx
|
|
2
|
+
|
|
3
|
+
"use client";
|
|
4
|
+
|
|
5
|
+
import { elementProps } from "@seed-design/dom-utils";
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
7
|
+
|
|
8
|
+
export interface UseMiddleTruncateProps {
|
|
9
|
+
children: string;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Number of characters to preserve from the end of the text.
|
|
13
|
+
* @default 0
|
|
14
|
+
*/
|
|
15
|
+
end?: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @default "…"
|
|
19
|
+
*/
|
|
20
|
+
ellipsis?: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @default 1
|
|
24
|
+
*/
|
|
25
|
+
maxLines?: number;
|
|
26
|
+
|
|
27
|
+
onTruncate?: (isTruncated: boolean) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function useMiddleTruncate(props: UseMiddleTruncateProps) {
|
|
31
|
+
const { children, end = 0, ellipsis = "…", maxLines = 1, onTruncate } = props;
|
|
32
|
+
|
|
33
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
34
|
+
const [displayText, setDisplayText] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const compute = useCallback(() => {
|
|
37
|
+
const el = contentRef.current;
|
|
38
|
+
if (!el || !children) {
|
|
39
|
+
setDisplayText(null);
|
|
40
|
+
onTruncate?.(false);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parent = el.parentElement;
|
|
45
|
+
if (!parent) return;
|
|
46
|
+
|
|
47
|
+
const computed = getComputedStyle(parent);
|
|
48
|
+
|
|
49
|
+
const contentWidth = Math.floor(
|
|
50
|
+
parent.clientWidth -
|
|
51
|
+
(Number.parseFloat(computed.paddingLeft) || 0) -
|
|
52
|
+
(Number.parseFloat(computed.paddingRight) || 0),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (contentWidth <= 0) {
|
|
56
|
+
setDisplayText(null);
|
|
57
|
+
onTruncate?.(false);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const measurer = document.createElement("span");
|
|
62
|
+
|
|
63
|
+
measurer.style.cssText = [
|
|
64
|
+
"position:absolute",
|
|
65
|
+
"visibility:hidden",
|
|
66
|
+
"pointer-events:none",
|
|
67
|
+
"display:block",
|
|
68
|
+
`width:${contentWidth}px`,
|
|
69
|
+
`font:${computed.font}`,
|
|
70
|
+
`letter-spacing:${computed.letterSpacing}`,
|
|
71
|
+
"word-break:break-all",
|
|
72
|
+
`line-height:${computed.lineHeight}`,
|
|
73
|
+
"white-space:nowrap",
|
|
74
|
+
].join(";");
|
|
75
|
+
measurer.textContent = children;
|
|
76
|
+
document.body.appendChild(measurer);
|
|
77
|
+
|
|
78
|
+
const lineHeight = measurer.scrollHeight;
|
|
79
|
+
const maxHeight = lineHeight * maxLines;
|
|
80
|
+
|
|
81
|
+
measurer.style.whiteSpace = "normal";
|
|
82
|
+
|
|
83
|
+
// text fits without truncation
|
|
84
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
85
|
+
setDisplayText(children);
|
|
86
|
+
onTruncate?.(false);
|
|
87
|
+
measurer.remove();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// binary search
|
|
92
|
+
const safeEnd = Math.min(Math.abs(end), children.length);
|
|
93
|
+
const endFragment = safeEnd > 0 ? children.slice(-safeEnd) : "";
|
|
94
|
+
const startSource = safeEnd > 0 ? children.slice(0, -safeEnd) : children;
|
|
95
|
+
|
|
96
|
+
// If ellipsis + endFragment alone overflows, reduce endFragment first
|
|
97
|
+
measurer.textContent = ellipsis + endFragment;
|
|
98
|
+
if (measurer.scrollHeight > maxHeight + 1) {
|
|
99
|
+
let eLow = 0;
|
|
100
|
+
let eHigh = endFragment.length;
|
|
101
|
+
|
|
102
|
+
while (eLow <= eHigh) {
|
|
103
|
+
const eMid = Math.floor((eLow + eHigh) / 2);
|
|
104
|
+
measurer.textContent = ellipsis + endFragment.slice(-eMid || undefined);
|
|
105
|
+
|
|
106
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
107
|
+
eLow = eMid + 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
eHigh = eMid - 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const trimmedEnd = eHigh > 0 ? endFragment.slice(-eHigh) : "";
|
|
115
|
+
|
|
116
|
+
setDisplayText(ellipsis + trimmedEnd);
|
|
117
|
+
onTruncate?.(true);
|
|
118
|
+
measurer.remove();
|
|
119
|
+
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let low = 0;
|
|
124
|
+
let high = startSource.length;
|
|
125
|
+
|
|
126
|
+
while (low <= high) {
|
|
127
|
+
const mid = Math.floor((low + high) / 2);
|
|
128
|
+
measurer.textContent = startSource.slice(0, mid) + ellipsis + endFragment;
|
|
129
|
+
|
|
130
|
+
if (measurer.scrollHeight <= maxHeight + 1) {
|
|
131
|
+
low = mid + 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
high = mid - 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const startFragment = startSource.slice(0, Math.max(high, 0));
|
|
139
|
+
|
|
140
|
+
setDisplayText(startFragment + ellipsis + endFragment);
|
|
141
|
+
onTruncate?.(true);
|
|
142
|
+
measurer.remove();
|
|
143
|
+
}, [children, end, ellipsis, maxLines, onTruncate]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
compute();
|
|
147
|
+
}, [compute]);
|
|
148
|
+
|
|
149
|
+
// recompute on resize
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const parent = contentRef.current?.parentElement;
|
|
152
|
+
if (!parent) return;
|
|
153
|
+
|
|
154
|
+
const observer = new ResizeObserver(() => compute());
|
|
155
|
+
observer.observe(parent);
|
|
156
|
+
|
|
157
|
+
return () => observer.disconnect();
|
|
158
|
+
}, [compute]);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
contentRef,
|
|
162
|
+
contentProps: elementProps({
|
|
163
|
+
style: {
|
|
164
|
+
wordBreak: "break-all",
|
|
165
|
+
},
|
|
166
|
+
children: displayText ?? children,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
}
|