0%

notion에서 EditableBlock 파헤쳐보기

다소 복잡한 코드로 보일 수 있으나, 사용한 라이브러리의 구조를 뜯어보고 조합해서 코드를 읽으면 이해할 수 있다 🤗

componentDidMount

최초 렌더링 시 마운트 되어야 하는 조건은 다음 2가지 경우에 따라 다르다.

  1. isFirstBlockWithoutHtml && isFirstBlockWithoutSibling: 텍스트 내용도, 다른 블록도 없이 새로운 단독 블록일 시
  2. 위의 경우가 아닌 경우: 기존의 블록이 존재하거나 입력한 텍스트가 있는 경우

1의 경우 전달받은 인자에 알맞게 내용을 입력해서 placeholder를 설정하고,

1
2
3
4
5
6
7
8
if (!hasPlaceholder) {
this.setState({
...this.state,
html: this.props.html,
tag: this.props.tag,
imageUrl: this.props.imageUrl,
});
}

2의 경우 기존의 state가 존재하는 경우이므로 거기에 전달받은 props의 내용을 덧붙여 렌더링한다.

1
2
3
4
5
this.addPlaceholder({
block: this.contentEditable.current,
position: this.props.position,
content: this.props.html || this.props.imageUrl,
});

componentDidUpdate

최초의 렌더링 이후, 컴포넌트가 업데이트 될 때 (state/ props/ 부모 컴포넌트가 업데이트 됨에 따라) 실행되는 코드이다.

여기서 업데이트는 잦게 발생하고 보통 블록의 업데이트가 연관되는데, 다음과 같은 조건을 고려하여 블록을 업데이트 시킨다. 입력 할 때마다 업데이트하는 것이 아니라, 입력이 멈추었을 때 블록을 대상으로 업데이트 시켜야 렌더링 이슈가 발생하지 않는다.

1
2
3
4
5
6
7
8
if (((stoppedTyping && htmlChanged) || tagChanged || imageChanged) && hasNoPlaceholder) {
this.props.updateBlock({
id: this.props.id,
html: this.state.html,
tag: this.state.tag,
imageUrl: this.state.imageUrl,
});
}

componentWillUnmount

컴포넌트가 업데이트 될 때마다 컴포넌트 리렌더링 직전 실행되는 코드로, 어떤 경우든 상관없이 업데이트 될 때마다 실행되어야 하는 코드를 넣는다.

현재 openActionMenuaddEventListener 로 이벤트 핸들러가 세팅되어 있는 상태라고 가정할 때, 아래와 같은 코드가 있다고 가정해보자.

1
2
3
componentWillUnmount() {
document.removeEventListener('click', this.closeActionMenu, false);
}

위 코드는 Component가 Will Unmount 할 때, remove Event Listener를 수행하게 될 것이다.

ComponentWillUnmountuseEffect 에서 return문이랑 동일한데, 하는 역할은 ComponentDidUpdate 직전, 즉, 리렌더링이 일어나기 직전 수행하고 다음 작업을 진행하는 것이다.

이와 같은 작업을 설정한 이유는, 여기서 closeActionMenu 는 action menu가 open 된 상태에서 어디를 클릭하든 menu가 close 되어야 하기 때문이다. 조건 없이 무조건 컴포넌트 상태에 변동이 있을 때 액션 메뉴가 닫혀야 하기 때문에 액션 메뉴를 열게 한 EventHandler를 제거해야 한다. 따라서 click 시 closeActionMenu가 실행되어야 하는 것이다.

handle key event

user가 / 커맨드를 keydown 했을 때 openSelectMenuHandler() 하는 것이 맞아보이지만, 실제로 keyup 을 했을 때 해당 메소드를 실행해야 한다. 사용자가 / 커맨드 입력을 완료해야 액션 메뉴가 떠야 하는데, 여기서 완료란 키 press가 떼어졌을 때 이기 때문이다. 따라서 handleKeyUp 에는 다음과 같은 코드를 작성한다.

1
2
3
4
5
handleKeyUp(e: any) {
if (e.key === CMD_KEY) {
this.openTagSelectorMenu('KEY_CMD');
}
}

그렇다면 onkeydown 에는 어떤 작업이 필요할까?

일단 사용자가 keydown하는 key에는 크게

  1. command key (/): tag를 담은 tagSelectorMenu 오픈

    / 입력 시 현재까지 입력 중이던 this.state.html 의 내용을 백업해두기 위해 htmlBackup 에 저장한다.

  2. Backspace key

    1. 단일: 새로운 블록

      html 내용이 없는 상태에서 backspace를 눌렀다면 블록을 지우는 것이다.

  3. Enter key

    1. 이전 키가 shift가 아닌 경우 && tagSelectorMenu가 열려있지 않은 상태: 현재 블록 페이지 내 블록 리스트에 추가
    2. shift와 함께: 블록 내에서 Enter (html 내용의 일부)
  4. 타이핑을 위해: html (혹은 htmlBackup) 내용 입력

그리고 위 모든 경우에 관계없이 누른 키를 previousKey 로 등록하여 다른 키 이벤트 발생 시 조합해서 고려해야 한다. (Shift + Enter 처럼)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
handleKeyDown(e: any) {
if (e.key === CMD_KEY) {
this.setState({ htmlBackup: this.state.html });
} else if (e.key === 'Backspace' && !this.state.html) {
this.props.deleteBlock({ id: this.props.id });
} else if (e.key === 'Enter' && this.state.previousKey !== 'Shift' && !this.state.tagSelectorMenuOpen) {
e.preventDefault();
this.props.addBlock({
id: this.props.id,
html: this.state.html,
tag: this.state.tag,
imageUrl: this.state.imageUrl,
ref: this.contentEditable.current,
});
}
this.setState({ previousKey: e.key });
}

handle actionMenu

actionMenu 는 아래와 같다.

img

블록 관점에서 mouse 이벤트가 핸들링 되어야 하는 경우는 ContentEditable 의 current가 존재하는 (focus가 된, 수정 중인) 대상이 있을 때에 한해서

1의 경우, ContentEditable 의 text를 range 잡아 (드래그해서 단어문장문단 택) 클릭한 경우이다! 이 경우 actionMenu가 오픈되어야 하는 위치는 텍스트 range의 가운데 위이다. 해당 위치 계산을 위해 'TEXT_SELECTION' 을 키로 전달하여 actionMenu 오픈 시 위치가 계산되도록 한다.

이 경우에는 text가 range에 잡혀 selection에 추가한 range의 start와 end가 다르다. (블록의 text 시작 위치 === start, 끝 위치 === end) 이게 같은 경우에는 text 입력에 따른 actionMenu가 실행되는 경우가 아니므로 처리하지 않는다.

1
2
3
4
5
6
7
8
9
10
handleMouseUp() {
if (this.contentEditable.current) {
const block = this.contentEditable.current;
const { selectionStart, selectionEnd } = getSelection(block);
if (selectionStart !== selectionEnd) {
this.openActionMenu(block, 'TEXT_SELECTION');
}
}
}

2의 경우 블록 오른쪽에 위치한, drag-n-drop 표시를 위한 아이콘을 onclick 한 상태이므로, 'DRAG_HANDLE_CLICK' 를 키로 전달한다. 이 경우 아이콘 바로 왼쪽에 actionMenu가 오픈되어야 한다.

1
2
3
4
5
6
handleDragHandleClick(e: any) {
if (e.target) {
const dragHandle = e.target;
this.openActionMenu(dragHandle, 'DRAG_HANDLE_CLICK');
}
}

actionMenu가 open된 후 발생하는 클릭 이벤트를 핸들링 하기 위해서는, range의 변경이 완료되었다고 판단하는 시간을 0.1초로 가정하고 이벤트 핸들러로 하여금 작동하도록 한다. 이러지 않으면, 사용자가 텍스트를 드래그하여 Range를 정하는 것과 맞물려 작동해 에러가 발생할 수 있으므로 불편을 초래할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
openActionMenu(parent: any, trigger: string) {
const { x, y } = this.calculateActionMenuPosition(parent, trigger);
this.setState({
...this.state,
actionMenuPosition: { x, y },
actionMenuOpen: true,
});

setTimeout(() => {
document.addEventListener('click', this.closeActionMenu);
}, 100);
}

또한 actionMenu가 닫힌 후에는 removeEventListener 를 통해 click에 대한 이벤트를 제거해야 이벤트 버블링을 방지할 수 있다.

handle tagSelectorMenu

tagSelectorMenu는 / 입력하는 html tag들을 담은 메뉴 아이템을 의미한다.

메뉴에서 tag를 select 했을 때 이벤트를 핸들링하는 handleTagSelection 을 살펴보자.

먼저, 이 핸들러는 전달된 tag 인자에 따라 추가된 블록의 html tag 타입을 설정해야 하는데, Enter 키 등으로 블록을 생성한 것이 아니라 페이지가 새로 생성된 최초의 상태에 default로 하나의 블록을 생성해 놓아야 한다.

여기서 사용자가 커맨드로 입력한 tag에 따라 기존 allowedTags 에서 sorting을 해야하기 때문에 match-sorter 라이브러리를 사용했다. 이 라이브러리에서 matchSorter 를 사용할 때 전달하는 인자 중 3번째 option 객체에 keys 를 넣어 전달할 수 있는데, 얘가 바로 default의 경우를 핸들링하는 키이다. 우리는 최초의 블록

1
2
3
4
5
6
7
{
id: this.props.id,
html: '',
tag: 'p',
imageUrl: '',
ref: this.contentEditable.current,
}

을 렌더링 하기 위해 key로써 'tag' 를 핸들러의 인자로 전달한다.

이 외에, tag selector menu에서 클릭 이벤트 없이 typing할 경우 사용자가 내용을 입력하는 것이므로 기존에 입력 중이던 내용을 백업해놓는 htmlBackup 이 존재할 것이기 때문에 그에 이어서 내용을 덧붙인다.

최종적으로 위 모든 경우가 아닌 것은 메뉴 중 태그를 선택한 경우이므로 블록의 태그를 설정하고 메뉴를 닫으면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
handleTagSelection(tag: string) {
if (tag === 'tag') {
this.setState({ ...this.state, tag }, () => {
this.closeTagSelectorMenu();
if (this.fileInput) {
this.fileInput.click();
}
this.props.addBlock({
id: this.props.id,
html: '',
tag: 'p',
imageUrl: '',
ref: this.contentEditable.current,
});
});
} else {
if (this.state.isTyping) {
this.state.htmlBackup &&
this.setState({ tag, html: this.state.htmlBackup }, () => {
this.contentEditable.current && setCaretToEnd(this.contentEditable.current);
this.closeTagSelectorMenu();
});
} else {
if (tag === 'img') {
this.props.setIsVisible(true);
}
this.setState({ ...this.state, tag }, () => {
this.closeTagSelectorMenu();
});
}
}
}

그리고 이 메뉴를 여닫는 메소드는 actionMenu의 경우와 비슷하다. (여기서는 setTimeout 을 설정하지 않아도 된다. range의 변화와 같이 짧은 시간 내 변화가 많이 축적되는 이벤트가 발생하는 것이 아니기 때문이다. 클릭 이벤트 한 번일 뿐!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
openTagSelectorMenu(trigger?: string) {
const { x, y } = this.calculateTagSelectorMenuPosition(trigger);
this.setState({
...this.state,
tagSelectorMenuPosition: { x, y },
tagSelectorMenuOpen: true,
});
document.addEventListener('click', this.closeTagSelectorMenu);
}

closeTagSelectorMenu() {
this.setState({
...this.state,
htmlBackup: null,
tagSelectorMenuPosition: { x: null, y: null },
tagSelectorMenuOpen: false,
});
document.removeEventListener('click', this.closeTagSelectorMenu);
}

위치를 계산하는 utils

  1. setCaretToEnd: current 블록이 변경될 때 cursor (caret) 의 위치는 해당 블록 내 텍스트의 end 여야 한다.

    1
    range.collapse(false) // false === end, true === start
  2. getSelection: 블록 내 텍스트를 드래그하여 텍스트 range를 생성(형성)할 때

  3. getCaretCoordinates: caret을 내포하는 블록(=== coordinates)의 위치를 계산하기 위해