위치 & 선택 관리

모델에서 위치와 선택 관리: 경로 기반 위치, 선택 표현, DOM 변환, 정규화.

개요

모델의 위치와 선택은 DOM과 다르게 표현됩니다. 이 차이점과 둘 사이를 변환하는 방법을 이해하는 것은 신뢰할 수 있는 에디터를 구축하는 데 중요합니다.

DOM 위치

{
  node: TextNode,
  offset: 5
}
  • 실제 DOM 노드를 참조
  • DOM 변경 시 깨짐
  • 브라우저별 특이사항
  • 직렬화하기 어려움

모델 위치

{
  path: [0, 1, 2],
  offset: 5
}
  • 모델 구조를 참조
  • DOM 업데이트에 걸쳐 안정적
  • 프레임워크 독립적
  • 직렬화하기 쉬움

위치 표현

경로 기반 위치

모델의 위치는 경로(인덱스 배열)를 사용하여 문서 트리를 탐색합니다:

// 경로 구조: [blockIndex, inlineIndex, textOffset]
// 예제 문서:
{
  type: 'document',
  children: [
    { type: 'paragraph', children: [...] },  // 인덱스 0
    { type: 'heading', children: [...] },     // 인덱스 1
    { type: 'paragraph', children: [...] }    // 인덱스 2
  ]
}

// 위치 예제:
{ path: [0], offset: 0 }           // 첫 번째 단락의 시작
{ path: [0, 0], offset: 5 }        // 첫 번째 단락의 첫 번째 인라인에서 5번째 문자
{ path: [1, 0, 1], offset: 3 }     // 제목의 첫 번째 인라인의 2번째 텍스트 노드에서 3번째 문자

경로 해석:

  • 각 숫자는 부모의 자식 배열에 대한 인덱스
  • 마지막 숫자는 텍스트 노드 내의 문자 오프셋
  • 경로는 DOM이 다시 렌더링되어도 안정적

위치의 오프셋

오프셋은 대상 노드 내의 문자 위치를 나타냅니다:

// 텍스트 노드의 경우, 오프셋은 문자 위치
{
  path: [0, 0],  // 첫 번째 단락, 첫 번째 인라인
  offset: 5      // 텍스트 노드에서 5번째 문자
}

// 요소 노드의 경우, 오프셋은 자식 인덱스
{
  path: [0],     // 첫 번째 단락
  offset: 2      // 단락의 2번째 자식 이후
}

// 모델에서 위치 찾기
function findPosition(path, offset) {
  let node = document;
  
  // 경로를 사용하여 탐색
  for (let i = 0; i < path.length - 1; i++) {
    node = node.children[path[i]];
  }
  
  // 마지막 경로 인덱스가 대상 노드를 가리킴
  const targetNode = node.children[path[path.length - 1]];
  
  return {
    node: targetNode,
    offset: offset
  };
}

위치 안정성

경로 기반 위치는 DOM이 변경되어도 유효합니다:

// 편집 전 위치
const position = { path: [0, 0], offset: 10 };

// 사용자가 [0, 0], offset: 5 위치에 텍스트 삽입
// 모델이 업데이트되지만 위치 경로 구조는 유지됨

// 편집 후 위치 (오프셋 조정)
const newPosition = { path: [0, 0], offset: 15 };  // 10 + 삽입된 5개 문자

// 텍스트가 삭제되면 오프셋 감소
// 노드가 분할되면 경로가 변경될 수 있음
// 하지만 경로 구조는 항상 유효함

선택 표현

앵커와 포커스

선택은 두 위치로 표현됩니다: 앵커(선택이 시작된 위치)와 포커스(선택이 끝난 위치):

// 모델 선택
{
  anchor: { path: [0, 0], offset: 5 },
  focus: { path: [0, 2], offset: 3 },
  isBackward: false
}

// 축소된 선택 (커서)
{
  anchor: { path: [0, 1], offset: 10 },
  focus: { path: [0, 1], offset: 10 },
  isBackward: false
}

// 역방향 선택 (오른쪽에서 왼쪽으로 선택)
{
  anchor: { path: [0, 2], offset: 10 },
  focus: { path: [0, 0], offset: 5 },
  isBackward: true
}

왜 앵커와 포커스인가?

  • 앵커는 사용자가 선택을 시작한 위치 (마우스 다운 또는 Shift+Arrow 시작)
  • 포커스는 선택이 현재 끝나는 위치 (마우스 위치 또는 커서)
  • isBackward는 선택 방향을 나타냄
  • 선택 확장/축소를 적절히 처리할 수 있게 함

축소된 선택

축소된 선택은 커서를 나타냅니다 (선택된 텍스트 없음):

// 축소된 선택
{
  anchor: { path: [0, 1], offset: 10 },
  focus: { path: [0, 1], offset: 10 },
  isBackward: false
}

// 축소되었는지 확인
function isCollapsed(selection) {
  return (
    selection.anchor.path.join(',') === selection.focus.path.join(',') &&
    selection.anchor.offset === selection.focus.offset
  );
}

// 커서 위치 가져오기
function getCursorPosition(selection) {
  if (isCollapsed(selection)) {
    return selection.anchor;  // 또는 focus, 둘 다 동일함
  }
  return null;
}

DOM에서 모델로 변환

브라우저의 DOM 선택을 모델 선택으로 변환합니다:

DOM 위치를 경로로

function domPositionToPath(domNode, domOffset, model) {
  // DOM 노드에 해당하는 모델 노드 찾기
  const modelNode = findModelNodeForDOM(domNode, model);
  
  if (!modelNode) {
    return null;
  }
  
  // 모델 트리를 따라 올라가며 경로 계산
  const path = [];
  let current = modelNode;
  
  while (current && current !== model) {
    const parent = findParent(current, model);
    if (parent) {
      const index = parent.children.indexOf(current);
      path.unshift(index);
    }
    current = parent;
  }
  
  // 오프셋 추가
  if (domNode.nodeType === Node.TEXT_NODE) {
    // 오프셋은 텍스트 노드의 문자 위치
    return { path, offset: domOffset };
  } else {
    // 오프셋은 요소의 자식 인덱스
    return { path: [...path, domOffset], offset: 0 };
  }
}

function findModelNodeForDOM(domNode, model) {
  // data-model-id를 가진 요소를 찾기 위해 DOM 트리를 따라 올라가기
  let current = domNode;
  while (current) {
    if (current.nodeType === Node.ELEMENT_NODE) {
      const modelId = current.getAttribute('data-model-id');
      if (modelId) {
        return findNodeById(model, modelId);
      }
    }
    current = current.parentElement;
  }
  return null;
}

DOM 범위를 선택으로

function domSelectionToModel(domSelection) {
  if (domSelection.rangeCount === 0) {
    return null;
  }
  
  const range = domSelection.getRangeAt(0);
  
  // 시작 위치 변환
  const anchor = domPositionToPath(
    range.startContainer,
    range.startOffset,
    model
  );
  
  // 끝 위치 변환
  const focus = domPositionToPath(
    range.endContainer,
    range.endOffset,
    model
  );
  
  if (!anchor || !focus) {
    return null;
  }
  
  // 역방향인지 결정
  const isBackward = comparePositions(anchor, focus) > 0;
  
  return {
    anchor: isBackward ? focus : anchor,
    focus: isBackward ? anchor : focus,
    isBackward
  };
}

function comparePositions(pos1, pos2) {
  // 경로를 사전식으로 비교
  for (let i = 0; i < Math.max(pos1.path.length, pos2.path.length); i++) {
    const idx1 = pos1.path[i] || 0;
    const idx2 = pos2.path[i] || 0;
    if (idx1 !== idx2) {
      return idx1 - idx2;
    }
  }
  // 경로가 같으면 오프셋 비교
  return pos1.offset - pos2.offset;
}

모델에서 DOM으로 변환

모델 선택을 DOM 선택으로 변환합니다:

경로를 DOM 위치로

function pathToDOMPosition(path, offset, model) {
  // 경로를 사용하여 모델 트리 탐색
  let node = model;
  for (let i = 0; i < path.length; i++) {
    if (!node.children || node.children.length <= path[i]) {
      return null;  // 유효하지 않은 경로
    }
    node = node.children[path[i]];
  }
  
  // 해당하는 DOM 노드 찾기
  const domNode = findDOMNodeForModel(node);
  if (!domNode) {
    return null;
  }
  
  // 오프셋 처리
  if (node.type === 'text') {
    // 텍스트 노드의 경우, 오프셋은 문자 위치
    return {
      node: domNode,
      offset: offset
    };
  } else {
    // 요소 노드의 경우, 오프셋은 자식 인덱스
    if (domNode.childNodes.length > offset) {
      return {
        node: domNode,
        offset: offset
      };
    }
    // 자식 범위를 벗어난 오프셋, 마지막 자식 사용
    return {
      node: domNode,
      offset: domNode.childNodes.length
    };
  }
}

function findDOMNodeForModel(modelNode) {
  // 일치하는 data-model-id를 가진 DOM 요소 찾기
  const modelId = getModelId(modelNode);
  return editor.querySelector(`[data-model-id="${modelId}"]`);
}

선택을 DOM 범위로

function modelSelectionToDOM(modelSelection) {
  // 앵커 위치 변환
  const anchorDOM = pathToDOMPosition(
    modelSelection.anchor.path,
    modelSelection.anchor.offset,
    model
  );
  
  // 포커스 위치 변환
  const focusDOM = pathToDOMPosition(
    modelSelection.focus.path,
    modelSelection.focus.offset,
    model
  );
  
  if (!anchorDOM || !focusDOM) {
    return null;
  }
  
  // DOM 범위 생성
  const range = document.createRange();
  
  if (modelSelection.isBackward) {
    range.setStart(focusDOM.node, focusDOM.offset);
    range.setEnd(anchorDOM.node, anchorDOM.offset);
  } else {
    range.setStart(anchorDOM.node, anchorDOM.offset);
    range.setEnd(focusDOM.node, focusDOM.offset);
  }
  
  // 선택에 적용
  const selection = window.getSelection();
  selection.removeAllRanges();
  selection.addRange(range);
  
  return range;
}

선택 정규화

선택은 모델 변경 후 유효하지 않을 수 있습니다. 정규화합니다:

유효하지 않은 선택 정규화

function normalizeSelection(selection, model) {
  // 앵커가 유효한지 확인
  const anchorValid = isValidPosition(selection.anchor, model);
  if (!anchorValid) {
    selection.anchor = findNearestValidPosition(selection.anchor, model);
  }
  
  // 포커스가 유효한지 확인
  const focusValid = isValidPosition(selection.focus, model);
  if (!focusValid) {
    selection.focus = findNearestValidPosition(selection.focus, model);
  }
  
  // 두 위치가 이제 같으면 축소
  if (isSamePosition(selection.anchor, selection.focus)) {
    selection.focus = { ...selection.anchor };
    selection.isBackward = false;
  }
  
  return selection;
}

function isValidPosition(position, model) {
  try {
    const node = getNodeAtPath(model, position.path);
    if (!node) return false;
    
    if (node.type === 'text') {
      return position.offset <= node.text.length;
    } else {
      return position.offset <= node.children.length;
    }
  } catch (e) {
    return false;
  }
}

function findNearestValidPosition(position, model) {
  // 유효하지 않은 위치 근처의 유효한 위치 찾기 시도
  // 유효한 노드를 찾을 때까지 경로를 따라 올라가기
  // 그 노드의 끝으로 오프셋 설정
  let path = [...position.path];
  
  while (path.length > 0) {
    const node = getNodeAtPath(model, path);
    if (node) {
      if (node.type === 'text') {
        return { path, offset: node.text.length };
      } else {
        return { path, offset: node.children.length };
      }
    }
    path.pop();
  }
  
  // 대체: 문서 시작
  return { path: [0], offset: 0 };
}

선택 확장

때로는 전체 노드를 포함하도록 선택을 확장해야 합니다:

function expandSelectionToNodes(selection, model) {
  // 앵커를 노드 시작으로 확장
  const anchorNode = getNodeAtPath(model, selection.anchor.path);
  if (anchorNode && selection.anchor.offset > 0) {
    selection.anchor = {
      path: selection.anchor.path,
      offset: 0
    };
  }
  
  // 포커스를 노드 끝으로 확장
  const focusNode = getNodeAtPath(model, selection.focus.path);
  if (focusNode) {
    const endOffset = focusNode.type === 'text' 
      ? focusNode.text.length 
      : focusNode.children.length;
    
    selection.focus = {
      path: selection.focus.path,
      offset: endOffset
    };
  }
  
  return selection;
}

위치 업데이트

모델이 변경되면 위치를 업데이트해야 합니다:

위치 추적

작업 후 업데이트가 필요한 위치를 추적합니다:

class PositionTracker {
  constructor() {
    this.positions = new Map();
  }
  
  track(position, id) {
    this.positions.set(id, position);
  }
  
  updateAfterOperation(operation) {
    // 작업을 기반으로 모든 추적된 위치 업데이트
    for (const [id, position] of this.positions.entries()) {
      const updated = this.updatePosition(position, operation);
      this.positions.set(id, updated);
    }
  }
  
  updatePosition(position, operation) {
    // 작업이 위치보다 앞이면 위치는 동일하게 유지
    // 작업이 위치에 있으면 위치가 앞으로 이동
    // 작업이 위치보다 뒤이면 위치는 변경되지 않음
    
    if (operation.type === 'insertText') {
      if (isBefore(operation.position, position)) {
        // 앞에 텍스트 삽입, 위치가 앞으로 이동
        return {
          ...position,
          offset: position.offset + operation.text.length
        };
      }
    }
    
    if (operation.type === 'deleteRange') {
      if (overlaps(operation.range, position)) {
        // 위치가 삭제된 범위에 있음, 범위 시작으로 이동
        return { ...operation.range.anchor };
      } else if (isAfter(operation.range, position)) {
        // 위치 이후 삭제, 위치는 변경되지 않음
        return position;
      } else {
        // 위치 이전 삭제, 오프셋 조정
        const deletedLength = getRangeLength(operation.range);
        return {
          ...position,
          offset: position.offset - deletedLength
        };
      }
    }
    
    return position;
  }
}

편집 후 위치 업데이트

function updateSelectionAfterEdit(selection, operation) {
  // 앵커 업데이트
  selection.anchor = updatePosition(selection.anchor, operation);
  
  // 포커스 업데이트
  selection.focus = updatePosition(selection.focus, operation);
  
  // 필요시 정규화
  return normalizeSelection(selection, model);
}

function updatePosition(position, operation) {
  switch (operation.type) {
    case 'insertText':
      return updatePositionForInsert(position, operation);
    case 'deleteRange':
      return updatePositionForDelete(position, operation);
    case 'splitNode':
      return updatePositionForSplit(position, operation);
    case 'mergeNodes':
      return updatePositionForMerge(position, operation);
    default:
      return position;
  }
}