╱╱╭╮╱╱╱╱╱╱╭━━━╮╱╱╱╭╮╱╭╮╱╱╱╱╱╱ ╱╱┃┃╱╱╱╱╱╱┃╭━╮┃╱╱╱┃┃╱┃┃╱╱╱╱╱╱ ╱╱┃┣━━┳━━╮┃┃╱┃┣━╮╱┃╰━╯┣━━┳━╮╱ ╭╮┃┃╭╮┃┃━┫┃╰━╯┃╭╮╮┃╭━╮┃╭╮┃╭╮╮ ┃╰╯┃╭╮┃┃━┫┃╭━╮┃┃┃┃┃┃╱┃┃╭╮┃┃┃┃ ╰━━┻╯╰┻━━╯╰╯╱╰┻╯╰╯╰╯╱╰┻╯╰┻╯╰╯

Frontend/React

[React] undo, redo, rollback을 지원하는 미니멀 편집기 구현하기 (content editable, selection, range, useRef)

재안안 2025. 3. 18. 00:27

  요즘들어 writable text node가 필요한 곳이 많다.

 

  <span />이나 <div />로 텍스트를 표현하다가 클릭 또는 더블 클릭을 하면 <input />으로 바꿔준 후, blur에서 다시 <span />이나 <div />로 바꾸는 작업을 여러번 했는데 문득 다른 방법은 없을까? 더 쉬운 방법은 없을까? 라는 생각이 들었다.

 

  개인적으로 상태를 최대한 줄이려고 하는데 해당 전환 작업 때문에 상태를 추가하는 것이 그닥 대키지 않고 상태 개수를 최소화 하기 위해서 이벤트 위임해 전환 상태를 관리하기에는 코드가 범용적이지 못하다.

 

  그래서 HTML의 editable 속성을 활용해서 내가 원하는 writable text node를 만들었다. 전문적인 텍스트 에디팅 기능을 제공한다. 해당 컴포넌트는 보통의 <input />이나 <textarea/>처럼 글자 추가와 삭제가 가능하며 undo (ctrl or command + z), redo (ctrl or command + y), paste (ctrl or command + v), 그리고 rollback이 가능하다.

 

  history stack을 이용하면 undo, redo, rollback 구현이 어렵지 않으나, 커서 위치 때문에 생각보다 애를 많이 먹었다.

텍스트 내용이 바뀌면 커서가 자꾸 index 0으로 이동하는데, 이런 행동은 글 수정시 기대하지 않는 동작이기 때문에 커서 위치를 자연스럽게, 기대하는 곳으로, 임의로 이동시키는 것이 생각보다 많이 어려웠다.

 

  사실 커서 위치는 아무도 기대하지 않는다. 그냥 마지막에 놓았던 위치에 고정 되있는 거라고 생각한다. 그런데 커서가 자꾸 제일 왼쪽으로 이동해서 마지막에 놓았던 위치에 돌려 놓는게 많이 어려웠다.

붙여넣기를 했는데 커서 위치가 0으로 간다..

undo를 했는데 커서가 제일 왼쪽으로 간다..


WritableText.jsx

function useHistory(contentRef, initialValue) {
  const historyRef = useRef({
    stack: [initialValue],
    currentIndex: 0,
    rollbackIndex: 0,
  });

  const addToHistory = (value) => {
    const history = historyRef.current;
     // redo 스택 클리어
    history.stack = history.stack.slice(0, history.currentIndex + 1);
    history.stack.push(value);
    history.currentIndex = history.stack.length - 1;
  };

  const setRollbackPoint = () => {
    historyRef.current.rollbackIndex = historyRef.current.currentIndex;
  };

  const rollback = () => {
    const history = historyRef.current;
    history.currentIndex = history.rollbackIndex;
    const content = history.stack[history.rollbackIndex];
    addToHistory(content);
    contentRef.current.textContent = content;
    return content;
  };

  const onUndo = () => {
    const history = historyRef.current;
    if (history.currentIndex > 0) {
      history.currentIndex--;
      const content = history.stack[history.currentIndex];
      contentRef.current.textContent = content;
      return content;
    }
    return null;
  };

  const onRedo = () => {
    const history = historyRef.current;
    if (history.currentIndex < history.stack.length - 1) {
      history.currentIndex++;
      const content = history.stack[history.currentIndex];
      contentRef.current.textContent = content;
      return content;
    }
    return null;
  };

  return {
    addToHistory,
    setRollbackPoint,
    rollback,
    onUndo,
    onRedo,
  };
};


export function WritableText({
  onSave,
  initialValue = '',
  placeholder = '텍스트를 입력하세요',
  className = '',
}) {
  const contentRef = useRef(null);
  const history = useHistory(contentRef, initialValue);

  const onFocus = (e) => {
    if (e.target.textContent === placeholder) {
      e.target.textContent = '';
    }
    history.setRollbackPoint();
  };

  const onInput = () => {
    const content = contentRef.current?.textContent || '';
    history.addToHistory(content);
  };

  const onBlur = () => {
    const content = contentRef.current?.textContent || '';
    history.addToHistory(content);
    onSave?.(content);
  };

  const onKeyDown = (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      contentRef.current?.blur();
      return;
    }

    if (e.key === 'Escape') {
      e.preventDefault();
      history.rollback();
      return;
    }
	
    const saveSelectionFromEnd = () => {
      const selection = window.getSelection();
      const range = selection.getRangeAt(0);
      // 커서 위치 보존용
      const currentLength = contentRef.current.textContent.length;
      return currentLength - range.endOffset;
    };

    const updateTextAndSelection = (content, distanceFromEnd) => {
      const textNode = contentRef.current?.firstChild;
      if (textNode) {
        textNode.nodeValue = content;
      } else {
        contentRef.current.textContent = content;
      }

      const createRangeWithText = (newTextNode) => {
        const newRange = document.createRange();
        const newLength = content.length;
        const newEnd = Math.max(0, newLength - distanceFromEnd);

        newRange.setStart(newTextNode, newEnd);
        newRange.setEnd(newTextNode, newEnd);

        return newRange;
      };

      const selection = window.getSelection();
      selection.removeAllRanges();
      selection.addRange(createRangeWithText(contentRef.current.firstChild));

      return content;
    };

    const isUndo =
      (e.ctrlKey || e.metaKey) &&
      e.key.toLowerCase() === 'z' &&
      !e.getModifierState('Shift');
    if (isUndo) {
      e.preventDefault();
      const distanceFromEnd = saveSelectionFromEnd();
      const undoContent = history.onUndo();
      if (undoContent) {
        updateTextAndSelection(undoContent, distanceFromEnd);
      }
      return;
    }

    const isRedo =
      ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') ||
      ((e.ctrlKey || e.metaKey) &&
        e.key.toLowerCase() === 'z' &&
        e.getModifierState('Shift'));
    if (isRedo) {
      e.preventDefault();
      const distanceFromEnd = saveSelectionFromEnd();
      const redoContent = history.onRedo();
      if (redoContent) {
        updateTextAndSelection(redoContent, distanceFromEnd);
      }
    }
  };

  const onPaste = (e) => {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    const insertTextAtCursor = (text) => {
      const selection = window.getSelection();
      const range = selection.getRangeAt(0);

      const startContainer = range.startContainer;
      const startOffset = range.startOffset;
      const endOffset = range.endOffset;

      const currentText = startContainer.textContent;
      const newText =
        currentText.slice(0, startOffset) + text + currentText.slice(endOffset);

      startContainer.textContent = newText;
      const createRangeWithPosition = (newPosition) => {
        const newRange = document.createRange();

        const textNode = startContainer.firstChild || startContainer;
        newRange.setStart(textNode, newPosition);
        newRange.setEnd(textNode, newPosition);
        return newRange;
      };

      selection.removeAllRanges();
      selection.addRange(createRangeWithPosition(startOffset + text.length));

      history.addToHistory(newText);
    };

    insertTextAtCursor(text);
  };

  useEffect(() => {
    const element = contentRef.current;
    if (!element) {
      return;
    }

    element.addEventListener('focus', onFocus);
    element.addEventListener('input', onInput);
    element.addEventListener('blur', onBlur);
    element.addEventListener('keydown', onKeyDown);
    element.addEventListener('paste', onPaste);

    return () => {
      element.removeEventListener('input', onInput);
      element.removeEventListener('blur', onBlur);
      element.removeEventListener('keydown', onKeyDown);
      element.removeEventListener('focus', onFocus);
      element.removeEventListener('paste', onPaste);
    };
  }, []);

  return useMemo(() => {
    return (
      <span
        ref={contentRef}
        contentEditable="plaintext-only"
        suppressContentEditableWarning // content editable 쓸려면 무시해야함 
        className={className}
      >
        {initialValue || placeholder}
      </span>
    );
  }, [className, onSave]);
};

  selection이 많이 알려지진 않았기에 조금 낯설 수도 있다. 그런데 익숙해지면 별거 없다. 본인은 Lexical을 통해 이전에 접해봤기 때문에 사용 할 줄을 아는 것 뿐이다. 사용할 줄 아는거지 잘하지는 않는다. 위에서는 커서라고 말했지만 정확하게는 Carret이라고 하는데, 이번에 처음해봐서 진짜 억지로 억지로 짜맞춘 느낌이고 과정도 순탄치 않았는데 생각대로 잘 안됐어서 더 재밌었다.

 

plaintext-only가 아니었다면, redo, undo, paste하기전에 <span id="position" />을 집어넣고 redo, undo, paste 이행 후, 해당 span으로 이동후 제거하는 방식으로 구현했을 것이다.

 

아니면 이런 방법도 있다.

function createRange(node, targetPosition) {
    let range = document.createRange();
    range.selectNode(node);
    range.setStart(node, 0);

    let pos = 0;
    const stack = [node];
    while (stack.length > 0) {
        const current = stack.pop();

        if (current.nodeType === Node.TEXT_NODE) {
            const len = current.textContent.length;
            if (pos + len >= targetPosition) {
                range.setEnd(current, targetPosition - pos);
                return range;
            }
            pos += len;
        } else if (current.childNodes && current.childNodes.length > 0) {
            for (let i = current.childNodes.length - 1; i >= 0; i--) {
                stack.push(current.childNodes[i]);
            }
        }
    }

    range.setEnd(node, node.childNodes.length);
    return range;
};

function setPosition(targetPosition){
    const range = createRange(contentEle, targetPosition);
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
};

 

그래도 사용했던 로직이 기대했던 대로 동작은 잘한다. 리액트 개발자가 아닌 JS 개발자로써의 내실이 더 다져진 것 같다. 완성후 오랜만에 뿌듯함을 느꼈다. 나중에 selection, range를 사용해서 드래그했을때 파란색 배경으로 하이라이트되는 것이 아니라 range만큼 font weight: bold가 되는 커스텀 selection을 만들어 보고 싶다.

 

다 떠나서 진짜 쉽지 않았다.

평소에는 <input /> 하나로 disable / enable 잘 바꿔서 써야겠다.