223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
|
|
export interface RichTextInputProps {
|
|
style?: React.CSSProperties;
|
|
onChange?: (value: string) => void;
|
|
onKeyDown?: (event: React.KeyboardEvent) => void;
|
|
onPaste?: (event: React.ClipboardEvent) => void;
|
|
placeholder?: string;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
|
|
export const RichTextInput = forwardRef((props : any, ref) => {
|
|
const editableDivRef = useRef<HTMLDivElement>(null);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getValue,
|
|
insertHTML,
|
|
focus,
|
|
clear,
|
|
insertHTMLInCurrentCarretPosition
|
|
}));
|
|
|
|
useEffect(() => {
|
|
if(props.autoFocus && editableDivRef.current){
|
|
focusEditableElement(editableDivRef.current);
|
|
}
|
|
}, [props.autoFocus]);
|
|
|
|
const focus = () => {
|
|
if(editableDivRef.current){
|
|
focusEditableElement(editableDivRef.current);
|
|
}
|
|
}
|
|
|
|
const insertHTMLInCurrentCarretPosition = (html: string) => {
|
|
if(!editableDivRef.current){
|
|
return;
|
|
}
|
|
if(document.activeElement !== editableDivRef.current){
|
|
focusEditableElement(editableDivRef.current);
|
|
}
|
|
document.execCommand('insertHTML', false, html);
|
|
}
|
|
|
|
const clear = () => {
|
|
if(editableDivRef.current){
|
|
editableDivRef.current.innerHTML = "";
|
|
}
|
|
if(props.onChange){
|
|
props.onChange("");
|
|
}
|
|
}
|
|
|
|
const focusEditableElement = (element: HTMLElement) => {
|
|
/**
|
|
* Focus to the end of the contenteditable element
|
|
*/
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
range.selectNodeContents(element);
|
|
range.collapse(false);
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(range);
|
|
element.focus();
|
|
}
|
|
|
|
const insertHTML = (html: string) => {
|
|
if(!editableDivRef.current) return;
|
|
let div = document.createElement("div");
|
|
div.innerHTML = html;
|
|
if(props.style && props.style.fontSize){
|
|
/**
|
|
* Prepare all elements to have the same font size as the editable div
|
|
*/
|
|
div.querySelectorAll('*').forEach(el => {
|
|
el.setAttribute('style', `
|
|
font-size: ${props.style?.fontSize}px;
|
|
vertical-align: sub;
|
|
display: inline-block;
|
|
margin-left: 1px;
|
|
margin-right: 1px;
|
|
user-select: none;
|
|
`);
|
|
el.setAttribute('width', '17');
|
|
el.setAttribute('height', '17');
|
|
});
|
|
}
|
|
let preparedHtml = div.innerHTML;
|
|
//let carret = saveCarretPosition(editableDivRef.current);
|
|
editableDivRef.current.innerHTML += preparedHtml;
|
|
//editableDivRef.current.focus();
|
|
//insertHtmlAtCarretPosition(preparedHtml);
|
|
if(props.onChange){
|
|
props.onChange(getValue());
|
|
}
|
|
//focusEditableElement(editableDivRef.current, carret);
|
|
}
|
|
|
|
const getValue = () : string => {
|
|
if(!editableDivRef.current) return "";
|
|
editableDivRef.current.querySelectorAll('*').forEach(el => {
|
|
if(el.tagName === 'BR'){
|
|
el.textContent = '\n';
|
|
return;
|
|
}
|
|
if(!el.hasAttribute("data")){
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
let text = el.getAttribute("data") || '';
|
|
el.textContent = text;
|
|
});
|
|
let content = editableDivRef.current.textContent || "";
|
|
if(content.endsWith('\n') && content.length == 1){
|
|
/**
|
|
* Remove trailing new line added by contenteditable div
|
|
* (bug in some browsers includes electron)
|
|
*/
|
|
content = content.slice(0, -1);
|
|
}
|
|
return content || "";
|
|
}
|
|
|
|
|
|
// const onCopy = (event : React.ClipboardEvent) => {
|
|
// //event.preventDefault();
|
|
// //console.info("COPY EVENT", event);
|
|
// //let value = getValue();
|
|
// //console.info("COPY VALUE", value);
|
|
// //event.clipboardData.setData('text/plain', value);
|
|
// }
|
|
|
|
const onPaste = (event: React.ClipboardEvent) => {
|
|
event.preventDefault();
|
|
let text = event.clipboardData.getData('text/plain');
|
|
if(text.trim() != '' && editableDivRef.current){
|
|
const html = text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
.replace(/\n/g, "<br>");
|
|
document.execCommand('insertHTML', false, html);
|
|
if(props.onChange){
|
|
props.onChange(getValue());
|
|
}
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
if(event.clipboardData.items[0].kind !== 'string'){
|
|
event.preventDefault();
|
|
}
|
|
if(props.onPaste){
|
|
props.onPaste(event);
|
|
}
|
|
}
|
|
|
|
const onKeyPress = (event : React.KeyboardEvent) => {
|
|
if (event.keyCode == 13 && event.shiftKey == true) {
|
|
//event.preventDefault();
|
|
//addLineBreak();
|
|
//return;
|
|
}
|
|
requestAnimationFrame(() => {
|
|
if(editableDivRef.current?.innerHTML.trim() == '<br>'){
|
|
editableDivRef.current.innerHTML = '';
|
|
}
|
|
if(props.onChange){
|
|
props.onChange(getValue());
|
|
}
|
|
});
|
|
if(props.onKeyDown){
|
|
props.onKeyDown(event);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
width: props.style?.width || '100%',
|
|
maxWidth: props.style?.width || '100%',
|
|
display: 'inline-block',
|
|
position: 'relative',
|
|
}}>
|
|
<div
|
|
onDrag={e => e.preventDefault()}
|
|
onPaste={onPaste}
|
|
//onCopy={onCopy}
|
|
onKeyDown={onKeyPress}
|
|
ref={editableDivRef}
|
|
contentEditable={true}
|
|
suppressContentEditableWarning
|
|
style={{
|
|
overflowX: 'hidden',
|
|
maxHeight: 150,
|
|
maxWidth: '100%',
|
|
overflowY: 'auto',
|
|
wordWrap: 'break-word',
|
|
whiteSpace: 'pre-wrap',
|
|
verticalAlign: 'middle',
|
|
boxSizing: 'border-box',
|
|
outline: 'none',
|
|
...props.style,
|
|
width: '100%',
|
|
}}
|
|
></div>
|
|
{getValue() == "" && props.placeholder && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: 10,
|
|
left: 10,
|
|
pointerEvents: 'none',
|
|
color: '#888',
|
|
userSelect: 'none',
|
|
fontSize: props.style?.fontSize || 14,
|
|
}}>
|
|
{props.placeholder}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}); |