33
44import { makeIconClass } from "@/util/util" ;
55import { cn } from "@/util/util" ;
6+ import { useCallback , useEffect , useRef , useState } from "react" ;
7+
8+ const RenameFocusDelayMs = 50 ;
69
710export interface VTabItem {
811 id : string ;
@@ -17,6 +20,7 @@ interface VTabProps {
1720 isReordering : boolean ;
1821 onSelect : ( ) => void ;
1922 onClose ?: ( ) => void ;
23+ onRename ?: ( newName : string ) => void ;
2024 onDragStart : ( event : React . DragEvent < HTMLDivElement > ) => void ;
2125 onDragOver : ( event : React . DragEvent < HTMLDivElement > ) => void ;
2226 onDrop : ( event : React . DragEvent < HTMLDivElement > ) => void ;
@@ -30,27 +34,108 @@ export function VTab({
3034 isReordering,
3135 onSelect,
3236 onClose,
37+ onRename,
3338 onDragStart,
3439 onDragOver,
3540 onDrop,
3641 onDragEnd,
3742} : VTabProps ) {
43+ const [ originalName , setOriginalName ] = useState ( tab . name ) ;
44+ const [ isEditable , setIsEditable ] = useState ( false ) ;
45+ const editableRef = useRef < HTMLDivElement > ( null ) ;
46+ const editableTimeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
47+
48+ useEffect ( ( ) => {
49+ setOriginalName ( tab . name ) ;
50+ } , [ tab . name ] ) ;
51+
52+ useEffect ( ( ) => {
53+ return ( ) => {
54+ if ( editableTimeoutRef . current ) {
55+ clearTimeout ( editableTimeoutRef . current ) ;
56+ }
57+ } ;
58+ } , [ ] ) ;
59+
60+ const selectEditableText = useCallback ( ( ) => {
61+ if ( ! editableRef . current ) {
62+ return ;
63+ }
64+ editableRef . current . focus ( ) ;
65+ const range = document . createRange ( ) ;
66+ const selection = window . getSelection ( ) ;
67+ if ( ! selection ) {
68+ return ;
69+ }
70+ range . selectNodeContents ( editableRef . current ) ;
71+ selection . removeAllRanges ( ) ;
72+ selection . addRange ( range ) ;
73+ } , [ ] ) ;
74+
75+ const startRename = useCallback ( ( ) => {
76+ if ( onRename == null || isReordering ) {
77+ return ;
78+ }
79+ if ( editableTimeoutRef . current ) {
80+ clearTimeout ( editableTimeoutRef . current ) ;
81+ }
82+ setIsEditable ( true ) ;
83+ editableTimeoutRef . current = setTimeout ( ( ) => {
84+ selectEditableText ( ) ;
85+ } , RenameFocusDelayMs ) ;
86+ } , [ isReordering , onRename , selectEditableText ] ) ;
87+
88+ const handleBlur = ( ) => {
89+ if ( ! editableRef . current ) {
90+ return ;
91+ }
92+ const newText = editableRef . current . textContent ?. trim ( ) || originalName ;
93+ editableRef . current . textContent = newText ;
94+ setIsEditable ( false ) ;
95+ if ( newText !== originalName ) {
96+ onRename ?.( newText ) ;
97+ }
98+ } ;
99+
100+ const handleKeyDown : React . KeyboardEventHandler < HTMLDivElement > = ( event ) => {
101+ if ( ! editableRef . current ) {
102+ return ;
103+ }
104+ if ( event . key === "Enter" ) {
105+ event . preventDefault ( ) ;
106+ event . stopPropagation ( ) ;
107+ editableRef . current . blur ( ) ;
108+ return ;
109+ }
110+ if ( event . key !== "Escape" ) {
111+ return ;
112+ }
113+ editableRef . current . textContent = originalName ;
114+ editableRef . current . blur ( ) ;
115+ event . preventDefault ( ) ;
116+ event . stopPropagation ( ) ;
117+ } ;
118+
38119 return (
39120 < div
40121 draggable
41122 onClick = { onSelect }
123+ onDoubleClick = { ( event ) => {
124+ event . stopPropagation ( ) ;
125+ startRename ( ) ;
126+ } }
42127 onDragStart = { onDragStart }
43128 onDragOver = { onDragOver }
44129 onDrop = { onDrop }
45130 onDragEnd = { onDragEnd }
46131 className = { cn (
47- "group relative flex h-9 w-full cursor-pointer items-center rounded-md border pl-2 pr-1 text-sm transition-colors select-none" ,
132+ "group relative flex h-9 w-full cursor-pointer items-center border-b border-border/70 pl-2 text-sm transition-colors select-none" ,
48133 "whitespace-nowrap" ,
49134 active
50- ? "border-accent/40 bg-accent/20 text-primary"
135+ ? "bg-accent/20 text-primary"
51136 : isReordering
52- ? "border-transparent bg-transparent text-secondary"
53- : "border-transparent bg-transparent text-secondary hover:border-border hover:bg-hover" ,
137+ ? "bg-transparent text-secondary"
138+ : "bg-transparent text-secondary hover:bg-hover" ,
54139 isDragging && "opacity-50"
55140 ) }
56141 >
@@ -59,19 +144,28 @@ export function VTab({
59144 < i className = { makeIconClass ( tab . indicator . icon , true , { defaultIcon : "bell" } ) } />
60145 </ span >
61146 ) }
62- < span
147+ < div
148+ ref = { editableRef }
63149 className = { cn (
64150 "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right]" ,
65- onClose && ! isReordering && "group-hover:pr-6"
151+ onClose && ! isReordering && "group-hover:pr-[18px]" ,
152+ isEditable && "rounded-[2px] bg-white/15 outline-none"
66153 ) }
154+ contentEditable = { isEditable }
155+ role = "textbox"
156+ aria-label = "Tab name"
157+ aria-readonly = { ! isEditable }
158+ onBlur = { handleBlur }
159+ onKeyDown = { handleKeyDown }
160+ suppressContentEditableWarning = { true }
67161 >
68162 { tab . name }
69- </ span >
163+ </ div >
70164 { onClose && (
71165 < button
72166 type = "button"
73167 className = { cn (
74- "absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer p-1 text-secondary transition" ,
168+ "absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer py-1 pl-1 pr-1.5 text-secondary transition" ,
75169 isReordering ? "opacity-0" : "opacity-0 group-hover:opacity-100 hover:text-primary"
76170 ) }
77171 onClick = { ( event ) => {
0 commit comments