프론트엔드 데브코스 5기 TIL 20 - 노션클로닝(6)

김영현·2023년 10월 25일
3

TIL

목록 보기
24/129

ContentEditable과의 싸움

이런 녀석은 정직하게 대해주면 안된다.
정직하게 대해주다가.

  1. contentEditable는 innerText를 state로 가진다. 따라서 업데이트 시켜줄때마다, innerText를 건드려, 포커스가 풀림
  2. 1번 상황으로 인하여 커서위치도 날아감.
  3. 어찌저찌해도, 커서는 맨 앞에 고정
  4. selection, range등 을 사용하여도 한 글자씩 분리됨(영어기반이라 그런듯. 한글은 자음모음이 합쳐져서 한 글자를 이룬다)

오마이가쒸. 어떻게 이를 해결하면 좋을까 하고 찾아보던 중

https://momoci99.github.io/Contenteditable/
https://deview.kr/data/deview/session/attach/[114]%EC%A4%91%EC%9A%94%ED%95%9C_%EA%B1%B4_%EA%BA%BE%EC%9D%B4%EC%A7%80_%EC%95%8A%EB%8A%94_%EB%A7%88%EC%9D%8C_%EC%B5%9C%EC%A2%85.pdf

한분의 블로그와 네이버 발표자료를 보고 깨달았다.

임시 저장용 인풋인 input-buffer를 하나 숨겨놓고, 원본 contentEditable의 상태를 거기다 업데이트한다.
또한, 상태가바뀔때마다 바로 보여주는게 아니라 상위 문서에서 fetch해올때만 contentEditable의 상태를 바꿔준다.

이렇게 하니 커서문제나 포커스를 잃거나 자음-모음분리 문제가 없다!

하지만 rich한 에디터를 만드려면, 마크다운 문법 정도는 들어가야한다.
결국 상태를 조작해야 할것 같은데...커서만 되돌리면 될것 같기도하다.


커서 조작

...마크다운을 넣으려면 결국 커서조작도 해야 하고
input-buffer를 사용하긴 어려워보인다.
바꾼 값을 바로 보여주어야하기에, 굳이 다른 곳에 상태를 넣어둘 필요가 있을까?
일단 진행하자.

출처 : https://happy-playboy.tistory.com/entry/ContentEditable%EC%97%90%EC%84%9C-%EC%BB%A4%EC%84%9CCaret-%ED%99%9C%EC%9A%A9%ED%95%98%EA%B8%B01-%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8

getSelection으로 페이지 내의 커서 위치를 가져올수 있다.
또한 getSelection.anchorOffset()메서드로 시작 위치를 알 수있다!
그리고 getSelection.collpase()메서드로 커서를 시작과끝이 동일한 위치에 놓을 수 있다.
이게 무슨 말이냐함은, 드래그하면 커서의 간격이 생겨 시작과 끝 좌표가 필요함
나는 드래그가 필요없으니, 시작과끝 좌표가 같을 터
결국 시작 좌표만 알면 된다.

//현재 커서위치를 가져온다. 커서는 결국 텍스트에있으니, 어떠한 노드자식 텍스트노드의 offset을 가진다.
const prevCaret = getSelection().anchorOffset;
//e.target이 아닌, 바로 밑의 자식(텍스트를 입력하면 텍스트 노드가 따로 생긴다)에 이전 시작좌표를 넣는다.
getSelection().collapse(e.target.childNodes[0], prevCaret);

깔끔하게 해결!!!!!!!!
초반에 고생하다 포기하고 다시 뒤적여봤는데, 다행히 값진 경험을 얻어냈다.
확실히 고생하다 얻은 경험이 진짜 값지다!!!!!!!!!!!!!!!!!

마지막 남은 글자를 지웠을 때

이때는 텍스트노드가 날아간다. 따라서 부모노드에게 커서를 달아줘야함

getSelection().collapse(e.target, 0);

혹시나싶어 0을 넣어봤더니 잘 된다!!


placeholder를 일반 노드에 넣어보자.

[contenteditable="true"]:empty::before {
   content: attr(placeholder);
}
  • [contenteditable=true]: contenteditable=true인 모든 노드를 선택한다!
  • :empty: 자식이 없는 노드를 가리킨다.(텍스트를 입력하지 않으면 텍스트노드가 없기에 자식없음)
  • ::befroe: 첫번째 자식으로 가상요소를 생성한다! 이게 무슨말이냐믄, content라는 가상요소를 생성하여 노드의 속성중placeholder값을 할당한다.

=> placeholder의 기능을 정확히 구현했구나!
js로만 가능한줄 알았는데, 생각보다 css의 기능이 무궁무진하다.
앞으로 css에서 처리 가능한건, css에서 처리하는게 좋을지도?
아무래도 css의 처리속도가 js보다 빠를것 같으니까.
아니라면...다음에 한번 찾아보자!


자음-모음 분리현상

어쨋든 마크다운 작업을하면, 타겟의 innerHTML에 마크다운된 태그(가령# 이런식으로라고하면 <h1>이런식으로</h1>넣어주어야한다.
그러면 당연히, 타자를 치던 타겟의 포커스는 풀리고, 커서가 사라진다.
그렇다고 무작정 커서를 계속 복구해주면, 우리의 한글은 자음-모음이 결합된 형태여서
커서를 계속 복구하면 자음-모음이 분리된다.

어떻게 하면 좋을까 하고 열심히 고민해봤지만, 당장 내일이 제출 기한이라 작동만 하는 방식으로 해결봤다.
마크다운이 적용됐을때만, 커서를 복구해준다
물론, 마크다운 종류는 한도끝도없긴한데.
일단 h1~h4만 적용해봤따!

//마크다운으로 바꿔주는 아주 못생긴 함수다...ㅋㅋㅋㅋ
  const convertMarkDown = (text) => {
    let isConverted = false;
    const prevText = text.split("\n").join("\n");
    const converted = text
      .split("\n")
      .map((line) => {
        if (line.indexOf("# ") === 0) {
          return `<h1 contenteditable="true" class="placeholder-h" 
          placeholder="제목1">${line.substring(2)}</h1>`;
        } else if (line.indexOf("## ") === 0) {
          return `<h2 contenteditable="true" 
          class="placeholder-h">${line.substring(3)}</h2>`;
        } else if (line.indexOf("### ") === 0) {
          return `<h3 contenteditable="true" 
          class="placeholder-h">${line.substring(4)}</h3>`;
        } else if (line.indexOf("#### ") === 0) {
          return `<h4 contenteditable="true" 
          class="placeholder-h">${line.substring(5)}</h4>`;
        }
        return line;
      })
      .join("\n");
    if (prevText !== converted) {
      isConverted = true;
    }
    return [converted, isConverted];
  };
  
 //input핸들러중 하나. converted마크다운에서, 마크다운이 된걸 쮀킹해와서...
   const [converted, isConverted] = convertMarkDown(e.target.innerHTML);
      if (isConverted) {
        console.log("바뀜");
        e.target.innerHTML = converted;
        e.target.focus();
        getSelection().collapse(e.target.childNodes[0], 0);
      } else if (!e.target.innerHTML) {
        e.target.focus();
        getSelection().collapse(e.target, 0);
      }

마감이라 진짜 믿도끝도없이 코드를 짯구나...
일단, 해결...인가? 버그테스트도 좀 해봐야겠다.


미션! 주소가 바뀌면 css도 바뀌게 하라!

특정 DocumentItem을 선택하면, 그 문서의 주소로 가진다.
트리중 선택한 그 문서의 배경을 하이라이트 하고싶음
그러려면 3가지가 필요하다

  1. 첫 렌더링시 주소를 받아와서 체크
  2. 앞,뒤로가기 체크
  3. 클릭 체크

와. 너무귀찮은데?

2,3번은 router처럼 popState, push로 체크하였다.
하지만 1번체크가 어려움.
load, DOMContentLoaded 다 안된다.

=> 재귀적 렌더링이라...모든 노드를 가져오지 못한다.
모든 렌더링이 끝난시점에 등록하는게 좋아보이는데?
어떻게 체크하지...일단...

  this.render = () => {
    //상태가 바뀔때, 렌더가 일어난다. 비워두지 않으면 현재 상태+새로운 상태가 되어 노드가 2배 생김
    $documentList.innerHTML = "";
    this.state.forEach((document) => {
      new DocumentItem({
        $target: $documentList,
        initialState: document,
        createDocument,
        removeDocument,
        depth: depth + 1,
        changeBackgroundSelectedDocument,
      });
    });
  };

  this.render();
  addDependOnPathEvent(changeBackgroundSelectedDocument);
  changeBackgroundSelectedDocument();

이런식으로 끝나고 걸어줬다. 함수명 너무 길어진거 아님?ㅋㅋㅋ

일단...마무리하고...생각해보자...


또 버그! contenteditable에서 엔터치기

내 에디터는 엔터를치면, 이벤트를 중지하고 div를 한줄 더 추가해준다.
내가 봤던 레퍼런스에서는 계속 이벤트를 걸어줬으나, 이벤트 위임을 활용하는게 좋아보였다.
한 줄씩 걸다가 innerHTML이 초기화되면 이벤트 핸들러도 초기화되는 불상사가 발생하기도 했고.

수정하다가, 현재 입력중인 텍스트노드를 찾으려했다.
보통 e.target현재 이벤트가 일어나는 노드를 가리킨다.
이게 왠걸? contenteditablekeydown을 조합하니, e.target자식 노드를 가리키는게아니라
contenteditable이 걸려있는 부모를 가리켰다.
따라서 e.target만을 사용하여 현재 작성중인 노드를 찾아 해결하였다.

const keyDownHandler = (e) => {
    //innerHTML수정하면 등록된 핸들러 날아가니까, 이벤트 위임 사용
    //isComposing은 합성글자(한글같은 문자)에대해 체크해준다.
    if (e.isComposing || e.target.tagName === "INPUT") {
      return;
    }
    if (e.target.getAttribute("contenteditable") !== "true") {
      return;
    }
    switch (e.key) {
      case "Enter":
        e.preventDefault();
        const nextLine = makeNewLine();
        e.target.after(nextLine); //다음 형제로 삽입해준다
        nextLine.focus();
        break;
      case "Backspace":
        if (!e.target.innerHTML) {
          e.preventDefault();
          const prevLine = e.target.previousElementSibling;
          if (prevLine) {
            prevLine.focus();
            getSelection().collapse(prevLine, prevLine.childNodes.length);
            e.target.remove();
          }
        }
        break;
      case "ArrowUp":
        e.target.previousElementSibling.focus();
        break;
      case "ArrowDown":
        e.target.nextElementSibling.focus();
        break;
    }
  };

input$editor하위에 있어서 한번 거름망 해주었다.


느낀점

일단 부딪혀봐야 안다. 이건 팩트다.
그리고 부딪히기전에 설계를 하는것도 중요하다. 이것도팩트다.
맨땅에 헤딩하지말고, 아주 기초적인 설계라도 해야한다.
또한 맨땅에 헤딩하는 것처럼 끈기가 필요하다.
얼추 마무리느 지었으니 더 일 벌리기전에 제출하고...
코드 리뷰받고 리팩토링을 빡세게 해야겠다!

profile
모르는 것을 모른다고 하기

0개의 댓글