195 lines
5.0 KiB
React
195 lines
5.0 KiB
React
import React, { useState, useRef, useEffect } from 'react';
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const EditableInput = ({
|
|
name = '',
|
|
value = '',
|
|
className = '',
|
|
style = {},
|
|
onChange = () => {},
|
|
onVariableHover = () => {}
|
|
}) => {
|
|
const contentRef = useRef(null);
|
|
const [internalValue, setInternalValue] = useState(value);
|
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
|
|
// Get current cursor position
|
|
const saveSelection = () => {
|
|
const sel = window.getSelection();
|
|
if (sel.rangeCount > 0) {
|
|
const range = sel.getRangeAt(0);
|
|
const preCaretRange = range.cloneRange();
|
|
preCaretRange.selectNodeContents(contentRef.current);
|
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
const start = preCaretRange.toString().length;
|
|
|
|
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
const end = preCaretRange.toString().length;
|
|
|
|
return { start, end };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Restore cursor position
|
|
const restoreSelection = (pos) => {
|
|
if (!pos || !contentRef.current) return;
|
|
|
|
const sel = window.getSelection();
|
|
const range = document.createRange();
|
|
let currentPos = 0;
|
|
let startNode = null;
|
|
let startOffset = 0;
|
|
let endNode = null;
|
|
let endOffset = 0;
|
|
|
|
const traverse = (node) => {
|
|
if (node.nodeType === 3) { // Text node
|
|
const nextPos = currentPos + node.length;
|
|
|
|
if (!startNode && pos.start >= currentPos && pos.start <= nextPos) {
|
|
startNode = node;
|
|
startOffset = pos.start - currentPos;
|
|
}
|
|
|
|
if (!endNode && pos.end >= currentPos && pos.end <= nextPos) {
|
|
endNode = node;
|
|
endOffset = pos.end - currentPos;
|
|
}
|
|
|
|
currentPos = nextPos;
|
|
} else {
|
|
for (const child of node.childNodes) {
|
|
traverse(child);
|
|
}
|
|
}
|
|
};
|
|
|
|
traverse(contentRef.current);
|
|
|
|
if (startNode && endNode) {
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(endNode, endOffset);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
};
|
|
|
|
// Parse content to find variables
|
|
const parseContent = (text) => {
|
|
const regex = /{{.*?}}/g;
|
|
const parts = [];
|
|
let lastIndex = 0;
|
|
|
|
let match;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
if (match.index > lastIndex) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.slice(lastIndex, match.index)
|
|
});
|
|
}
|
|
parts.push({
|
|
type: 'variable',
|
|
content: match[0]
|
|
});
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
|
|
if (lastIndex < text.length) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.slice(lastIndex)
|
|
});
|
|
}
|
|
|
|
return parts;
|
|
};
|
|
|
|
const handleInput = (e) => {
|
|
const newValue = e.target.textContent;
|
|
const newSelection = saveSelection();
|
|
|
|
setInternalValue(newValue);
|
|
setSelection(newSelection);
|
|
onChange(newValue);
|
|
};
|
|
|
|
const handlePaste = (e) => {
|
|
e.preventDefault();
|
|
const text = e.clipboardData.getData('text/plain');
|
|
const selection = window.getSelection();
|
|
|
|
if (selection.rangeCount > 0) {
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
|
|
const processedContent = parseContent(text);
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
processedContent.forEach(({ type, content }) => {
|
|
const span = document.createElement('span');
|
|
span.textContent = content;
|
|
if (type === 'variable') {
|
|
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
|
|
}
|
|
fragment.appendChild(span);
|
|
});
|
|
|
|
range.insertNode(fragment);
|
|
handleInput({ target: contentRef.current });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!contentRef.current) return;
|
|
|
|
const parts = parseContent(internalValue);
|
|
const currentSelection = saveSelection() || selection;
|
|
|
|
contentRef.current.innerHTML = '';
|
|
|
|
parts.forEach(({ type, content }) => {
|
|
const span = document.createElement('span');
|
|
span.textContent = content;
|
|
|
|
if (type === 'variable') {
|
|
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
|
|
span.onmouseover = () => onVariableHover(content);
|
|
}
|
|
|
|
contentRef.current.appendChild(span);
|
|
});
|
|
|
|
restoreSelection(currentSelection);
|
|
}, [internalValue]);
|
|
|
|
// Update internal value when prop value changes
|
|
useEffect(() => {
|
|
if (value !== internalValue) {
|
|
setInternalValue(value);
|
|
}
|
|
}, [value]);
|
|
|
|
return (
|
|
<div
|
|
ref={contentRef}
|
|
contentEditable
|
|
className={cn(
|
|
"min-h-[40px] rounded-md border border-input bg-background px-3 py-2",
|
|
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
"transition-colors",
|
|
className
|
|
)}
|
|
style={style}
|
|
onInput={handleInput}
|
|
onPaste={handlePaste}
|
|
data-value={internalValue}
|
|
data-name={name}
|
|
suppressContentEditableWarning={true}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default EditableInput;
|