Skip to content

Instantly share code, notes, and snippets.

@HallexCosta
Created September 22, 2025 04:11
Show Gist options
  • Save HallexCosta/524ffee47991df115fa8e06ccc2c443c to your computer and use it in GitHub Desktop.
Save HallexCosta/524ffee47991df115fa8e06ccc2c443c to your computer and use it in GitHub Desktop.
ritchtext
import React, { useRef, useEffect, useState, useMemo, useLayoutEffect } from 'react';
export function App() {
const editorRef = useRef(null);
const placeholderRef = useRef(null)
const [text, setText] = useState('Lorem {sender}');
const [isEmpty, setIsEmpty] = useState(true)
//const isEmpty = text === '';
// pega posição absoluta do cursor
function getCaretCharacterOffsetWithin(element) {
let caretOffset = 0;
const sel = window.getSelection();
if (sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(element);
preCaretRange.setEnd(range.endContainer, range.endOffset);
caretOffset = preCaretRange.toString().length;
}
return caretOffset;
}
// restaura o cursor na posição salva
function setCaretPosition(element, offset) {
let nodeStack = [element],
node;
let charIndex = 0;
let range = document.createRange();
range.setStart(element, 0);
range.collapse(true);
while ((node = nodeStack.pop())) {
if (node.nodeType === 3) {
// text node
let nextCharIndex = charIndex + node.length;
if (offset >= charIndex && offset <= nextCharIndex) {
range.setStart(node, offset - charIndex);
range.collapse(true);
break;
}
charIndex = nextCharIndex;
} else {
let i = node.childNodes.length;
while (i--) {
nodeStack.push(node.childNodes[i]);
}
}
}
let sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
function extractExtraText(text, placeholder) {
// transforma placeholder em regex literal
const regexStr = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// remove o placeholder literal do texto
const result = text.replace(new RegExp(regexStr, "g"), "").trim();
return result;
}
function highlightContent(el) {
const caretPos = getCaretCharacterOffsetWithin(el);
const text = el.textContent;
const isEquals = text === placeholderRef.current?.innerText
const extraText = extractExtraText(text, placeholderRef.current?.innerText)
console.log({
editorRef: text,
placeholderRef: placeholderRef.current?.innerText,
extractExtraText: extraText,
extractTextV2: text.slice(1, placeholderRef.current?.innerText.length - 1),
isEquals,
})
// if (isEmpty && !isEquals) {
// setIsEmpty(false)
// }
if (extraText.length > 0) {
setIsEmpty(false)
}
setText(extraText)
const tokens = text.split(/(\s+)/);
let newHTML = '';
tokens.forEach(token => {
if (/^\{[^}]+\}$/.test(token)) {
newHTML += `<span style="color:orange;font-weight:bold">${token}</span>`;
} else {
newHTML += token;
}
});
el.innerHTML = newHTML;
setCaretPosition(el, caretPos);
}
useEffect(() => {
const el = editorRef.current;
highlightContent(el);
}, []);
function handleShowValue() {
if (editorRef.current) {
const plainText = editorRef.current.textContent; // só texto
const htmlValue = editorRef.current.innerHTML; // com spans coloridos
console.log('Texto puro:', plainText);
console.log('Com highlight:', htmlValue);
setText(plainText);
// console.log('text', text)
}
}
useEffect(() => {
const handleSelectionChange = () => {
const sel = window.getSelection();
if (!sel || !editorRef.current) return;
if (editorRef.current.contains(sel.anchorNode)) {
// caret position relative to text content
// setCaretPos(sel.anchorOffset);
if (isEmpty) {
// moveCaretToStart(editorRef.current)
}
console.log({
text
})
console.log('sel.anchor', sel.anchorOffset, isEmpty)
}
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, []);
const moveCaretToStart = (el) => {
if (!el) return;
setTimeout(() => {
const selection = window.getSelection();
const range = document.createRange();
// Ensure we target the *contentEditable* element
range.setStart(el, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}, 1)
};
// useEffect(() => {
// if (text.length) {
// setIsEmpty(false)
// } else {
// setIsEmpty(true)
// }
// }, [text])
const handleOnFocus = (e) => {
// console.log({isEmpty})
if (isEmpty) {
moveCaretToStart(editorRef.current);
}
};
console.log({
isEmpty
})
return (
<>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={e => highlightContent(e.currentTarget)}
onFocus={handleOnFocus}
style={{
width: '400px',
minHeight: '120px',
padding: '10px',
backgroundColor: '#222',
color: 'white',
fontSize: '14px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
outline: 'none',
position: 'relative',
opacity: isEmpty ? 0.5 : 1,
}}
>
<div
ref={placeholderRef}
style={{
position: 'absolute',
top: '10px',
left: '10px',
color: '#888',
pointerEvents: 'none',
fontFamily: 'monospace',
fontSize: '14px',
display: isEmpty ? 'block' : 'none'
}}
>
{`Olá! Eu sou {sender_name} e escrevo para demonstrar interesse na posição de {job_title} na {company}. Podem me contatar via WhatsApp: {whatsapp_link} ou LinkedIn: {linkedin_link}`}
</div>
{text}
</div>
<button onClick={handleShowValue}>Mostrar valor</button>
</>
);
}
export default App
@HallanCosta
Copy link

Now it works

Only placeholder:
image

With Text:
image

Code working:

import React, { useRef, useEffect, useState } from 'react';

export default function App() {
  const editorRef = useRef(null);
  const placeholderRef = useRef(null)
  const [text, setText] = useState('{jobId} lorem ipsum {userId}');
  const [isEmpty, setIsEmpty] = useState(true)

  // pega posição absoluta do cursor
  function getCaretCharacterOffsetWithin(element) {
    let caretOffset = 0;
    const sel = window.getSelection();
    if (sel.rangeCount > 0) {
      const range = sel.getRangeAt(0);
      const preCaretRange = range.cloneRange();
      preCaretRange.selectNodeContents(element);
      preCaretRange.setEnd(range.endContainer, range.endOffset);
      caretOffset = preCaretRange.toString().length;
    }
    return caretOffset;
  }

  // restaura o cursor na posição salva
  function setCaretPosition(element, offset) {
    let nodeStack = [element],
      node;
    let charIndex = 0;
    let range = document.createRange();
    range.setStart(element, 0);
    range.collapse(true);

    while ((node = nodeStack.pop())) {
      if (node.nodeType === 3) {
        // text node
        let nextCharIndex = charIndex + node.length;
        if (offset >= charIndex && offset <= nextCharIndex) {
          range.setStart(node, offset - charIndex);
          range.collapse(true);
          break;
        }
        charIndex = nextCharIndex;
      } else {
        let i = node.childNodes.length;
        while (i--) {
          nodeStack.push(node.childNodes[i]);
        }
      }
    }

    let sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  }

  function highlightContent(el) {
    console.log('START highlightContent', el)
    const caretPos = getCaretCharacterOffsetWithin(el);
    const text = el.textContent;

    if (text.length > 0) {
      setIsEmpty(false)
    }

    if (text.length === 0) {
      setIsEmpty(true)
    }

    const tokens = text.split(/(\s+)/);
    let newHTML = '';

    tokens.forEach(token => {
      if (/^\{[^}]+\}$/.test(token)) {
        newHTML += `<span style="color:orange;font-weight:bold">${token}</span>`;
      } else {
        newHTML += token;
      }
    });

    el.innerHTML = newHTML;
    setCaretPosition(el, caretPos);
  }

  useEffect(() => {
    highlightContent(editorRef.current);
    highlightContent(placeholderRef.current);
  }, []);

  function handleShowValue() {
    if (editorRef.current) {
      const plainText = editorRef.current.textContent; // só texto
      const htmlValue = editorRef.current.innerHTML; // com spans coloridos

      console.log('Texto puro:', plainText);
      console.log('Com highlight:', htmlValue);
      setText(htmlValue);
    }
  }

  const moveCaretToStart = (el) => {
    if (!el) return;

    setTimeout(() => {
      const selection = window.getSelection();
      const range = document.createRange();

      // Ensure we target the *contentEditable* element
      range.setStart(el, 0);
      range.collapse(true);

      selection.removeAllRanges();
      selection.addRange(range);
    }, 1)
  };

  const handleOnFocus = (e) => {
    if (isEmpty) {
      moveCaretToStart(editorRef.current);
    }
  };

  return (
    <>
      <div style={{ position: 'relative' }}>
        <div
          ref={editorRef}
          contentEditable
          suppressContentEditableWarning
          onInput={e => highlightContent(e.currentTarget)}
          onFocus={handleOnFocus}
          style={{
            width: '400px',
            minHeight: '120px',
            padding: '10px',
            backgroundColor: '#222',
            color: 'white',
            fontSize: '14px',
            fontFamily: 'monospace',
            whiteSpace: 'pre-wrap',
            outline: 'none',
          }}
        >
          {text}
        </div>
        <div
          ref={placeholderRef}
          style={{
            width: '400px',
            minHeight: '120px',
            padding: '10px',
            backgroundColor: '#222',
            color: 'white',
            whiteSpace: 'pre-wrap',
            outline: 'none',

            pointerEvents: 'none',

            position: 'absolute',
            top: 0,
            left: 0,

            fontFamily: 'monospace',
            fontSize: '14px',
            opacity: isEmpty ? 0.5 : 1,
            display: isEmpty ? 'block' : 'none',
            zIndex: 1,
          }}
        > 
          {`Olá! Eu sou {sender_name} e escrevo para demonstrar interesse na posição de {job_title} na {company}. Podem me contatar via WhatsApp: {whatsapp_link} ou LinkedIn: {linkedin_link}`}
        </div>
      </div>
      <button onClick={handleShowValue}>Mostrar valor</button>
    </>
  );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment