@workflow/web-shared 5.0.0-beta.3 → 5.0.0-beta.5
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/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/event-list.js +7 -4
- package/dist/components/new-trace-viewer/components/event-list.js.map +1 -1
- package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts +18 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js +54 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/copy-selection.js.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts +20 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js +81 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/middle-truncate.js.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts +25 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js +101 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/truncate.js.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts +20 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.d.ts.map +1 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js +114 -0
- package/dist/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.js.map +1 -0
- package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/split-pane.js +2 -3
- package/dist/components/new-trace-viewer/components/split-pane.js.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.d.ts +10 -11
- package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/components/timeline.js +57 -53
- package/dist/components/new-trace-viewer/components/timeline.js.map +1 -1
- package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/context.js +5 -7
- package/dist/components/new-trace-viewer/context.js.map +1 -1
- package/dist/components/new-trace-viewer/detail-panel.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/detail-panel.js +2 -1
- package/dist/components/new-trace-viewer/detail-panel.js.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/trace-viewer.js +44 -18
- package/dist/components/new-trace-viewer/trace-viewer.js.map +1 -1
- package/dist/components/new-trace-viewer/utils.d.ts +3 -8
- package/dist/components/new-trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/new-trace-viewer/utils.js +12 -65
- package/dist/components/new-trace-viewer/utils.js.map +1 -1
- package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
- package/dist/components/sidebar/attribute-panel.js +6 -0
- package/dist/components/sidebar/attribute-panel.js.map +1 -1
- package/dist/components/trace-viewer/util/timing.d.ts +1 -1
- package/dist/components/trace-viewer/util/timing.d.ts.map +1 -1
- package/dist/components/trace-viewer/util/timing.js +5 -8
- package/dist/components/trace-viewer/util/timing.js.map +1 -1
- package/dist/components/ui/error-stack-block.d.ts.map +1 -1
- package/dist/components/ui/error-stack-block.js +24 -2
- package/dist/components/ui/error-stack-block.js.map +1 -1
- package/dist/components/workflow-trace-view.js +1 -1
- package/dist/components/workflow-trace-view.js.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.d.ts +2 -2
- package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
- package/dist/components/workflow-traces/trace-span-construction.js +17 -4
- package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
- package/dist/lib/hydration.d.ts +6 -0
- package/dist/lib/hydration.d.ts.map +1 -1
- package/dist/lib/hydration.js +124 -1
- package/dist/lib/hydration.js.map +1 -1
- package/dist/lib/trace-builder.d.ts.map +1 -1
- package/dist/lib/trace-builder.js +2 -1
- package/dist/lib/trace-builder.js.map +1 -1
- package/dist/lib/utils.d.ts +9 -7
- package/dist/lib/utils.d.ts.map +1 -1
- package/dist/lib/utils.js +27 -27
- package/dist/lib/utils.js.map +1 -1
- package/package.json +5 -4
- package/src/components/new-trace-viewer/components/event-list.tsx +26 -19
- package/src/components/new-trace-viewer/components/middle-truncate/copy-selection.ts +114 -0
- package/src/components/new-trace-viewer/components/middle-truncate/middle-truncate.tsx +157 -0
- package/src/components/new-trace-viewer/components/middle-truncate/truncate.ts +132 -0
- package/src/components/new-trace-viewer/components/middle-truncate/use-middle-truncate.ts +167 -0
- package/src/components/new-trace-viewer/components/split-pane.tsx +2 -3
- package/src/components/new-trace-viewer/components/timeline.tsx +119 -115
- package/src/components/new-trace-viewer/context.tsx +7 -9
- package/src/components/new-trace-viewer/detail-panel.tsx +2 -1
- package/src/components/new-trace-viewer/trace-viewer.tsx +100 -32
- package/src/components/new-trace-viewer/utils.ts +19 -93
- package/src/components/sidebar/attribute-panel.tsx +5 -0
- package/src/components/trace-viewer/util/timing.ts +5 -9
- package/src/components/ui/error-stack-block.tsx +32 -4
- package/src/components/workflow-trace-view.tsx +1 -1
- package/src/components/workflow-traces/trace-span-construction.ts +23 -5
- package/src/lib/hydration.ts +132 -1
- package/src/lib/trace-builder.ts +2 -1
- package/src/lib/utils.ts +29 -29
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { Circle } from 'lucide-react';
|
|
2
2
|
import { cn } from '../../../lib/utils';
|
|
3
3
|
import type { Span } from '../../trace-viewer/types';
|
|
4
|
-
import { formatDuration
|
|
4
|
+
import { formatDuration } from '../../trace-viewer/util/timing';
|
|
5
5
|
import {
|
|
6
6
|
WorkflowIcon,
|
|
7
7
|
WebhookIcon,
|
|
8
8
|
SleepIcon,
|
|
9
9
|
StepForwardIcon,
|
|
10
10
|
} from '../icons';
|
|
11
|
+
import { getSpanDurationMs } from '../utils';
|
|
12
|
+
import { MiddleTruncate } from './middle-truncate/middle-truncate';
|
|
11
13
|
|
|
12
14
|
interface EventStyle {
|
|
13
15
|
icon: React.ComponentType<{ className?: string }>;
|
|
@@ -26,6 +28,8 @@ const defaultStyle: EventStyle = {
|
|
|
26
28
|
className: 'text-gray-900',
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
const ROW_HEIGHT_CLASS = 'h-10';
|
|
32
|
+
|
|
29
33
|
function getEventStyle(resource: string, isErrored: boolean): EventStyle {
|
|
30
34
|
const style = eventStyles[resource] ?? defaultStyle;
|
|
31
35
|
return {
|
|
@@ -43,7 +47,7 @@ const EventRow = ({
|
|
|
43
47
|
isSelected: boolean;
|
|
44
48
|
onSelectSpan: (spanId: string) => void;
|
|
45
49
|
}) => {
|
|
46
|
-
const durationMs =
|
|
50
|
+
const durationMs = getSpanDurationMs(span);
|
|
47
51
|
const isErrored =
|
|
48
52
|
(span.attributes.data as Record<string, unknown>).status === 'failed';
|
|
49
53
|
const { icon: Icon, className: tagClassName } = getEventStyle(
|
|
@@ -53,24 +57,31 @@ const EventRow = ({
|
|
|
53
57
|
|
|
54
58
|
return (
|
|
55
59
|
<li
|
|
56
|
-
className=
|
|
60
|
+
className={cn(
|
|
61
|
+
'relative overflow-clip group after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-gray-alpha-400',
|
|
62
|
+
ROW_HEIGHT_CLASS
|
|
63
|
+
)}
|
|
57
64
|
role="treeitem"
|
|
58
65
|
aria-selected={isSelected}
|
|
59
66
|
aria-expanded={isSelected}
|
|
60
67
|
aria-level={1}
|
|
61
68
|
onClick={() => onSelectSpan(span.spanId)}
|
|
62
69
|
>
|
|
63
|
-
<div className="hover:bg-gray-100 group-aria-selected:bg-gray-100 group-aria-selected:hover:bg-gray-200
|
|
64
|
-
<div className="flex items-center
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
<div className="h-full hover:bg-gray-100 group-aria-selected:bg-gray-100 group-aria-selected:hover:bg-gray-200">
|
|
71
|
+
<div className="flex h-full min-w-0 items-center px-2">
|
|
72
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
73
|
+
<span className={cn('shrink-0', tagClassName)}>
|
|
74
|
+
<Icon className="w-4 h-4" />
|
|
75
|
+
</span>
|
|
76
|
+
<span className="min-w-0 text-label-14">
|
|
77
|
+
<MiddleTruncate value={span.name} />
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="ml-2 shrink-0">
|
|
81
|
+
<span className="text-label-14 text-gray-900 tabular-nums">
|
|
82
|
+
{formatDuration(durationMs)}
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
74
85
|
</div>
|
|
75
86
|
</div>
|
|
76
87
|
</li>
|
|
@@ -87,11 +98,7 @@ const EventList = ({
|
|
|
87
98
|
onSelectSpan: (spanId: string) => void;
|
|
88
99
|
}) => {
|
|
89
100
|
return (
|
|
90
|
-
<ul
|
|
91
|
-
id="event-list"
|
|
92
|
-
role="tree"
|
|
93
|
-
className="block min-h-0 overflow-visible px-2 py-2"
|
|
94
|
-
>
|
|
101
|
+
<ul id="event-list" role="tree" className="block min-h-0 overflow-visible">
|
|
95
102
|
{spans.map((span) => {
|
|
96
103
|
return (
|
|
97
104
|
<EventRow
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ELLIPSIS, toGraphemes } from './truncate';
|
|
2
|
+
|
|
3
|
+
interface GetMiddleTruncateCopyTextOptions {
|
|
4
|
+
prefixText: string;
|
|
5
|
+
selectionEnd: number;
|
|
6
|
+
selectionStart: number;
|
|
7
|
+
suffixText: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface GetMiddleTruncateCopyTextFromSelectionTextOptions {
|
|
12
|
+
prefixText: string;
|
|
13
|
+
selectionText: string;
|
|
14
|
+
suffixText: string;
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getMiddleTruncateCopyText({
|
|
19
|
+
prefixText,
|
|
20
|
+
selectionEnd,
|
|
21
|
+
selectionStart,
|
|
22
|
+
suffixText,
|
|
23
|
+
value,
|
|
24
|
+
}: GetMiddleTruncateCopyTextOptions): string | null {
|
|
25
|
+
const visibleText = prefixText + ELLIPSIS + suffixText;
|
|
26
|
+
|
|
27
|
+
if (
|
|
28
|
+
selectionStart < 0 ||
|
|
29
|
+
selectionEnd > visibleText.length ||
|
|
30
|
+
selectionStart >= selectionEnd
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (selectionStart === 0 && selectionEnd === visibleText.length) {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ellipsisStart = prefixText.length;
|
|
40
|
+
const ellipsisEnd = ellipsisStart + ELLIPSIS.length;
|
|
41
|
+
|
|
42
|
+
if (selectionStart > ellipsisStart || selectionEnd < ellipsisEnd) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const originalGraphemes = toGraphemes(value);
|
|
47
|
+
const selectedPrefixGraphemeCount = toGraphemes(
|
|
48
|
+
visibleText.slice(0, selectionStart)
|
|
49
|
+
).length;
|
|
50
|
+
const selectedSuffixGraphemeCount = toGraphemes(
|
|
51
|
+
visibleText.slice(ellipsisEnd, selectionEnd)
|
|
52
|
+
).length;
|
|
53
|
+
const suffixStart = originalGraphemes.length - toGraphemes(suffixText).length;
|
|
54
|
+
|
|
55
|
+
return originalGraphemes
|
|
56
|
+
.slice(
|
|
57
|
+
selectedPrefixGraphemeCount,
|
|
58
|
+
suffixStart + selectedSuffixGraphemeCount
|
|
59
|
+
)
|
|
60
|
+
.join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getMiddleTruncateCopyTextFromSelectionText({
|
|
64
|
+
prefixText,
|
|
65
|
+
selectionText,
|
|
66
|
+
suffixText,
|
|
67
|
+
value,
|
|
68
|
+
}: GetMiddleTruncateCopyTextFromSelectionTextOptions): string | null {
|
|
69
|
+
const visibleText = prefixText + ELLIPSIS + suffixText;
|
|
70
|
+
const trimmedSelectionText = selectionText.trim();
|
|
71
|
+
|
|
72
|
+
if (!trimmedSelectionText) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const leading = selectionText.slice(
|
|
77
|
+
0,
|
|
78
|
+
selectionText.length - selectionText.trimStart().length
|
|
79
|
+
);
|
|
80
|
+
const trailing = selectionText.slice(selectionText.trimEnd().length);
|
|
81
|
+
|
|
82
|
+
if (trimmedSelectionText === visibleText) {
|
|
83
|
+
return leading + value + trailing;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!trimmedSelectionText.includes(ELLIPSIS)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const selectionStart = visibleText.indexOf(trimmedSelectionText);
|
|
91
|
+
if (selectionStart === -1) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const selectionEnd = selectionStart + trimmedSelectionText.length;
|
|
96
|
+
const mappedText = getMiddleTruncateCopyText({
|
|
97
|
+
prefixText,
|
|
98
|
+
selectionEnd,
|
|
99
|
+
selectionStart,
|
|
100
|
+
suffixText,
|
|
101
|
+
value,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return mappedText === null ? null : leading + mappedText + trailing;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
getMiddleTruncateCopyText,
|
|
109
|
+
getMiddleTruncateCopyTextFromSelectionText,
|
|
110
|
+
};
|
|
111
|
+
export type {
|
|
112
|
+
GetMiddleTruncateCopyTextFromSelectionTextOptions,
|
|
113
|
+
GetMiddleTruncateCopyTextOptions,
|
|
114
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
import type { ClipboardEvent, ComponentPropsWithoutRef, JSX } from 'react';
|
|
5
|
+
import { cn } from '../../../../lib/utils';
|
|
6
|
+
import {
|
|
7
|
+
getMiddleTruncateCopyText,
|
|
8
|
+
getMiddleTruncateCopyTextFromSelectionText,
|
|
9
|
+
} from './copy-selection';
|
|
10
|
+
import { ELLIPSIS } from './truncate';
|
|
11
|
+
import { useMiddleTruncate } from './use-middle-truncate';
|
|
12
|
+
|
|
13
|
+
/** Props for the {@link MiddleTruncate} component. */
|
|
14
|
+
interface MiddleTruncateProps
|
|
15
|
+
extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
|
|
16
|
+
/** The full text string to display, truncated from the middle when it overflows. */
|
|
17
|
+
value: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getRangeOffsets(
|
|
21
|
+
container: HTMLElement,
|
|
22
|
+
range: Range
|
|
23
|
+
): { end: number; start: number } | null {
|
|
24
|
+
if (
|
|
25
|
+
!container.contains(range.startContainer) ||
|
|
26
|
+
!container.contains(range.endContainer)
|
|
27
|
+
) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const startRange = document.createRange();
|
|
32
|
+
startRange.selectNodeContents(container);
|
|
33
|
+
startRange.setEnd(range.startContainer, range.startOffset);
|
|
34
|
+
|
|
35
|
+
const endRange = document.createRange();
|
|
36
|
+
endRange.selectNodeContents(container);
|
|
37
|
+
endRange.setEnd(range.endContainer, range.endOffset);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
end: endRange.toString().length,
|
|
41
|
+
start: startRange.toString().length,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @gdoc
|
|
47
|
+
*
|
|
48
|
+
* Truncates text from the middle with an ellipsis when it overflows, preserving the beginning and end (e.g. file paths, URLs). Copy behavior restores the full untruncated text to the clipboard.
|
|
49
|
+
* Renders a `<span>` element.
|
|
50
|
+
*
|
|
51
|
+
* Documentation: [Geist Middle Truncate](https://vercel.com/geist/middle-truncate)
|
|
52
|
+
*
|
|
53
|
+
* @param value - Full text string to display.
|
|
54
|
+
*/
|
|
55
|
+
function MiddleTruncate({
|
|
56
|
+
value,
|
|
57
|
+
className,
|
|
58
|
+
onCopy: onCopyProp,
|
|
59
|
+
...props
|
|
60
|
+
}: MiddleTruncateProps): JSX.Element {
|
|
61
|
+
const { ref, measureRef, displayText, isTruncated, prefixText, suffixText } =
|
|
62
|
+
useMiddleTruncate(value);
|
|
63
|
+
const visibleRef = useRef<HTMLSpanElement | null>(null);
|
|
64
|
+
|
|
65
|
+
const handleCopy = useCallback(
|
|
66
|
+
(e: ClipboardEvent<HTMLSpanElement>) => {
|
|
67
|
+
onCopyProp?.(e);
|
|
68
|
+
if (e.defaultPrevented || !isTruncated) return;
|
|
69
|
+
|
|
70
|
+
const selection = window.getSelection();
|
|
71
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
72
|
+
const selectionText = selection.toString();
|
|
73
|
+
if (!selectionText) return;
|
|
74
|
+
|
|
75
|
+
// Only intercept when the selection is fully within this element.
|
|
76
|
+
// If the user selected across elements (e.g. a table row), let
|
|
77
|
+
// the browser's default behavior preserve surrounding context.
|
|
78
|
+
const range = selection.getRangeAt(0);
|
|
79
|
+
const visibleEl = visibleRef.current;
|
|
80
|
+
let copyText: string | null = null;
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
e.currentTarget.contains(range.startContainer) &&
|
|
84
|
+
e.currentTarget.contains(range.endContainer) &&
|
|
85
|
+
visibleEl
|
|
86
|
+
) {
|
|
87
|
+
const offsets = getRangeOffsets(visibleEl, range);
|
|
88
|
+
if (offsets) {
|
|
89
|
+
copyText = getMiddleTruncateCopyText({
|
|
90
|
+
prefixText,
|
|
91
|
+
selectionEnd: offsets.end,
|
|
92
|
+
selectionStart: offsets.start,
|
|
93
|
+
suffixText,
|
|
94
|
+
value,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
copyText ??= getMiddleTruncateCopyTextFromSelectionText({
|
|
100
|
+
prefixText,
|
|
101
|
+
selectionText,
|
|
102
|
+
suffixText,
|
|
103
|
+
value,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (copyText === null) return;
|
|
107
|
+
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
e.clipboardData.setData('text/plain', copyText);
|
|
110
|
+
},
|
|
111
|
+
[onCopyProp, isTruncated, prefixText, suffixText, value]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<span
|
|
116
|
+
title={isTruncated ? value : undefined}
|
|
117
|
+
{...props}
|
|
118
|
+
ref={ref}
|
|
119
|
+
className={cn(
|
|
120
|
+
'relative inline-grid min-w-0 max-w-full overflow-hidden whitespace-nowrap',
|
|
121
|
+
className
|
|
122
|
+
)}
|
|
123
|
+
onCopy={handleCopy}
|
|
124
|
+
>
|
|
125
|
+
{isTruncated && <span className="sr-only select-none">{value}</span>}
|
|
126
|
+
<span
|
|
127
|
+
aria-hidden="true"
|
|
128
|
+
className="pointer-events-none col-start-1 row-start-1 invisible select-none whitespace-nowrap"
|
|
129
|
+
>
|
|
130
|
+
{value}
|
|
131
|
+
</span>
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden={isTruncated || undefined}
|
|
134
|
+
className="col-start-1 row-start-1 min-w-0 overflow-hidden"
|
|
135
|
+
ref={visibleRef}
|
|
136
|
+
>
|
|
137
|
+
{isTruncated ? (
|
|
138
|
+
<>
|
|
139
|
+
<span>{prefixText}</span>
|
|
140
|
+
<span>{ELLIPSIS}</span>
|
|
141
|
+
<span>{suffixText}</span>
|
|
142
|
+
</>
|
|
143
|
+
) : (
|
|
144
|
+
displayText
|
|
145
|
+
)}
|
|
146
|
+
</span>
|
|
147
|
+
<span
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
className="pointer-events-none absolute left-0 top-0 inline-block invisible select-none whitespace-nowrap"
|
|
150
|
+
ref={measureRef}
|
|
151
|
+
/>
|
|
152
|
+
</span>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { MiddleTruncate };
|
|
157
|
+
export type { MiddleTruncateProps };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const ELLIPSIS = '...';
|
|
2
|
+
const MIN_START = 3;
|
|
3
|
+
const MIN_END = 3;
|
|
4
|
+
const MIN_KEPT = MIN_START + MIN_END;
|
|
5
|
+
const graphemeSegmenter =
|
|
6
|
+
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
7
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
8
|
+
: null;
|
|
9
|
+
|
|
10
|
+
type MeasureFn = (text: string) => number;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Splits a string into grapheme clusters using Intl.Segmenter.
|
|
14
|
+
* Falls back to Array.from for environments without Intl.Segmenter.
|
|
15
|
+
*/
|
|
16
|
+
function toGraphemes(text: string): string[] {
|
|
17
|
+
if (graphemeSegmenter) {
|
|
18
|
+
return [...graphemeSegmenter.segment(text)].map((s) => s.segment);
|
|
19
|
+
}
|
|
20
|
+
return Array.from(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TruncateResult {
|
|
24
|
+
prefixText: string;
|
|
25
|
+
prefixGraphemeCount: number;
|
|
26
|
+
suffixText: string;
|
|
27
|
+
suffixGraphemeCount: number;
|
|
28
|
+
text: string;
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildCandidate(graphemes: string[], kept: number): TruncateResult {
|
|
33
|
+
if (kept <= 0) {
|
|
34
|
+
return {
|
|
35
|
+
prefixText: '',
|
|
36
|
+
prefixGraphemeCount: 0,
|
|
37
|
+
suffixText: '',
|
|
38
|
+
suffixGraphemeCount: 0,
|
|
39
|
+
text: ELLIPSIS,
|
|
40
|
+
truncated: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const suffixGraphemeCount =
|
|
45
|
+
kept >= MIN_KEPT
|
|
46
|
+
? Math.max(MIN_END, Math.floor(kept / 2))
|
|
47
|
+
: Math.floor(kept / 2);
|
|
48
|
+
const prefixGraphemeCount = kept - suffixGraphemeCount;
|
|
49
|
+
const prefixText = graphemes.slice(0, prefixGraphemeCount).join('');
|
|
50
|
+
const suffixText =
|
|
51
|
+
suffixGraphemeCount > 0
|
|
52
|
+
? graphemes.slice(-suffixGraphemeCount).join('')
|
|
53
|
+
: '';
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
prefixText,
|
|
57
|
+
prefixGraphemeCount,
|
|
58
|
+
suffixText,
|
|
59
|
+
suffixGraphemeCount,
|
|
60
|
+
text: prefixText + ELLIPSIS + suffixText,
|
|
61
|
+
truncated: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Middle-truncates a string so `prefix...suffix` fits within `availableWidth`.
|
|
67
|
+
* Uses binary search over grapheme clusters with an injectable measure function.
|
|
68
|
+
*/
|
|
69
|
+
function middleTruncate(
|
|
70
|
+
graphemes: string[],
|
|
71
|
+
availableWidth: number,
|
|
72
|
+
measure: MeasureFn,
|
|
73
|
+
fullWidth?: number
|
|
74
|
+
): TruncateResult {
|
|
75
|
+
const fullText = graphemes.join('');
|
|
76
|
+
const resolvedFullWidth = fullWidth ?? measure(fullText);
|
|
77
|
+
|
|
78
|
+
if (availableWidth <= 0 || graphemes.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
prefixText: fullText,
|
|
81
|
+
prefixGraphemeCount: graphemes.length,
|
|
82
|
+
suffixText: '',
|
|
83
|
+
suffixGraphemeCount: 0,
|
|
84
|
+
text: fullText,
|
|
85
|
+
truncated: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (resolvedFullWidth <= availableWidth) {
|
|
90
|
+
return {
|
|
91
|
+
prefixText: fullText,
|
|
92
|
+
prefixGraphemeCount: graphemes.length,
|
|
93
|
+
suffixText: '',
|
|
94
|
+
suffixGraphemeCount: 0,
|
|
95
|
+
text: fullText,
|
|
96
|
+
truncated: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Binary search for the maximum number of kept graphemes
|
|
101
|
+
let lo = 0;
|
|
102
|
+
let hi = graphemes.length - 1;
|
|
103
|
+
let best = -1;
|
|
104
|
+
|
|
105
|
+
while (lo <= hi) {
|
|
106
|
+
const mid = (lo + hi) >>> 1;
|
|
107
|
+
const candidate = buildCandidate(graphemes, mid);
|
|
108
|
+
|
|
109
|
+
if (measure(candidate.text) <= availableWidth) {
|
|
110
|
+
best = mid;
|
|
111
|
+
lo = mid + 1;
|
|
112
|
+
} else {
|
|
113
|
+
hi = mid - 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (best === -1) {
|
|
118
|
+
return {
|
|
119
|
+
prefixText: '',
|
|
120
|
+
prefixGraphemeCount: 0,
|
|
121
|
+
suffixText: '',
|
|
122
|
+
suffixGraphemeCount: 0,
|
|
123
|
+
text: '',
|
|
124
|
+
truncated: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return buildCandidate(graphemes, best);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { middleTruncate, toGraphemes, ELLIPSIS, MIN_START, MIN_END };
|
|
132
|
+
export type { MeasureFn, TruncateResult };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { middleTruncate, toGraphemes } from './truncate';
|
|
12
|
+
|
|
13
|
+
const useIsomorphicLayoutEffect =
|
|
14
|
+
typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
15
|
+
|
|
16
|
+
interface MiddleTruncateState {
|
|
17
|
+
displayText: string;
|
|
18
|
+
isTruncated: boolean;
|
|
19
|
+
prefixGraphemeCount: number;
|
|
20
|
+
prefixText: string;
|
|
21
|
+
suffixGraphemeCount: number;
|
|
22
|
+
suffixText: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createFullState(
|
|
26
|
+
value: string,
|
|
27
|
+
graphemes: string[]
|
|
28
|
+
): MiddleTruncateState {
|
|
29
|
+
return {
|
|
30
|
+
displayText: value,
|
|
31
|
+
isTruncated: false,
|
|
32
|
+
prefixGraphemeCount: graphemes.length,
|
|
33
|
+
prefixText: value,
|
|
34
|
+
suffixGraphemeCount: 0,
|
|
35
|
+
suffixText: '',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Middle-truncation logic. Returns refs to attach to the container and measurement elements, plus the truncated display text. Recalculates on resize, font loading, and value changes.
|
|
41
|
+
* Powers the `<MiddleTruncate>` (`<span>`) component.
|
|
42
|
+
*
|
|
43
|
+
* Documentation: [Geist Middle Truncate](https://vercel.com/geist/middle-truncate)
|
|
44
|
+
*
|
|
45
|
+
* @param value - Full text string to truncate.
|
|
46
|
+
*/
|
|
47
|
+
function useMiddleTruncate(value: string): {
|
|
48
|
+
ref: React.RefObject<HTMLSpanElement | null>;
|
|
49
|
+
measureRef: React.RefObject<HTMLSpanElement | null>;
|
|
50
|
+
displayText: string;
|
|
51
|
+
isTruncated: boolean;
|
|
52
|
+
prefixGraphemeCount: number;
|
|
53
|
+
prefixText: string;
|
|
54
|
+
suffixGraphemeCount: number;
|
|
55
|
+
suffixText: string;
|
|
56
|
+
} {
|
|
57
|
+
const graphemes = useMemo(() => toGraphemes(value), [value]);
|
|
58
|
+
const fullState = useMemo(
|
|
59
|
+
() => createFullState(value, graphemes),
|
|
60
|
+
[graphemes, value]
|
|
61
|
+
);
|
|
62
|
+
const ref = useRef<HTMLSpanElement | null>(null);
|
|
63
|
+
const measureRef = useRef<HTMLSpanElement | null>(null);
|
|
64
|
+
const [state, setState] = useState<MiddleTruncateState>(() => fullState);
|
|
65
|
+
const rafRef = useRef<number>(0);
|
|
66
|
+
|
|
67
|
+
const updateState = useCallback((nextState: MiddleTruncateState) => {
|
|
68
|
+
setState((currentState) => {
|
|
69
|
+
if (
|
|
70
|
+
currentState.displayText === nextState.displayText &&
|
|
71
|
+
currentState.isTruncated === nextState.isTruncated &&
|
|
72
|
+
currentState.prefixText === nextState.prefixText &&
|
|
73
|
+
currentState.prefixGraphemeCount === nextState.prefixGraphemeCount &&
|
|
74
|
+
currentState.suffixText === nextState.suffixText &&
|
|
75
|
+
currentState.suffixGraphemeCount === nextState.suffixGraphemeCount
|
|
76
|
+
) {
|
|
77
|
+
return currentState;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return nextState;
|
|
81
|
+
});
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const recalculate = useCallback(() => {
|
|
85
|
+
const el = ref.current;
|
|
86
|
+
const measureEl = measureRef.current;
|
|
87
|
+
if (!el || !measureEl) return;
|
|
88
|
+
|
|
89
|
+
const available = el.clientWidth;
|
|
90
|
+
|
|
91
|
+
if (available <= 0) {
|
|
92
|
+
updateState(fullState);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const measure = (text: string): number => {
|
|
97
|
+
measureEl.textContent = text;
|
|
98
|
+
return measureEl.scrollWidth;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const fullWidth = measure(value);
|
|
102
|
+
|
|
103
|
+
if (fullWidth <= available) {
|
|
104
|
+
updateState(fullState);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = middleTruncate(graphemes, available, measure, fullWidth);
|
|
109
|
+
updateState({
|
|
110
|
+
displayText: result.text,
|
|
111
|
+
isTruncated: result.truncated,
|
|
112
|
+
prefixGraphemeCount: result.prefixGraphemeCount,
|
|
113
|
+
prefixText: result.prefixText,
|
|
114
|
+
suffixGraphemeCount: result.suffixGraphemeCount,
|
|
115
|
+
suffixText: result.suffixText,
|
|
116
|
+
});
|
|
117
|
+
}, [fullState, graphemes, updateState, value]);
|
|
118
|
+
|
|
119
|
+
// Measure on mount and when value changes - before paint
|
|
120
|
+
useIsomorphicLayoutEffect(() => {
|
|
121
|
+
recalculate();
|
|
122
|
+
}, [recalculate]);
|
|
123
|
+
|
|
124
|
+
// ResizeObserver + font loading for ongoing responsiveness
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const el = ref.current;
|
|
127
|
+
if (!el) return;
|
|
128
|
+
|
|
129
|
+
const debouncedRecalc = (): void => {
|
|
130
|
+
cancelAnimationFrame(rafRef.current);
|
|
131
|
+
rafRef.current = requestAnimationFrame(recalculate);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const ro =
|
|
135
|
+
typeof ResizeObserver !== 'undefined'
|
|
136
|
+
? new ResizeObserver(debouncedRecalc)
|
|
137
|
+
: null;
|
|
138
|
+
ro?.observe(el);
|
|
139
|
+
window.addEventListener('resize', debouncedRecalc);
|
|
140
|
+
|
|
141
|
+
const onFontsLoaded = (): void => {
|
|
142
|
+
debouncedRecalc();
|
|
143
|
+
};
|
|
144
|
+
const fontSet = 'fonts' in document ? document.fonts : null;
|
|
145
|
+
fontSet?.addEventListener?.('loadingdone', onFontsLoaded);
|
|
146
|
+
|
|
147
|
+
return () => {
|
|
148
|
+
ro?.disconnect();
|
|
149
|
+
window.removeEventListener('resize', debouncedRecalc);
|
|
150
|
+
cancelAnimationFrame(rafRef.current);
|
|
151
|
+
fontSet?.removeEventListener?.('loadingdone', onFontsLoaded);
|
|
152
|
+
};
|
|
153
|
+
}, [recalculate]);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ref,
|
|
157
|
+
measureRef,
|
|
158
|
+
displayText: state.displayText,
|
|
159
|
+
isTruncated: state.isTruncated,
|
|
160
|
+
prefixGraphemeCount: state.prefixGraphemeCount,
|
|
161
|
+
prefixText: state.prefixText,
|
|
162
|
+
suffixGraphemeCount: state.suffixGraphemeCount,
|
|
163
|
+
suffixText: state.suffixText,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export { useMiddleTruncate };
|
|
@@ -11,8 +11,7 @@ import {
|
|
|
11
11
|
} from 'react';
|
|
12
12
|
import { cn } from '../../../lib/utils';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
const GUTTER_PX = 9;
|
|
14
|
+
const GUTTER_PX = 1;
|
|
16
15
|
const MIN_PX = 50;
|
|
17
16
|
const DEFAULT_START_PX = 340;
|
|
18
17
|
|
|
@@ -155,7 +154,7 @@ export function SplitPane({
|
|
|
155
154
|
<div
|
|
156
155
|
ref={containerRef}
|
|
157
156
|
className={cn(
|
|
158
|
-
'grid flex-1 min-h-0
|
|
157
|
+
'grid flex-1 min-h-0 overflow-x-hidden overflow-y-auto',
|
|
159
158
|
isDragging && 'select-none'
|
|
160
159
|
)}
|
|
161
160
|
style={{ gridTemplateColumns: colTemplate }}
|