Files
elink-test-master/TestMasterUI/src/components/shared/EditableInput.jsx
T
2025-01-21 13:19:21 +09:00

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;