[모던JS: 브라우저] Range와 Selection

KG·2021년 6월 24일
2

모던JS

목록 보기
38/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

Range와 Selection

브라우저에서 필요한 내용을 ctrl+F 커맨드로 검색한다거나, 마우스를 통해 선택하는 경우 하이라이트 처리되는 동작이 있다. 이러한 동작을 보통 selection 이벤트라고 부른다. 선택된 내용은 복사를 통해 다른 곳으로 그 내용을 전달할 수도 있다. 이러한 동작은 자바스크립트를 사용해서도 조절할 수 있다.

기존 선택 항목을 가져오고, 전체 또는 부분적으로 선택/해제하거나 문서에서 선택한 부분을 제거 또는 추가, 그리고 새로운 태그로 감싸는 등의 작업을 할 수 있다. 이러한 작업은 자바스크립트에서 Range 객체와 Selection 객체를 이용해서 수행할 수 있는데, 이들의 기본적인 사용법과 차이점에 대해 살펴보도록 하자.

Range

selection의 기본 컨셉은 Range 객체를 통해 구현된다. 기본적으로 시작과 끝에 대한 포인트를 지정하는 것으로 selection의 범위를 선택할 수 있기 때문이다.

이때 각각의 포인트는 주어진 오프셋(offset)과 함께 부모 DOM 노드를 기준으로 나타낼 수 있다. 만약 부모 노드가 요소 노드라면, 오프셋은 자식 노드의 번호로 지정하며 텍스트 노드라면 텍스트 내에서의 위치를 지정한다. Range 객체를 이용해 특정 영역을 선택하기 위해서는 먼저 생성자를 통해 해당 객체를 생성해야 한다.

let range = new Range();

그리고 생성된 객체 range를 이용해 내장 메서드 range.setStart(node, offset) 내지 range.setEnd(node, offset) 등을 이용해서 범위를 지정할 수 있다. 이 동작을 면밀히 살펴보기 위해 먼저 다음의 HTML 마크업 구조가 있다고 가정하자.

<p id='p'>Example: <i>italic</i> and <b>bold</b></p>

해당 HTML 문서는 다음과 같이 텍스트 노드와 요소 노드로 구성된 DOM 객체로 생성될 것이다.

여기서 "Example: <i>italic</i>" 범위를 선택해보자. 이들은 각각 텍스트 노드와 요소노드로 모두 <p> 요소의 자식에 해당한다.

<p id="p">Example: <i>italic</i> and <b>bold</b></p>

<script>
  let range = new Ragne();
  
  range.setStart(p, 0);
  range.setEnd(p, 2);
  
  // range 객체에서 toString이 호출되면
  // 태그 정보를 제외한 텍스트를 문자열로 반환한다
  alert(range);
  
  document.getSelection().addRange(range);
</script>

위 스크립트가 실행되면 위의 이미지와 같은 범위가 선택된다. 각각 메서드에 대한 설명은 아래와 같다. 마지막 지점을 선택하는 경우에는 선택 위치에 해당하는 요소는 범위에 포함되지 않는다는 것에 유의해야 한다. 이는 String 내장 메서드의 substr/substring과 동일한 로직이다.

  • range.setStart(p, 0) : <p> 요소의 자식 중 0번째 자식부터 범위 시작 => 텍스트 노드 Example
  • range.setEnd(p, 2) : <p> 요소의 자식 중 2번째 자식까지로 2번째 자식은 범위에서 제외 => 요소 노드 <i>italic</i>

이때 setStartsetEnd를 항상 동일한 노드에서 지정할 필요는 없다. 선택 범위는 유저에 의해 굉장히 다양한 범위가 지정될 수 있고, 그로 인해 해당 범위에는 서로 관계없는 수 많은 노드들이 동시에 선택될 수 있다. 중요한 것은 end 포인트가 start 포인트 이후에 존재해야 한다는 점이다.

1) 텍스트 노드 부분 선택

텍스트 노드의 경우에는 콘텐츠 내용이 선택이 되는데, 이때 부분적인 선택이 발생할 수도 있다. 예를 들면 아래 이미지와 같다.

따라서 다음과 같이 텍스트 노드 내에서 다시 범위를 재지정 할 필요가 있다. 즉 해당하는 요소의 순서를 선택하고, 그 요소 내부에서 몇 번째 위치의 인덱스가 필요한지 구분해야 한다.

  • <p> 요소의 첫 번째 자식에서 2번째 인덱스 부터 시작
  • <p> 요소의 세 번째 자식에서 3번째 인덱스 전에 종료
<p id="p">Example: <i>italic</i> and <b>bold</b></p>

<script>
  let range = new Range();
  
  range.setStart(p.firstChild, 2);
  range.setEnd(p.querySelector('b').firstChild, 3);
  
  alert(range);
  
  window.getSelection().addRange(range);
</script>

해당 코드는 다음의 과정을 실행한 것과 동일하다.

  • startContainer, startOffset : 노드와 시작지점의 오프셋으로 위 예시에서는 각각 <p> 노드와 2를 사용했다.
  • endContainer, endOffset : 노드와 종료지점의 오프셋으로 위 예시에서는 각각 <p> 노드와 3을 사용했다.
  • collapsed : boolean 값으로 만약 true 일 경우 시작지점과 종료지점의 위치가 동일함을 말한다. 즉 선택 범위에 내용이 존재하지 않는 경우이다. 위에 예시에서는 false 값을 가진다.
  • commonAncestorContainer : 선택된 범위 내 모든 노드가 공통적으로 가지는 가장 가까운 조상 요소를 가리키며 위 예시에서는 <p> 요소이다.

2) Range 내장 메서드

범위(range)를 편리하게 다루기 위해서 Range 객체에서는 다양한 메서드를 지원한다.

  1. 범위 시작지점 조정
  • setStart(node, offset) : 선택한 node에서 시작지점 설정
  • setStartBefore(node) : 선택한 node 이전에 위치한 노드를 시작지점으로 설정
  • setStartAfter(node) : 선택한 node 이후에 위치한 노드를 시작지점으로 설정
  1. 범위 종료지점 조정
  • setEnd(node, offset) : 선택한 node에서 종료지점 설정
  • setEndBefore(node) : 선택한 node 이전에 위치한 노드를 종료지점으로 설정
  • setEndAfter(node) : 선택한 node 이후에 위치한 노드를 종료지점으로 설정

앞에서 살펴본 바와 같이 node는 텍스트 노드일 수도 요소 노드일 수도 있다. 텍스트 노드의 경우에는 offset에 의해 문자열 내 문자 사이를 건너뛸 수 있으며 요소 노드의 경우에는 내부에 또 다른 자식 노드를 가지고 있는 경우 자식 노드를 건너뛸 수 있다.

  1. 그 외 범위지정 메서드
  • selectNode(node) : 선택된 node 전체를 범위로 지정
  • selectNodeContents(node) : 선택된 node 전체의 컨텐츠를 범위로 지정
  • collapse(toStart) : toStart=true라면 end=start로 설정하고, false인 경우 start=end로 설정
  • cloneRange() : 동일한 start/end 지점을 갖고 있는 range 객체 복사
  1. 범위 내 컨텐츠 조작 메서드
  • deleteContents() : 범위 내 컨텐츠 제거
  • extractContents() : 범위 내 컨텐츠 제거 후 해당 내용을 DocumentFragment 객체로 반환
  • cloneContents() : : 범위 내 컨텐츠 내용을 DocumentFragment 객체로 반환
  • insertNode(node) : node를 범위 시작 부분에 삽입
  • surroundContents(node) : 범위 내 컨텐츠를 node로 래핑. 이를 위해 범위는 반드시 시작과 종료 태그로 감싸져 있어야 함.

이 외에도 범위를 비교하는 등의 추가 메서드가 있지만 잘 사용되지 않는다.

Selection

사실 Range는 범위를 다루는데에 있어 조금 포괄적인 객체이다. 해당 객체를 통해 범위를 다루면, 이들을 통해 지정된 범위는 브라우저에서 시작적으로 다루어지지는 않는다.

그러나 사용자가 범위를 지정할 때에는 시각적인 하이라이트 처리가 발생한다. 브라우저별로 스타일링은 조금씩 다르지만 시각적인 표현이 드러나는 점은 모두 동일하다. Range 객체를 통해 지정된 범위가 이처럼 시각적으로 표현되기 위해서는 이를 관리하는 Selection 객체에 전달되어야 한다. 때문에 위에서 document.getSelection().addRange(range)와 같이 지정한 범위를 넘겨주는 작업이 요구됨을 볼 수 있었다.

이처럼 브라우저는 현재 선택된 범위를 관리하는 Selection 객체를 제공한다. 이는 보통 전역객체인 window.getSelection() 또는 document.getSelection()을 통해 그 정보를 받아올 수 있다.

스펙에 의하면 selection은 0개 부터 1개 이상의 여러 범위를 포함할 수 있다. 하지만 이 같은 스펙은 오로지 파이어폭스 브라우저에서만 구현되어 있고, 다른 브라우저에서는 멀티-셀렉션이 구현되어 있지 않다.

파이어폭스를 제외한 다른 브라우저는 최대 1개 까지의 셀렉션을 허용한다. 따라서 위 이미지처럼 여러 셀력션을 지정하려는 경우 기존에 선택한 셀렉션은 자동으로 해제되는 것을 볼 수 있다.

1) 지원 프로퍼티

Range 객체와 비슷한 프로퍼티를 지원한다. 다만 Selection에서는 start 대신 anchor를, end 대신 focus라는 용어를 사용한다.

  • anchorNode : 셀렉션이 시작된 노드
  • anchorOffset : 셀력션이 시작되는 anchorNode 내에서의 오프셋
  • focusNode : 셀렉션이 끝나는 노드
  • focusOffset : 셀렉션이 끝나는 focusNode 내의 오프셋
  • isCollapsed : true일 경우 비어있는 셀렉션이거나 존재하지 않는 경우를 의미
  • rangeCount : 셀렉션에 존재하는 범위의 개수로 파이어폭스 브라우저를 제외하면 최대값은 1

또한 셀렉션은 범위와 달리 방향을 구분한다. 이는 유저에 의해 범위가 선택될 때 다양한 방법으로 선택이 가능한데, 예를 들어 마우스 드래그를 통해 선택하는 경우 우에서 좌 또는 좌에서 우와 같이 2가지 방향으로 범위 선택이 가능하기 때문이다. 때문에 Range와는 달리 Seletcion에서는 start(anchor)값이 end(focus)값 보다 뒤에 위치할 수 있다. 좌에서 우로 가는 방향을 Forward라 부르고, 우에서 좌로 가는 방향을 Backword라고 부른다.

2) Selection 이벤트

셀렉션이 발생할 때 이를 잡아낼 수 있는 이벤트와 이벤트 핸들러가 있다.

  • elem.onselectstart : elem에 셀렉션 이벤트가 발생하는 경우를 감지
  • document.onselectionchange : 셀력션 이벤트가 발생하는 매 순간을 감지하며 document 객체에서 관리

다음 예제를 통해 셀력센 이벤트를 관리해보자.

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

From <input id="from" disabled /> ~ To <input id="to" disabled />

<script>
  document.onselectionchange = function() {
    let { anchorNode, anchorOffset, focusNode, focusOffset } = document.getSelection();
  
    from.value = `${anchorNode && anchorNode.data}: ${anchorOffset}`;
    to.value = `${focusNode && focusNode.data}: ${focusOffset}`;
  };
</script>

선택하는 범위에 따라 시작지점과 종료지점에 대한 오프셋이 출력되는 것을 확인할 수 있다. document 레벨에서 셀렉션 이벤트를 관리하기 때문에, 모든 셀렉션 이벤트를 해당 레벨에서 잡아낼 수 있다.

이때 유저가 선택한 범위에는 텍스트 노드와 요소 노드가 혼재해 있을 수 있다. 따라서 텍스트 노드는 문자열로 처리하고, 요소 노드의 경우에는 태그를 그대로 처리하기 위해 HTML로 마크업 구조로 처리하는 예제를 살펴보자.

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

Cloned: <span id="cloned"></span>
<br>
As text: <span id="astext"></span>

<script>
  document.onselectionchange = function() {
    let selection = document.getSelection();
  
    cloned.innerHTML = astext.innerHTML = "";
  
    for (let i = 0; i < selection.rangeCount; i++) {
      cloned.append(selection.getRangeAt(i).cloneContents());
    }
                                             
    astext.innerHTML += selection;
  };
</script>

3) Selection 내장 메서드

  1. 범위 추가/제거 관련 메서드
  • getRangeAt(i) : i번째 범위에 접근하며 0부터 시작. 파이어폭스 브라우저를 제외하면 항상 0만 사용
  • addRange(range) : range를 셀렉션에 추가. 파이어폭스를 제외한 모든 브라우저는 이미 선택된 셀렉션이 존재하는 경우 이 요청을 무시
  • removeRange(range) : range를 셀렉션에서 제거
  • removeAllRanges() : 모든 범위를 제거
  • empty() : removeAllRanges()와 동일
  1. Range 객체없이 범위 지정 메서드

    유저의 셀렉션 이벤트는 사실상 Selection에서 관리되기 때문에 Range 객체를 이용해 범위를 지정하고 Selection에 넘겨주는 방식이 아니라, 바로 해당 객체에서 범위를 관리할 수 있는 다양한 메서드도 지원한다. 이를 통해 관리하는 것이 보다 편리하기에 다 많이 사용한다.

  • collapse(node, offset) : 기존 선택된 범위를 nodeoffset에 해당하는 범위로 교체
  • setPosition(node, offset) : collapse(node, offset) 와 동일
  • collapseToStart() : 셀렌션 start로 병합 (빈 셀렉션으로 대체)
  • collapseToEnd() : 셀렉션 end로 병합 (빈 셀렉션으로 대체)
  • extend(node, offset) : focus를 주어진 nodeoffset으로 이동
  • setBaseAndExtend(anchorNode, anchorOffset, focusNode, focusOffset) : 주어진 anchorNode/anchorOffsetfocusNode/focusOffset으로 셀렉션 범위를 대체. 이 사이에 위치한 모든 콘텐츠가 선택됨
  • selectAllChildren(node) : node의 모든 자식 노드를 선택
  • deleteFromDocument() : 선택된 컨텐츠를 문서에서 제거
  • containsNode(node, allowPartialContainment = false) : 셀력센에 node가 포함되어 있는지 체크, 만약 부분적으로나마 포함되어 있는지에 대해 검사하고자 한다면 allowPartialContainment = true로 설정

위에서 Range를 이용해 처리했던 작업을 단순히 Selection만 이용해서 처리해보자.

<p id="p">Select me: <i>italic</i> and <b>bold</b></p>

<script>
  // p 태그의 0번째부터 마지막 자식까지 포함한 범위를 선택
  document.getSelection().setBaseAndExtent(p, 0, p, p.childNodes.length);
</script>

위 작업을 Range 객체를 사용해 표현한다면 아래와 같다.

<script>
  let range = new Range();
  range.selectNodeContents(p); // or selectNode(p) to select the <p> tag too

  document.getSelection().removeAllRanges(); // clear existing selection if any
  document.getSelection().addRange(range);
</script>

범위 선택을 위해서는 기존에 존재하는 셀렉션을 먼저 제거해야 한다. 만일 셀렉션이 이미 존재하는 경우라면 removeAllRanges() 등의 메서드를 이용해 이를 초기화 후 범위를 새로 지정해야 한다. 그렇지 않으면 새로운 범위 지정에 대한 모든 요청은 무시된다. (파이어폭스 제외)
그러나 setBaseAndExtent와 같은 일부 메서드는 기존 셀렉션을 초기화 할 필요가 없는 예외에 속한다.

Selection과 Form 컨트롤 요소

inputtextarea 와 같은 폼 요소는 일반 요소와 달리 SelectionRange 객체를 이용해 셀렉션 이벤트를 다루지 않는다. 이들은 HTML요소가 아닌 항상 순수 텍스트만 취급하는 것이 보장되기 때문이다. 때문에 이들을 고려할 필요가 없어 더욱 간편하게 관련 이벤트를 처리할 수 있다. 폼 컨트롤 요소에서 다루는 프로퍼티, 이벤트 및 메서드는 다음과 같다.

1) 프로퍼티

  • input.selectionStart : 셀렉션 start 위치 접근/설정
  • input.selectionEnd : 셀렉션 end 위치 접근/설정
  • input.selectionDirection : 셀렉션 방향 정보(forward, backward, none - 마우스 더블클릭 등으로 인해 선택한 경우)

2) 이벤트

  • input.onselect : 무언가 선택되었을 때 발생하는 이벤트 관리

3) 메서드

  • input.select() : 요소 내에 있는 모든 것을 선택

  • input.setSelectionRagne(start, end, [direction]) : 선택된 셀렉션 범위를 start부터 end 까지 변경, direction은 생략 가능

  • input.setRangeText(replacement, [start], [end], [selectionMode]) : 선택된 범위를 새로운 텍스트로 대체, start/end는 생략가능하며 생략된 경우 유저가 선택한 위치가 지정

    • selectionMode의 경우 텍스트를 교체한 후 선택 항목을 설정하는 방법을 결정
      • select : 새로 삽입된 텍스트가 선택
      • start : 선택 범위가 삽입된 텍스트 바로 앞으로 축소(커서가 바로 앞에 위치)
      • end : 선택 범위가 삽입된 텍스트 직후로 축소(커서는 바로 뒤에 위치)
      • preserve : 기본값이며, 선택항목을 그대로 유지

4) 예시

1. 셀렉션 추적

<textarea id="area" style="width:80%;height:60px">
Selecting in this text updates values below.
</textarea>
<br>
From <input id="from" disabled> – To <input id="to" disabled>

<script>
  area.onselect = function() {
    from.value = area.selectionStart;
    to.value = area.selectionEnd;
  };
</script>

textarea에서 어느 부분을 선택하느냐에 따라 관련 오프셋 값이 인풋창에 출력될 것이다. 이때 onselect 는 무언가 선택되었을 때 발생하지만, 셀렉션이 없어지는 경우는 감지하지 못한다. 또한 위에서 살펴본 document.onselectionchange 이벤트는 폼 요소에서 발생하는 셀렉션 관련 이벤트는 감지하지 못한다. 물론 브라우저에 따라 이를 감지하는 경우도 있지만, 폼 요소는 기본 셀렉션 이벤트와 분리해서 생각해야 한다.

2. 커서 이동

selectionStartselectionEnd를 변경하는 것으로 셀렉션을 설정할 수 있다. 이때 중요한 점은 두 값이 서로 동일한 경우엔 정확히 커서의 위치와 일치한다는 점이다. 이를 통해 우리는 커서 이동 역시 조작할 수 있다.

<textarea id="area" style="width:80%;height:60px">
Focus on me, the cursor will be at position 10.
</textarea>

<script>
  area.onfocus = () => {
    // focus 이벤트가 발생하고 난 이후 커서 이동을 적용하기 위해
    // 제로 딜레이 setTimeout을 이용 
    setTimeout(() => {
      area.selectionStart = area.selectionEnd = 10;
    });
  }
</script>

3. 셀력선 수정

셀렉션의 콘텐츠를 수정하기 위해서 input.setTextRange() 메서드를 이용할 수 있다. 물론 selectionStart/End를 통해 수정할 범위를 캐치하고 직접 value를 수정하는 것도 똑같은 기능을 할 수 있지만 전용 메서드를 통해 이러한 동작을 보다 편하게 관리할 수 있다. 아래 예제에서는 유저에 의해 선택된 텍스트를 *...*의 형태로 감싼 형태로 반환한다.

<input id="input" style="width:200px" value="Select here and click the button">
<button id="button">Wrap selection in stars *...*</button>

<script>
  button.onclick = () => {
    // 아무 것도 선택되지 않은 경우
    if (input.selectionStart === input.selectionEnd) {
      return;
    }
  
    let selected = input.value.slice(input.selectionStart, input.selectionEnd);
    input.setRangeText(`*${selected}*`);
</script>

input.setRangeText 메서드엔 추가적으로 start/end 값을 기입할 수 있는데 이를 개발자가 명시적으로 알 수 있는 경우라면 다음과 같이 구현할 수도 있다.

<script>
  button.onclick = () => {
    let pos = input.value.indexOf('THIS');
    if (pos >= 0) {
      input.setRangeText("*THIS*", pos, pos + 4, "select");
      input.focus();
    }
  });
</script>

4. 커서 삽입

만약 아무것도 선택되지 않은 경우라던가, setRangeText 메서드에서 start/end가 서로 동일한 경우엔 새로운 텍스트가 기존 텍스트를 지우지 않고 그냥 삽입만 될 것이다. 즉 setRangeText 메서드를 이용해 커서 위치에 새로운 텍스트를 삽입할 수 있다.

<input id="input" style="width:200px" value="Text Text Text Text Text">
<button id="button">Insert "HELLO" at cursor</button>

<script>
  button.onclick = () => {
    input.setRangeText("HELLO", input.selectionStart, input.selectionEnd, "end");
    input.focus();
  });
</script>

선택불가

때때로 어떤 요소나 텍스트의 경우에는 유저로 부터 선택이 불가능하도록 설정하고 싶은 경우가 있다. 이때 사용할 수 있는 방법은 크게 세 가지가 있다.

1) CSS 속성 이용

CSS 속성 중에 user-select: none을 사용한다면 선택을 막을 수 있다.

<style>
#elem {
  user-select: none;
}
</style>
<div>Selectable <div id="elem">Unselectable</div> Selectable</div>

이 경우 Unselectable에 해당하는 요소엔 셀렉션의 스타일링이 발생하지 않는다. 그러나 만약 해당 요소의 이전 위치에서부터 드래그를 시작해 해당 요소가 그 범위 내에 포함되는 경우가 있을 수 있다. 이 경우에는 elem 요소에 대해 document.getSelection() 으로 접근할 수 있다. 즉 해당 방법은 스타일링적인 시각적 측면에서 셀렉션을 방지하는 방법으로, 내부적으로는 셀렉션 이벤트가 발생한다. 그렇지만 복사/붙여넣기 등의 동작에서 그 내용은 무시된다.

2) onselectionstart 또는 mousedown 기본동작 방지

<div>Selectable <div id="elem">Unselectable</div> Selectable</div>

<script>
  elem.onselectstart = () => false;
</script>

이 경우 elem 요소에 발생하는 셀렉션 이벤트 자체를 차단할 수 있다. 하지만 만약 이전 요소부터 드래그를 하여 해당 요소가 포함되게끔 범위를 지정하는 경우엔 elem 요소는 내부적으로 자동으로 범위에 확장되어 편입된다. 때문에 복사/붙여넣기를 통해서도 해당 내용이 그대로 옮겨질 수 있다.

이러한 매커니즘은 동일한 동작에서 선택을 트리거하는 다른 이벤트 핸들러가 있는 경우 편리하다. 예를 들어 mousedown 이벤트에서 선택이 발생하는 경우인데, 충동을 방지하기 위해 선택 항목을 사용하지 않도록 설정하고 요소 컨텐츠는 복사하는 등의 기능을 살리는 방식을 취할 수 있기 때문이다.

3) document.getSelection().empty()

document.getSelection().empty() 메서드를 이용해 선택 후에 바로 그 범위를 제거하는 방법도 있다. 그러나 이 경우에는 시각적으로 선택 하이라이트 처리가 발생한 후 제거되기 때문에 불필요한 깜빡임 현상이 발생한다. 즉 UX에 좋지 않은 영향을 끼치기 때문에 잘 사용하지 않는 방식이다.

References

  1. https://ko.javascript.info/selection-range#ref-96
profile
개발잘하고싶다

0개의 댓글