Frontend

Contenteditable cursor state management

mechaniccoder 2023. 7. 20. 00:04

안녕하세요. 이번 포스팅에서는 최근 회사에서 contenteditable로 구현한 웹 에디터에서 cursor 관련 상태와 로직을 개선한 경험에 대해 공유하려고 합니다. 조금이나마 도움이 되었으면 좋겠네요.
먼저 구현해야 하는 기능에 대해 짤막하게 설명드리겠습니다.

구현 기능

contenteditable로 구현된 에디터 블럭들이 여러 개가 있습니다. 하나의 에디터 블럭에서 입력을 하는 와중에 Enter, Delete 혹은 Backspace 키를 누르면 마치 하나의 문서를 작업하는 것처럼 커서 기준으로 하나의 에디터 블럭이 두개로 나눠지거나 두개의 블럭이 하나로 합쳐지는 기능입니다.

  • Enter를 입력하면 커서 기준으로 오른쪽부터 끝까지의 text가 다음 에디터 블럭으로 나뉘어 집니다.(separate)
  • 에디터 블럭의 커서가 맨 앞에 있는 상태에서 Backspace를 입력하면 이전 에디터 블럭으로 텍스트가 합쳐집니다.(mergeToPrev)
  • 에디터 블럭의 커서가 맨 끝에 있는 상태에서 Delete를 입력하면 다음 에디터 블럭의 텍스트가 현재 에디터 블럭으로 합쳐집니다.(mergeToCurrent)

실제 회사에서는 다양한 기능들이 있지만 단순화하기 위해 세가지 기능만 알아보겠습니다.

Legacy 설계

기존 코드의 로직에 대해 알아보겠습니다. 위에서 소개한 기능 중에 어떤 것이 실행됐는지 lastAction이라는 state로 저장한 뒤에 렌더링이 완료되면 lastAction에 따라서 분기 처리되어 알맞은 코드가 실행되는 방식입니다.

useEffect(() => {
  if (lastAction === 'separate') {
      //...
  }

  if (lastAction === 'mergeToPrev') {
      //...
  }

  if (lastAction === 'mergeToCurrent') {
      //...
  }
}, [lastAction])

이 코드의 문제점은 다른 기능이 추가되면 분기도 추가되어야 합니다. 예를 들어, 붙여넣기 했을때 커서를 설정해야하면 lastAction state로 paste와 이를 사용하는 useEffect안에서 분기도 추가해줘야 할 것 입니다.

useEffect(() => {
  if (lastAction === 'separate') {
      //...
  }

  if (lastAction === 'mergeToPrev') {
      //...
  }

  if (lastAction === 'mergeToCurrent') {
      //...
  }

  // paste 발생 시 커서 조정
  if (lastAction === 'paste') {
      //...
  }
}, [lastAction])

개선한 설계

이 문제를 개선하기 위해 어떤 설계가 직관적이면서 쉬울지 고민했습니다. 구현해야하는 기능에 대해서 다시 살펴보죠. separate, mergeToPrev, mergeToNext 기능이 실행된 후에 커서의 위치를 특정하기 위해서는 2가지를 알아야 합니다.

  1. 커서가 위치한 에디터 블럭의 id
  2. 해당 에디터 블럭에서 커서의 offset

이 두가지만 알면 커서를 설정할 수 있기 때문에 이를 state로 관리했습니다.

cursorPosition = {
  editorBlockId: 1,
  offset: 0,
}

사용하는 쪽에서는 cursorPosition만 바라보고 커서 관련 로직을 실행하기만 하면 됩니다.

useEffect(() => {
  if (cursorPosition) {
    setCursor(//...)
  }
}, [cursorPosition])

다른 기능이 추가되더라도 분기가 늘어나지 않습니다. 원하는 커서 위치를 업데이트해주기만 하면 알아서 커서를 설정해주기 때문에 단순해졌습니다. 그럼 각 기능에 대해 어떻게 적용되는지를 알아보죠.

separate

에디터 블럭(id = 1)에서 Enter를 입력하면 그 다음 에디터 블럭(id = 2)의 맨 앞에 커서가 위치해야합니다.

setCursorPosition({
  editorBlockId: 2,
  offset: 0
})

mergeToPrev

에디터 블럭(id = 2) 맨 앞에서 Backspace를 입력하면 이전 에디터 블럭(id = 1)이 가지고 있던 text의 맨 끝에 커서가 위치합니다.

const prevEditorBlockTextLength = prevEditorBlock.text.length;

setCursorPosition({
  editorBlockId: 1,
  offset: prevEditorBlockTextLength
})

mergeToCurrent

에디터 블럭(id = 1)의 맨 끝에서 Delete를 입력하면 다음 에디터 블럭(id = 2)이 합쳐지면서 에디터 블럭이 가지고 있던 text의 맨 끝에 커서가 위치합니다.

const currentEditorBlockTextLength = currentEditorBlock.text.length;

setCursorPosition({
  editorBlockid: 1,
  offset: currentEditorBlockTextLength
})

마치며

커서 관련 상태 및 로직에 대해 legacy를 어떻게 개선했는지 공유드렸는데 이게 꼭 정답이라고는 생각하지 않습니다. 더 좋은 방법이 있다면 댓글로 알려주시고 관련한 고민을 하시는 분들에게 조금이라도 도움이 되었으면 좋겠네요.