해결 팁 / 선택 범위 저장 및 복원하기

선택 범위 저장 및 복원하기

특히 DOM 조작 후 contenteditable에서 선택 범위를 안정적으로 저장하고 복원하는 방법

난이도: 중급
카테고리: common-patterns
selectionrangesaverestorecursorpattern

이 Tip을 사용할 때

다음과 같은 경우에 이 패턴을 사용하세요:

  • DOM 조작 전에 선택 저장
  • DOM 변경 후 선택 복원
  • React/Vue 리렌더링 중 커서 위치 유지
  • 프레임워크 상태 업데이트 간 선택 처리
  • 프로그래밍 방식으로 콘텐츠를 수정할 때 사용자의 선택 보존

문제

contenteditable에서 DOM을 조작하면 브라우저가 선택을 추적하지 못합니다. 다음 상황에서 발생합니다:

  • React/Vue 리렌더링이 DOM 노드를 교체할 때
  • 프로그래밍 방식 DOM 변경이 발생할 때
  • 텍스트 노드가 분할되거나 병합될 때
  • 요소가 감싸지거나 언래핑될 때
  • 콘텐츠가 교체될 때

해결 방법

1. 기본 선택 저장/복원

선택을 저장하고 복원하는 간단한 패턴:

class SelectionManager {
  constructor(editor) {
    this.editor = editor;
    this.savedRange = null;
  }
  
  saveSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) {
      this.savedRange = null;
      return;
    }
    
    const range = selection.getRangeAt(0);
    this.savedRange = {
      startContainer: range.startContainer,
      startOffset: range.startOffset,
      endContainer: range.endContainer,
      endOffset: range.endOffset,
      commonAncestorContainer: range.commonAncestorContainer,
    };
  }
  
  restoreSelection() {
    if (!this.savedRange) return;
    
    const selection = window.getSelection();
    const range = document.createRange();
    
    try {
      // 노드가 여전히 DOM에 있는지 확인
      if (!this.savedRange.startContainer.isConnected ||
          !this.savedRange.endContainer.isConnected) {
        // 노드가 제거됨, 동등한 위치 찾기 시도
        this.restoreSelectionFallback();
        return;
      }
      
      range.setStart(
        this.savedRange.startContainer,
        this.savedRange.startOffset
      );
      range.setEnd(
        this.savedRange.endContainer,
        this.savedRange.endOffset
      );
      
      selection.removeAllRanges();
      selection.addRange(range);
    } catch (e) {
      console.error('선택 복원 실패:', e);
      this.restoreSelectionFallback();
    }
  }
  
  restoreSelectionFallback() {
    // 대체: 에디터 끝에 커서 설정
    const selection = window.getSelection();
    const range = document.createRange();
    range.selectNodeContents(this.editor);
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
  }
  
  clearSelection() {
    this.savedRange = null;
  }
}

// 사용법
const editor = document.querySelector('div[contenteditable]');
const selectionManager = new SelectionManager(editor);

// DOM 조작 전
selectionManager.saveSelection();

// ... DOM 변경 수행 ...

// DOM 조작 후
selectionManager.restoreSelection();

2. 문자 오프셋 기반 선택

선택을 문자 오프셋으로 저장 (DOM 변경에 더 강함):

class CharacterOffsetSelection {
  constructor(editor) {
    this.editor = editor;
  }
  
  saveSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    
    const range = selection.getRangeAt(0);
    
    // 시작 오프셋 계산
    const startRange = range.cloneRange();
    startRange.selectNodeContents(this.editor);
    startRange.setEnd(range.startContainer, range.startOffset);
    const startOffset = startRange.toString().length;
    
    // 끝 오프셋 계산
    const endRange = range.cloneRange();
    endRange.selectNodeContents(this.editor);
    endRange.setEnd(range.endContainer, range.endOffset);
    const endOffset = endRange.toString().length;
    
    return {
      start: startOffset,
      end: endOffset,
      collapsed: range.collapsed,
    };
  }
  
  restoreSelection(savedSelection) {
    if (!savedSelection) return;
    
    const selection = window.getSelection();
    const range = document.createRange();
    
    // 시작 위치 찾기
    const startPos = this.findPosition(savedSelection.start);
    if (startPos) {
      range.setStart(startPos.node, startPos.offset);
    } else {
      return; // 복원 불가
    }
    
    // 끝 위치 찾기
    if (savedSelection.collapsed) {
      range.collapse(true);
    } else {
      const endPos = this.findPosition(savedSelection.end);
      if (endPos) {
        range.setEnd(endPos.node, endPos.offset);
      } else {
        range.collapse(true);
      }
    }
    
    selection.removeAllRanges();
    selection.addRange(range);
  }
  
  findPosition(offset) {
    let currentOffset = 0;
    const walker = document.createTreeWalker(
      this.editor,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    let node;
    while (node = walker.nextNode()) {
      const nodeLength = node.textContent.length;
      
      if (currentOffset + nodeLength >= offset) {
        return {
          node,
          offset: offset - currentOffset,
        };
      }
      
      currentOffset += nodeLength;
    }
    
    // 오프셋이 콘텐츠를 넘어서면 마지막 위치 반환
    const lastNode = this.getLastTextNode(this.editor);
    if (lastNode) {
      return {
        node: lastNode,
        offset: lastNode.textContent.length,
      };
    }
    
    return null;
  }
  
  getLastTextNode(element) {
    const walker = document.createTreeWalker(
      element,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    let lastNode = null;
    let node;
    while (node = walker.nextNode()) {
      lastNode = node;
    }
    
    return lastNode;
  }
}

// 사용법
const editor = document.querySelector('div[contenteditable]');
const selectionManager = new CharacterOffsetSelection(editor);

// DOM 변경 전 저장
const saved = selectionManager.saveSelection();

// ... DOM 수정 ...

// 변경 후 복원
selectionManager.restoreSelection(saved);

3. React 통합 패턴

React에서 선택 저장/복원하여 커서 점프 방지:

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

function ContentEditable({ value, onChange }) {
  const editorRef = useRef(null);
  const selectionRef = useRef(null);
  
  const saveSelection = useCallback(() => {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return;
    
    const range = selection.getRangeAt(0);
    const editor = editorRef.current;
    
    // 문자 오프셋 계산
    const startRange = range.cloneRange();
    startRange.selectNodeContents(editor);
    startRange.setEnd(range.startContainer, range.startOffset);
    const startOffset = startRange.toString().length;
    
    const endRange = range.cloneRange();
    endRange.selectNodeContents(editor);
    endRange.setEnd(range.endContainer, range.endOffset);
    const endOffset = endRange.toString().length;
    
    selectionRef.current = {
      start: startOffset,
      end: endOffset,
      collapsed: range.collapsed,
    };
  }, []);
  
  const restoreSelection = useCallback(() => {
    if (!selectionRef.current || !editorRef.current) return;
    
    const selection = window.getSelection();
    const range = document.createRange();
    const editor = editorRef.current;
    
    // 시작 위치 찾기
    let currentOffset = 0;
    const walker = document.createTreeWalker(
      editor,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    let startNode = null;
    let startOffset = 0;
    let node;
    
    while (node = walker.nextNode()) {
      const nodeLength = node.textContent.length;
      
      if (currentOffset + nodeLength >= selectionRef.current.start) {
        startNode = node;
        startOffset = selectionRef.current.start - currentOffset;
        break;
      }
      
      currentOffset += nodeLength;
    }
    
    if (!startNode) return;
    
    range.setStart(startNode, startOffset);
    
    if (selectionRef.current.collapsed) {
      range.collapse(true);
    } else {
      // 끝 위치 찾기
      let endNode = null;
      let endOffset = 0;
      currentOffset = 0;
      walker = document.createTreeWalker(
        editor,
        NodeFilter.SHOW_TEXT,
        null
      );
      
      while (node = walker.nextNode()) {
        const nodeLength = node.textContent.length;
        
        if (currentOffset + nodeLength >= selectionRef.current.end) {
          endNode = node;
          endOffset = selectionRef.current.end - currentOffset;
          break;
        }
        
        currentOffset += nodeLength;
      }
      
      if (endNode) {
        range.setEnd(endNode, endOffset);
      } else {
        range.collapse(true);
      }
    }
    
    selection.removeAllRanges();
    selection.addRange(range);
  }, []);
  
  useEffect(() => {
    const editor = editorRef.current;
    if (!editor) return;
    
    // 입력 시 선택 저장
    editor.addEventListener('input', saveSelection);
    editor.addEventListener('keyup', saveSelection);
    editor.addEventListener('mouseup', saveSelection);
    
    return () => {
      editor.removeEventListener('input', saveSelection);
      editor.removeEventListener('keyup', saveSelection);
      editor.removeEventListener('mouseup', saveSelection);
    };
  }, [saveSelection]);
  
  useEffect(() => {
    // 값 변경 후 선택 복원
    if (editorRef.current && selectionRef.current) {
      // DOM이 업데이트되었는지 확인하기 위해 setTimeout 사용
      setTimeout(() => {
        restoreSelection();
      }, 0);
    }
  }, [value, restoreSelection]);
  
  const handleInput = (e) => {
    saveSelection();
    onChange(e.currentTarget.textContent);
  };
  
  return (
    <div
      ref={editorRef}
      contentEditable
      onInput={handleInput}
      suppressContentEditableWarning
      dangerouslySetInnerHTML={{ __html: value }}
    />
  );
}

4. 포괄적인 선택 관리자

여러 저장/복원 전략을 제공하는 완전한 해결책:

class ComprehensiveSelectionManager {
  constructor(editor) {
    this.editor = editor;
    this.savedSelection = null;
    this.strategy = 'character-offset'; // 'node-offset' | 'character-offset' | 'marker'
  }
  
  saveSelection(strategy = this.strategy) {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) {
      this.savedSelection = null;
      return;
    }
    
    const range = selection.getRangeAt(0);
    
    switch (strategy) {
      case 'node-offset':
        this.savedSelection = this.saveNodeOffset(range);
        break;
      case 'character-offset':
        this.savedSelection = this.saveCharacterOffset(range);
        break;
      case 'marker':
        this.savedSelection = this.saveWithMarkers(range);
        break;
      default:
        this.savedSelection = this.saveCharacterOffset(range);
    }
    
    this.savedSelection.strategy = strategy;
  }
  
  saveNodeOffset(range) {
    return {
      strategy: 'node-offset',
      startContainer: range.startContainer,
      startOffset: range.startOffset,
      endContainer: range.endContainer,
      endOffset: range.endOffset,
    };
  }
  
  saveCharacterOffset(range) {
    const startRange = range.cloneRange();
    startRange.selectNodeContents(this.editor);
    startRange.setEnd(range.startContainer, range.startOffset);
    const startOffset = startRange.toString().length;
    
    const endRange = range.cloneRange();
    endRange.selectNodeContents(this.editor);
    endRange.setEnd(range.endContainer, range.endOffset);
    const endOffset = endRange.toString().length;
    
    return {
      strategy: 'character-offset',
      start: startOffset,
      end: endOffset,
      collapsed: range.collapsed,
    };
  }
  
  saveWithMarkers(range) {
    // 선택 경계에 보이지 않는 마커 삽입
    const startMarker = document.createTextNode('\uFEFF');
    const endMarker = document.createTextNode('\uFEFF');
    
    try {
      range.insertNode(startMarker);
      range.collapse(false);
      range.insertNode(endMarker);
    } catch (e) {
      return this.saveCharacterOffset(range);
    }
    
    return {
      strategy: 'marker',
      startMarker,
      endMarker,
    };
  }
  
  restoreSelection() {
    if (!this.savedSelection) return;
    
    const strategy = this.savedSelection.strategy || this.strategy;
    
    switch (strategy) {
      case 'node-offset':
        this.restoreNodeOffset(this.savedSelection);
        break;
      case 'character-offset':
        this.restoreCharacterOffset(this.savedSelection);
        break;
      case 'marker':
        this.restoreWithMarkers(this.savedSelection);
        break;
    }
  }
  
  restoreNodeOffset(saved) {
    const selection = window.getSelection();
    const range = document.createRange();
    
    try {
      if (!saved.startContainer.isConnected ||
          !saved.endContainer.isConnected) {
        throw new Error('Nodes disconnected');
      }
      
      range.setStart(saved.startContainer, saved.startOffset);
      range.setEnd(saved.endContainer, saved.endOffset);
      
      selection.removeAllRanges();
      selection.addRange(range);
    } catch (e) {
      // 문자 오프셋으로 대체
      const charOffset = this.saveCharacterOffset(range);
      this.restoreCharacterOffset(charOffset);
    }
  }
  
  restoreCharacterOffset(saved) {
    const selection = window.getSelection();
    const range = document.createRange();
    
    const startPos = this.findCharacterPosition(saved.start);
    if (!startPos) return;
    
    range.setStart(startPos.node, startPos.offset);
    
    if (saved.collapsed) {
      range.collapse(true);
    } else {
      const endPos = this.findCharacterPosition(saved.end);
      if (endPos) {
        range.setEnd(endPos.node, endPos.offset);
      } else {
        range.collapse(true);
      }
    }
    
    selection.removeAllRanges();
    selection.addRange(range);
  }
  
  restoreWithMarkers(saved) {
    const selection = window.getSelection();
    const range = document.createRange();
    
    if (!saved.startMarker.isConnected || !saved.endMarker.isConnected) {
      // 마커 제거됨, 대체
      return;
    }
    
    range.setStartBefore(saved.startMarker);
    range.setEndAfter(saved.endMarker);
    
    // 마커 제거
    saved.startMarker.remove();
    saved.endMarker.remove();
    
    selection.removeAllRanges();
    selection.addRange(range);
  }
  
  findCharacterPosition(offset) {
    let currentOffset = 0;
    const walker = document.createTreeWalker(
      this.editor,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    let node;
    while (node = walker.nextNode()) {
      const nodeLength = node.textContent.length;
      
      if (currentOffset + nodeLength >= offset) {
        return {
          node,
          offset: Math.min(offset - currentOffset, nodeLength),
        };
      }
      
      currentOffset += nodeLength;
    }
    
    // 콘텐츠를 넘어서면 마지막 위치 반환
    const lastNode = this.getLastTextNode();
    if (lastNode) {
      return {
        node: lastNode,
        offset: lastNode.textContent.length,
      };
    }
    
    return null;
  }
  
  getLastTextNode() {
    const walker = document.createTreeWalker(
      this.editor,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    let lastNode = null;
    let node;
    while (node = walker.nextNode()) {
      lastNode = node;
    }
    
    return lastNode;
  }
  
  clearSelection() {
    this.savedSelection = null;
  }
  
  getCurrentSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    
    return selection.getRangeAt(0);
  }
  
  isSelectionCollapsed() {
    const range = this.getCurrentSelection();
    return range ? range.collapsed : true;
  }
  
  getSelectedText() {
    const selection = window.getSelection();
    return selection.toString();
  }
}

// 사용법
const editor = document.querySelector('div[contenteditable]');
const manager = new ComprehensiveSelectionManager(editor);

// DOM 조작 전 저장
manager.saveSelection('character-offset');

// ... DOM 수정 ...

// 변경 후 복원
manager.restoreSelection();

주의사항

  • 문자 오프셋 전략은 노드 오프셋보다 DOM 변경에 더 강합니다
  • 노드 기반 선택을 복원하기 전에 항상 노드가 여전히 연결되어 있는지 확인하세요
  • 비동기 DOM 업데이트 후 복원할 때 setTimeout 또는 requestAnimationFrame을 사용하세요
  • 마커 전략은 빠르지만 일부 작업에 의해 마커가 제거될 수 있습니다
  • IME 컴포지션으로 테스트하세요 - 컴포지션 중 선택 동작이 다릅니다
  • React/Vue의 경우 모든 input 이벤트에서 저장하고 상태 업데이트 후 복원하세요
  • 빈번한 업데이트로 인한 성능을 위해 선택 저장을 디바운싱하는 것을 고려하세요

브라우저 호환성

  • Chrome/Edge: 모든 전략이 잘 작동합니다
  • Firefox: 좋은 지원이지만 문자 오프셋 계산을 테스트하세요
  • Safari: 잘 작동하지만 IME 컴포지션에 주의하세요

관련 자료

Edit on GitHub