중첩된 컴포넌트에서의 이벤트 트러블 슈팅

이슈 1: Ref 노드 선택 안됨

기능 설명

Custom Collection 노드 기능은 기존에 만들어둔 컴포넌트를 Collection으로 전환하여 다른 페이지에도 사용할 수 있는 기능이다. 노드를 Collection으로 전환하면 Global Collection이 생성되고 해당 노드는 Collection을 참조하는 Ref 노드가 된다.

문제 상황

노드를 Collection으로 전환해도 App에서 Ref 노드로 변경되지 않고 여전히 기존의 노드가 노출된다.

가설 1: 노드를 Collection으로 전환하는 로직이 잘못 되었다.

로직을 확인해본 결과, App – Collection – 원본 노드로 이어지는 트리구조가 올바르게 저장되었고, 원본 노드 자리에 Ref 노드가 입력된 것까지 App Dom에는 문제가 없었다.

가설 2: 렌더링이 제대로 되지 않고 있다.

Ref 노드가 어떻게 렌더링 되고 있는지 정확히 알지 못했기 때문에 확인해보았다.

export function RenderedNode({ nodeId }) {
  const { dom } = useAppStateContext();
  const node = getNode(dom, nodeId);

  if (isRef(node)) {
      return  (
          <RenderedNodeHud node={node}>
            <RenderedNode nodeId={node.attributes.refId} />
          </RenderedNodeHud>
      )
  }

  if (isCollection(node)) {
    const childNodeGroups = getChildNodes(dom, node);
    return (
      <>
        {childNodeGroups?.map((child) => (
          <RenderedNode nodeId={child.id} />
        ))}
      </>
    );
  }

  if (isElement(node)) {
    const childNodeGroups = getChildNodes(dom, node);

    return (
      <RenderedNodeContent
        node={node}
        childNodeGroups={childNodeGroups}
        Component={Component}
      />
    );
  }
}

노드를 렌더링하는 부분은 재귀 구조로 이루어져있다. Ref 노드를 렌더링할 때는 DnD 및 부가기능을 가지고 있는 RenderedNodeHud로 Ref의 노드를 렌더링하고, node.attributes.refId를 통해 Collection 노드를 호출한다.

Collection 노드는 가지고 있는 자식 노드를 다시 RenderedNode로 호출하고, 여기서 Element 노드인 자식 노드들이 RenderedNodeContent를 호출하며 최종적으로 렌더링된다.

이 매커니즘을 기반으로 렌더 트리를 확인한 결과, Ref 노드 안에 Collection 노드, 즉 원본 노드가 제대로 렌더링 되고 있었다.

가설 3: 노드 선택이 잘못 되고 있다.

노드를 선택하고 Drag and Drop 하는 로직이 RenderedNodeHud에 있는데, 여기서 선택되는 로직에 이상이 있는지 확인해보았다.

export function RenderedNodeHud({node, children}) {
    return (
        <Box
           onMouseDown={(e) => {
              e.stopPropagation();

              selectNode(node.id);
           }}
          >
            {children}
        </Box>
    )
}

노드를 선택하는 로직이다. 여러 개의 노드들이 중첩되어 있고, 이를 클릭했을 때 가장 안쪽의 노드가 선택된다. 이 때문에 Ref 노드가 아닌 원본 노드가 선택되고 있던 것이다.

그렇다면 이를 어떻게 해결할 수 있을까?

처음에는 타겟노드를 잡아서 document를 기준으로 부모 노드를 탐색한 뒤 Ref 노드가 있으면 해당 노드를 선택하는 방식을 생각했다. 하지만 document에 접근하는 방식보다 세련된 방식을 사용하길 원했다. 그래서 이벤트 전파를 이용했다.

stopPropagation은 이벤트 버블링을 막는다. 이벤트 버블링은 이벤트가 발생한 타겟 노드에서부터 상위 노드로 이벤트가 전파되는 것이다.
일반적인 경우에는 잘 사용하지 않지만, 이 케이스를 보자. stopPropagation이 없으면 가장 안쪽에서 selectNode가 실행되고, 상위 노드로 이벤트가 전파되어 다시 selectNode가 실행되면 타겟노드의 최상위 노드가 선택된다. 그럼 영원히 최상위의 Page 노드만 선택되는 것이다.

이 경우엔, 원본 노드에서는 이벤트를 전파하고, Ref 노드에 와서 이벤트 전파를 막으면 selectNode가 Ref 노드에서 멈춘다.

export function RenderedNodeHud({node, children}) {
    return (
        <Box
           onMouseDown={(e) => {
              if (!isCollection(getParent(dom, node))) {
                e.stopPropagation();

                selectNode(node.id);
              }
           }}
          >
            {children}
        </Box>
    )
}

여기서 추가적으로 Ref 편집 기능 정상화 요청이 들어왔다. Ref 노드에서 편집 모드로 전환하면 원본 노드에 접근할 수 있고, 여기서 편집할 시 Collection 노드에 반영된다.

  const isLocked = useMemo(() => {
    const editingNode = DomHelper.getNode(dom, editingNodeId);

      const ancestors = DomHelper.getAncestorNodes(
    dom,
    DomHelper.getNode(dom, nodeId),
  );

  // 부모 중에 Collection이 있거나, editing 중이 아닌 경우 lock
    return ancestors.some(
      (ancestor) =>
        DomHelper.isCollection(ancestor) &&
        (!editingNodeId ||
          (DomHelper.isRef(editingNode) &&
            editingNode?.attributes?.refId !== ancestor.id)),
    );
  }, [dom, node, editingNodeId]);

isLocked라는 변수를 만들어서 Ref 노드의 잠금 여부를 관리했다. Ref 노드 안쪽에는 여러가지 노드들이 중첩되어 쌓여있을 수 있기 때문에, 이를 판단하기 위해 조상 노드 중에서 Collection이 있는지 확인한다. 그리고 editing 중인지, editing 중인 Ref 노드가 조상인 Collection 노드를 참조하고 있는지 확인한다.

isLocked가 참이면 이벤트를 전파하지 않고, 거짓이면 전파하도록 한다.

if (!isLocked) {
  e.stopPropagation();

  selectNode(node.id);
}

이렇게 하면 lock 상태에서는 Ref 노드만 접근 가능하고 lock을 해제한 경우 원본 노드를 모두 접근 가능하다.

이슈 2: 노드 선택 및 hover 시 border 노출 안됨

기능 설명

노드를 선택하거나 hover했을 때 border로 해당 노드를 표시해주어야 한다.

문제 상황

처음엔 NodeOverlay라는 컴포넌트를 만들어, 상태에 따라 border를 노출하도록 했다. border가 앞으로 나와야 하기 때문에 z-index를 설정해주었는데, 이렇게 하면 해당 노드를 선택했을 때 그 안쪽의 노드가 선택이 안되는 이슈가 있었다.

<NodeOverlay
  isEmptyContainerActive={
    isActive &&
      isEmptyCanHaveChildrenNode(dom, props.node) &&
      !isAroundActive
  }
  isDropActive={isDropActive}
  isAroundActive={isAroundActive}
  isDragging={isDragging}
  isSelected={isSelectedNode}
  />
const NodeOverlay = ({
  isEmptyContainerActive,
  isAroundActive,
  isDropActive,
  isDragging,
  isSelected,
}) => {
  return (
    <Box
      sx={(theme) => ({
        zIndex: isDropActive || isDragging || isSelected ? 1000 : -1,
        border:
          (isSelected || isDropActive) && !isDragging
            ? `solid 2px ${theme.colors.primary.light}`
            : '',
      })}
    ></Box>
  );
};

이러한 이슈로 NodeOverlay는 걷어내고 가상 선택자 before을 사용했는데, 테두리 안쪽으로 border가 생겨 노드가 background 색상을 가지고 있는 경우 border가 보이지 않는 이슈가 있었다. 마찬가지로 z-index를 설정하면 내부 노드를 선택하지 못하는 이슈가 발생했다.

<Box
  sx={(theme) => ({
    '&::before': {
      border: isHover
      ? `solid 2px ${theme.colors.primary.light}`
      : (isSelectedNode || isDropActive) && !isDragging && !isRoot
      ? `solid 2px ${theme.colors.primary.light}`
      : '',
    },
  })}
  >

해결

구글링을 하던 중 z-index로 인한 겹쳐진 영역에서 이벤트 처리라는 글을 보았고, pointer-events라는 CSS 속성을 알게 됐다.

pointer-events 속성을 none으로 설정하면 해당 엘리먼트에서는 마우스 이벤트가 발생하지 않는다. before 가상선택자는 z-index 우선순위가 높아도 마우스 이벤트 타겟에서 제외되기 때문에 중첩된 노드를 문제 없이 잡을 수 있다.

<Box
  sx={(theme) => ({
    '&::before': {
      border: isHover
      ? `solid 2px ${theme.colors.primary.light}`
      : (isSelectedNode || isDropActive) && !isDragging && !isRoot
      ? `solid 2px ${theme.colors.primary.light}`
      : '',
      zIndex: 100,
      pointerEvents: 'none',
    },
  })}
  >


“배워서 남주는 개발자 김채은입니다 ( •̀ .̫ •́ )✧”

김채은,프론트엔드 개발 @ 나두모두