일주일 동안 Notion 클로닝 프로젝트를 진행하면서 느낀 점을 간략하게 작성해 보려고 한다.
먼저 프로젝트의 요구사항은 다음과 같다.
📖 기본 요구사항
- 바닐라 JS만을 이용해 노션을 클로닝 합니다.
- 기본적인 레이아웃은 노션과 같으며, 스타일링, 컬러 값 등은 원하는 대로 커스텀 합니다.
- 글 단위를 Document라고 합니다. Document는 Document 여러 개를 포함할 수 있습니다.
- 화면 좌측에 Root Documents를 불러오는 API를 통해 루트 Documents를 렌더링 합니다.
- Root Document를 클릭하면 오른쪽 편집기 영역에 해당 Document의 Content를 렌더링 합니다.
- 해당 Root Document에 하위 Document가 있는 경우, 해당 Document 아래에 트리 형태로 렌더링 합니다.
- Document Tree에서 각 Document 우측에는 + 버튼이 있습니다. 해당 버튼을 클릭하면, 클릭한 Document의 하위 Document로 새 Document를 생성하고 편집화면으로 넘깁니다.
- 편집기에는 기본적으로 저장 버튼이 없습니다. Document Save API를 이용해 지속적으로 서버에 저장되도록 합니다.
- History API를 이용해 SPA 형태로 만듭니다.
- 루트 URL 접속 시엔 별다른 편집기 선택이 안 된 상태입니다.
- /documents/{documentId} 로 접속 시, 해당 Document 의 content를 불러와 편집기에 로딩합니다.
📖 보너스 요구사항
- 기본적으로 편집기는 textarea 기반으로 단순한 텍스트 편집기로 시작하되, 여력이 되면 div와 contentEditable을 조합해서 좀 더 Rich 한 에디터를 만들어봅니다.
- 편집기 최하단에는 현재 편집 중인 Document의 하위 Document 링크를 렌더링하도록 추가합니다.
- 편집기 내에서 다른 Document name을 적은 경우, 자동으로 해당 Document의 편집 페이지로 이동하는 링크를 거는 기능을 추가합니다.
- 추가로 더 구현하면 좋겠다는 기능들을 추가합니다.
일단 이 요구사항을 맞닥뜨렸을 때 바닐라 JS로만 구현하라고 해서 상당히 힘들 것이라고 생각이 들었다.
처음 구현을 시작할 때 페이지를 어떻게 나눠야 하는지부터 의문이 들었다.
App.js에서 state를 관리해서 각 페이지들에게 뿌려줘야 할지,
또는 App.js는 state를 갖지 않고 Page에서 state를 관리하고
Component에 뿌려줘야 할지 고민하다가 페이지를 2개로 나눴다.
하지만 각각의 페이지에서 state를 따로 관리하다 보니 서로 상호작용할 수 없는 문제가 발생했다.
예를 들어서 editPage에서 documentList의 state를 바꾸고 싶었지만 접근할 수 없었다.
결국 리팩토링을 통해 하나의 mainPage로 2개의 컴포넌트를 갖는 구조로 변경하였다.
하지만 이게 맞는 건지는 잘 모르겠다.. 모든 라이브러리와 프레임워크를 사용할 수 없었기 때문에 최선이었다고 생각한다.
기본적인 요구사항들은 쉽게 구현하였지만 난관은 따로 있었다.
보너스 요구사항 중 하나인 div와 contentEditable을 조합해서 마크다운이 적용되는 에디터를 만드는 것이었다.
마크다운 에디터를 만드는 것이 이렇게 까다로운 것인지 몰랐다.
정규 표현식과 replace를 통해 div
를 없애주었고 </div>
는 <br>
로 치환하여 줄넘김을 구현하였다.
마크다운 문법에 해당하는 사용자의 입력을 한 줄씩 순환하여 html 문법으로 치환하였다.
하지만 a 태그와 li 태그가 끝나지 않고 다음 줄로 계속해서 적용되는 오류가 발생했고 리팩토링을 통해서 개선할 예정이다.
let richContent = "";
if (this.state.content) {
richContent = this.state.content.replace(/<div>/g, "<br>").replace(/<\/div>/g, "");
}
let htmlText = richContent
.split("<br>")
.map((line) => {
if (line.indexOf("# ") === 0) {
return `<h1>${line.substr(2)}</h1>`;
} else if (line.indexOf("## ") === 0) {
return `<h2>${line.substr(3)}</h2>`;
} else if (line.indexOf("### ") === 0) {
return `<h3>${line.substr(4)}</h3>`;
} else if (line.indexOf("- ") === 0) {
return `<li>${line.substr(2)}</li>`;
} else {
if (this.state.titleList) {
for (const titleItem of this.state.titleList) {
console.log(titleItem);
if (line.indexOf(titleItem.title) !== -1) {
const replacedLine = line.replace(
titleItem.title,
`<a class="link" data-id=${titleItem.id}>${titleItem.title}</a>`
);
return replacedLine;
}
}
}
}
return line;
})
.join("<br>");
또한 리렌더링이 발생할 때 편집 중이던 에디터의 포커스가 사라지는 현상이 발생해서 일단 range와 selection을 통해 content 에디터의 마지막 요소로 자동적으로 포커스가 잡히게 설정한 상태다.
바닐라 JS로만 프로젝트를 구현하다 보니까
range와 selection 등 몰랐던 기능들도 많이 알게 되는 계기가 되는 것 같다.
const moveCursorToEnd = (element) => {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(element);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
};
moveCursorToEnd($contentInput);
그 외에 요구사항들은 다 구현하였고, 현재 과제를 제출하여 vercel에 배포까지 한 상태이다.
멘토님의 코드 리뷰가 완료되면 리팩토링을 통해 지속적으로 개선해 나아갈 예정이다.