-
Notifications
You must be signed in to change notification settings - Fork 139
Expand file tree
/
Copy pathTitleEditable.tsx
More file actions
359 lines (288 loc) · 10.5 KB
/
TitleEditable.tsx
File metadata and controls
359 lines (288 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
import { debounce } from 'lodash-es';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Log } from '@/utils/log';
/**
* Title Update Flow & Echo Prevention Mechanism:
*
* 1. USER INPUT → LOCAL UPDATE
* - User types → debounced update (300ms) → send to server
* - User blurs/enters → immediate update → send to server
* - Cache sent values with timestamps for echo detection
*
* 2. REMOTE UPDATE HANDLING
* - Ignore updates while user is actively typing (500ms window)
* - Ignore updates shortly after sending (2s protection window)
* - Detect and ignore "echo" updates (values we recently sent)
* - Accept genuine remote updates and clean old cache entries
*
* 3. ECHO PREVENTION STRATEGY
* - Track sent values in Map<string, timestamp>
* - Ignore remote updates matching recently sent values
* - Auto-cleanup old cache entries (15s expiry)
* - Clear old cache when genuine remote updates arrive
*/
// Cursor utility functions
const isCursorAtEnd = (el: HTMLDivElement) => {
const selection = window.getSelection();
if (!selection) return false;
const range = selection.getRangeAt(0);
const text = el.textContent || '';
return range.startOffset === text.length;
};
const getCursorOffset = () => {
const selection = window.getSelection();
if (!selection) return 0;
return selection.getRangeAt(0).startOffset;
};
const setCursorPosition = (element: HTMLDivElement, position: number) => {
const range = document.createRange();
const selection = window.getSelection();
if (!element.firstChild) return;
const textNode = element.firstChild;
const maxPosition = textNode.textContent?.length || 0;
const safePosition = Math.min(position, maxPosition);
range.setStart(textNode, safePosition);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
};
function TitleEditable({
viewId,
name,
onUpdateName,
onEnter,
onFocus,
autoFocus = true,
}: {
viewId: string;
name: string;
onUpdateName: (name: string) => void;
onEnter?: (text: string) => void;
onFocus?: () => void;
autoFocus?: boolean;
}) {
const { t } = useTranslation();
// Component state and refs
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
// Timing and cache refs
const lastInputTimeRef = useRef<number>(0);
const lastUpdateSentTimeRef = useRef<number>(0);
const sentValuesRef = useRef<Map<string, number>>(new Map());
// Timer refs
const inputTimerRef = useRef<NodeJS.Timeout>();
const blurTimerRef = useRef<NodeJS.Timeout>();
const cleanupTimerRef = useRef<NodeJS.Timeout>();
// Cache management
const cleanOldSentValues = useCallback(() => {
const now = Date.now();
const maxAge = 15000; // 15 seconds
for (const [value, timestamp] of sentValuesRef.current.entries()) {
if (now - timestamp > maxAge) {
sentValuesRef.current.delete(value);
Log.debug('🧹 Cleaned old sent value:', value);
}
}
}, []);
const scheduleCleanup = useCallback(() => {
if (cleanupTimerRef.current) {
clearTimeout(cleanupTimerRef.current);
}
cleanupTimerRef.current = setTimeout(cleanOldSentValues, 5000);
}, [cleanOldSentValues]);
// Update functions - send changes to server and cache for echo detection
const sendUpdate = useCallback((value: string, isImmediate = false) => {
Log.debug(isImmediate ? '⚡ Immediate update:' : '⏰ Debounced update:', value);
const now = Date.now();
lastUpdateSentTimeRef.current = now;
sentValuesRef.current.set(value, now);
scheduleCleanup();
onUpdateName(value);
}, [onUpdateName, scheduleCleanup]);
const debouncedUpdate = useMemo(() => {
return debounce((value: string) => sendUpdate(value, false), 300);
}, [sendUpdate]);
const sendUpdateImmediately = useCallback((value: string) => {
debouncedUpdate.cancel();
sendUpdate(value, true);
}, [debouncedUpdate, sendUpdate]);
// Handle remote updates with echo prevention
useEffect(() => {
const now = Date.now();
const isTyping = now - lastInputTimeRef.current < 500;
const isRecentlyUpdated = now - lastUpdateSentTimeRef.current < 2000;
// Skip if the user is actively editing — preserves in-progress typing.
// Without this guard, a remote echo would clobber characters mid-keystroke.
if (isTyping || isRecentlyUpdated) {
return;
}
if (sentValuesRef.current.has(name)) {
return;
}
// If the title was auto-focused on mount but the user hasn't typed yet,
// allow remote updates to flow through. This handles the case where the
// initial `name` prop changes shortly after mount (e.g. database title
// resolving from the child view's "Grid" to the container's real name)
// — without this, a no-op blur would persist the stale initial value.
const hasUserTyped = lastInputTimeRef.current > 0;
if (isFocused && hasUserTyped) {
return;
}
// Genuine remote update — clean old cache entries
for (const [value, timestamp] of sentValuesRef.current.entries()) {
if (now - timestamp > 5000) {
sentValuesRef.current.delete(value);
}
}
// Apply remote update to UI
if (contentRef.current) {
const currentContent = contentRef.current.textContent || '';
if (currentContent !== name) {
contentRef.current.textContent = name;
// Restore cursor to end if the input is currently focused.
if (isFocused && contentRef.current === document.activeElement) {
setCursorPosition(contentRef.current, name.length);
}
}
}
}, [name, isFocused]);
// Initialize component
useEffect(() => {
const contentBox = contentRef.current;
if (!contentBox) {
console.warn('[TitleEditable] contentRef not available yet');
return;
}
contentBox.textContent = name;
if (autoFocus) {
requestAnimationFrame(() => {
if (contentBox && document.contains(contentBox)) {
contentBox.focus();
if (contentBox.textContent) {
setCursorPosition(contentBox, contentBox.textContent.length);
}
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const focusedTextbox = useCallback(() => {
const textbox = document.getElementById(`editor-${viewId}`) as HTMLElement;
textbox?.focus();
}, [viewId]);
// Event handlers with useCallback optimization
const handleFocus = useCallback(() => {
Log.debug('🎯 Input focused');
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
blurTimerRef.current = undefined;
}
setIsFocused(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(() => {
Log.debug('👋 Input blurred');
const currentText = contentRef.current?.textContent || '';
const hasUserTyped = lastInputTimeRef.current > 0;
// Only persist on blur if the user actually edited the field. Auto-focus
// without typing must not overwrite the stored name with whatever stale
// text was rendered at mount time.
if (hasUserTyped) {
sendUpdateImmediately(currentText);
}
setIsFocused(false);
blurTimerRef.current = setTimeout(() => {
Log.debug('🧹 Cleaning input state after blur');
lastInputTimeRef.current = 0;
if (inputTimerRef.current) {
clearTimeout(inputTimerRef.current);
}
}, 100);
}, [sendUpdateImmediately]);
const handleInput = useCallback(() => {
if (!contentRef.current) return;
lastInputTimeRef.current = Date.now();
// Clean up browser auto-inserted <br> tags
if (contentRef.current.innerHTML === '<br>') {
contentRef.current.innerHTML = '';
}
const currentText = contentRef.current.textContent || '';
debouncedUpdate(currentText);
if (inputTimerRef.current) {
clearTimeout(inputTimerRef.current);
}
inputTimerRef.current = setTimeout(() => {
Log.debug('⏸️ User stopped typing');
}, 500);
}, [debouncedUpdate]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (!contentRef.current) return;
lastInputTimeRef.current = Date.now();
if (e.key === 'Enter' || e.key === 'Escape') {
e.preventDefault();
if (e.key === 'Enter') {
const currentText = e.currentTarget.textContent || '';
const offset = getCursorOffset();
if (offset >= currentText.length || offset <= 0) {
sendUpdateImmediately(currentText);
onEnter?.('');
} else {
const beforeText = currentText.slice(0, offset);
const afterText = currentText.slice(offset);
contentRef.current.textContent = beforeText;
sendUpdateImmediately(beforeText);
onEnter?.(afterText);
}
setTimeout(() => focusedTextbox(), 0);
} else {
const currentText = contentRef.current.textContent || '';
sendUpdateImmediately(currentText);
}
setTimeout(() => {
lastInputTimeRef.current = 0;
if (inputTimerRef.current) {
clearTimeout(inputTimerRef.current);
}
}, 100);
} else if (e.key === 'ArrowDown' || (e.key === 'ArrowRight' && isCursorAtEnd(contentRef.current))) {
e.preventDefault();
focusedTextbox();
}
}, [sendUpdateImmediately, onEnter, focusedTextbox]);
// Cleanup timers
useEffect(() => {
return () => {
if (inputTimerRef.current) {
clearTimeout(inputTimerRef.current);
}
if (blurTimerRef.current) {
clearTimeout(blurTimerRef.current);
}
if (cleanupTimerRef.current) {
clearTimeout(cleanupTimerRef.current);
}
debouncedUpdate.cancel();
};
}, [debouncedUpdate]);
return (
<div
ref={contentRef}
suppressContentEditableWarning={true}
id={`editor-title-${viewId}`}
data-testid='page-title-input'
style={{ wordBreak: 'break-word' }}
className={
'custom-caret relative flex-1 cursor-text whitespace-pre-wrap break-words empty:before:text-text-tertiary empty:before:content-[attr(data-placeholder)] focus:outline-none'
}
data-placeholder={t('menuAppHeader.defaultNewPageName')}
contentEditable={true}
autoFocus={autoFocus}
onFocus={handleFocus}
onBlur={handleBlur}
onInput={handleInput}
onKeyDown={handleKeyDown}
/>
);
}
export default memo(TitleEditable);