-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathContentEditableTextarea.tsx
More file actions
124 lines (112 loc) · 3.88 KB
/
ContentEditableTextarea.tsx
File metadata and controls
124 lines (112 loc) · 3.88 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
import { observer } from 'mobx-react-lite';
import React, { useCallback, useEffect, useRef, useState } from 'react';
interface ContentEditableTextareaProps {
value: string;
placeholder: string;
onSave: (value: string) => void;
className?: string;
style?: React.CSSProperties;
}
export const ContentEditableTextarea = observer(
({
value,
placeholder,
onSave,
className = '',
style = {},
}: ContentEditableTextareaProps) => {
const [isEditing, setIsEditing] = useState(false);
const [currentValue, setCurrentValue] = useState(value);
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const showPlaceholder = !currentValue && !isEditing;
// Check if content is truncated for styling
const fullText = currentValue || placeholder;
const firstLine = fullText.split('\n')[0];
const isTruncated =
!isEditing &&
!showPlaceholder &&
(fullText.includes('\n') || firstLine.length > 100);
// Set initial content and update when not editing to preserve cursor position
useEffect(() => {
if (elementRef.current && !isEditing) {
const fullText = currentValue || placeholder;
const firstLine = fullText.split('\n')[0];
const hasMoreLines = fullText.includes('\n') || firstLine.length > 100;
const displayValue =
hasMoreLines && !showPlaceholder
? `${firstLine.slice(0, 100)}...`
: firstLine;
if (elementRef.current.textContent !== displayValue) {
elementRef.current.textContent = displayValue;
}
}
}, [currentValue, placeholder, isEditing, showPlaceholder]);
const handleFocus = useCallback(() => {
setIsEditing(true);
// When entering edit mode, show the full content
if (elementRef.current) {
elementRef.current.textContent = currentValue || '';
}
}, [currentValue]);
const handleBlur = useCallback(() => {
setIsEditing(false);
if (currentValue !== value) {
onSave(currentValue);
}
}, [currentValue, value, onSave]);
const handleInput = useCallback((e: React.FormEvent<HTMLDivElement>) => {
const newValue = e.currentTarget.textContent || '';
setCurrentValue(newValue);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setCurrentValue(value);
elementRef.current?.blur();
}
},
[value],
);
return (
<div
ref={elementRef}
contentEditable
suppressContentEditableWarning
onFocus={handleFocus}
onBlur={handleBlur}
onInput={handleInput}
onKeyDown={handleKeyDown}
title={isTruncated ? fullText : undefined}
className={`${className} ${showPlaceholder ? 'placeholder' : ''}`}
style={{
outline: 'none',
cursor: 'text',
minHeight: isEditing ? '3em' : '1.5em',
maxHeight: isEditing ? 'none' : '1.5em',
overflow: isEditing ? 'auto' : 'hidden',
padding: 'var(--component-spacing-sm)',
border: '1px solid var(--color-neutral-border-default)',
borderRadius: 'var(--border-radius-md)',
backgroundColor: 'var(--color-neutral-canvas-default)',
whiteSpace: isEditing ? 'pre-wrap' : 'nowrap',
wordWrap: 'break-word',
textOverflow: isEditing ? 'clip' : 'ellipsis',
transition: 'all 0.2s ease-in-out',
...style,
...(showPlaceholder && {
color: 'var(--color-neutral-canvas-default-fg-muted)',
fontStyle: 'italic',
}),
...(isTruncated && {
borderColor: 'var(--color-neutral-border-subtle)',
backgroundColor: 'var(--color-neutral-canvas-subtle)',
}),
}}
children={undefined}
/>
);
},
);