바로가기

주석에 번호를 붙이는 데 왜 CSS를 썼나

개발 중인 마크다운 메모장 Comment Note에 들어온 첫 이슈를 처리한 기록. 간단해 보이는 요청이었는데, 구현 방식을 고르는 데서 갈렸다.

만들고 있는 것

글을 쓰다 보면 본문에 이런 줄이 섞인다.

(← 이거 나중에 고치기)
※ 근거 약함
TODO: 수치 확인

며칠 뒤에 열어 보면 본문인지 나에게 남긴 말인지 구분이 안 된다. Comment Note는 그 말을 본문에서 떼어 내는 메모장이다. 구간을 드래그해서 우클릭하면 주석이 달리고, 본문은 그대로 둔 채 그 구간에 표시만 남는다. 주석은 화면 아래 목록에 문서 순서대로 쌓인다.

윈도우용 데스크톱 앱이고, Tauri(Rust + WebView2)로 만들었다. 설치 파일 1.1MB.

들어온 이슈

#1 [제안] 이슈를 다는 방식을 전통적으로 했으면

제보자가 화면 두 장을 붙여 놓았다. 지금 화면은 이랬다.

현재 — 하이라이트만 있다

WRF에 주석이 달려 배경색이 칠해져 있다. 그리고 이렇게 바꿔 달라고 했다.

제안 — 각주 번호가 붙는다

위와 같이 변경하고 숫자 1 밑에 언더라인해서 클릭하면 주석으로 이동하거나 보이도록.

WRF[1]. 각주 방식이다.

요청 뒤에 있는 진짜 문제

처음엔 취향 문제인 줄 알았다. 하이라이트냐 번호냐.

그런데 실제 화면을 놓고 보니 그게 아니었다. 주석이 세 개 달린 문서에서, 위쪽 하이라이트와 아래쪽 메모 카드를 잇는 단서가 하나도 없었다. 하이라이트는 전부 같은 색이고 카드에는 1, 2, 3 번호가 붙어 있는데, 세 번째 하이라이트가 몇 번 카드인지 알려면 하나씩 눌러 보는 수밖에 없었다.

번호는 장식이 아니라 두 영역을 잇는 유일한 식별자였다. 종이책 각주가 수백 년 동안 그렇게 해 온 이유이기도 하다. 요청을 그대로 받는 게 맞았다.

첫 번째 갈림길: 번호를 어디에 넣을 것인가

가장 쉬운 구현은 번호를 그냥 텍스트로 넣는 것이다.

span.appendChild(document.createTextNode("[" + n + "]"));

한 줄이면 화면은 똑같이 나온다. 그런데 이 앱에서는 이렇게 하면 안 되는 이유가 세 개 있다.

1. 파일이 오염된다. 이 앱은 사용자가 고른 폴더의 .md 파일을 그대로 다룬다. 앱 전용 데이터베이스가 없다. 편집 내용은 편집기 DOM에서 텍스트를 긁어 파일에 쓰는데, 번호가 텍스트 노드로 들어가 있으면 저장되는 파일이 이렇게 된다.

WRF[1] 모델을 사용하여 날씨 연구를 진행 중입니다.

다른 편집기로 열면 정체불명의 [1]이 박혀 있다. 파일 하나가 곧 메모 하나라는 이 프로젝트의 전제가 깨진다.

2. 복사할 때 딸려 나온다. 본문을 긁어 다른 곳에 붙여 넣으면 [1]이 따라간다.

3. 편집 중에 부서진다. 편집 영역이 contenteditable이라 커서가 번호 안으로 들어간다. [1]에서 1만 지워 []가 남는 상태를 막을 방법이 마땅치 않다.

그래서 번호를 문서에 넣지 않고 그리기만 하기로 했다. CSS 가상 요소를 쓴다.

/* 각주 번호. 문서 글자가 아니라 표시라서 ::after 로 그린다 —
   본문 텍스트에 섞이지 않으므로 파일에도, 복사한 내용에도 들어가지 않는다. */
.anno[data-n]::after{
  content:"[" attr(data-n) "]";
  font-family:var(--font-mono);
  font-size:.82em;
  color:var(--accent);
  border-bottom:1px solid var(--accent);   /* 누를 수 있다는 표시 */
  cursor:pointer;
  user-select:none;
}

::after로 그린 내용은 텍스트 노드가 아니다. 그래서 이 셋이 공짜로 따라온다.

  • 파일에 저장되는 텍스트에 없다 (DOM을 훑어 텍스트를 모을 때 잡히지 않는다)
  • 드래그 선택에 포함되지 않아 복사에 딸려가지 않는다
  • 편집 커서가 들어갈 수 없어 사용자가 부술 수 없다

번호 값은 data-n 속성으로 넘기고 attr()로 읽는다. 숫자가 바뀌면 속성만 갈아 끼우면 된다.

가상 요소는 클릭 이벤트에서도 부모 요소로 취급되므로, 번호를 눌러도 기존 주석 클릭 핸들러가 그대로 받는다. 별도 처리가 필요 없었다.

두 번째 갈림길: 한 주석이 조각날 때

이 앱의 주석은 마크다운 원문의 문자 오프셋 [start, end)로 저장된다. 화면에 그릴 때는 그 구간을 <span>으로 감싸는데, 구간이 항상 한 덩어리인 것은 아니다.

  • 여러 줄에 걸친 주석 → 줄마다 span 하나 (블록 경계를 넘는 span은 HTML에서 깨진다)
  • 표의 여러 셀에 걸친 주석 → 셀마다 span 하나

여기에 번호를 그대로 붙이면 이렇게 된다.

| 생활용품[2] | 20,000[2] |

그래서 한 주석의 마지막 조각에만 번호를 붙였다.

function stampRefs(){
  var order = {};
  state.note.annos.forEach(function(a, i){
    if(!a.gone) order[a.id] = i + 1;
  });
  [src, doc].forEach(function(root){
    // 이전 번호를 지우고
    [].forEach.call(root.querySelectorAll(".anno[data-n]"), function(el){
      el.removeAttribute("data-n");
    });
    // 주석마다 마지막 조각에만 다시 붙인다
    Object.keys(order).forEach(function(id){
      var els = root.querySelectorAll('[data-anno="' + id + '"]');
      if(els.length) els[els.length - 1].setAttribute("data-n", order[id]);
    });
  });
}

[src, doc] 두 곳을 도는 이유는, 이 앱이 마크다운 원문과 미리보기를 나란히 띄우기 때문이다. 두 화면에 같은 번호가 같은 자리에 찍혀야 한다.

세 번째 갈림길: 사라진 주석의 번호

이 앱에는 "본문에서 사라진 구간"이라는 상태가 있다. 주석이 가리키던 문장을 지우면 주석을 조용히 버리지 않고 회색으로 남겨 둔다. 지울지 다시 붙일지는 사람이 정하는 게 맞다고 봤다.

번호를 매길 때 이것들까지 세면 살아 있는 주석 번호가 밀린다. 위 코드의 if(!a.gone)가 그 처리다. 화면에 그려진 주석만 1부터 이어진다.

클릭하면 "이동하거나 보이도록"

요청의 마지막 문장이 애매했다. 이동인가, 보이는 건가.

둘 다 하기로 했다. 번호를 누르면:

  1. 본문에서 그 구간이 깜빡이고 (원문·미리보기 양쪽 다)
  2. 아래 주석 목록에서 해당 카드가 선택되고
  3. 목록이 접혀 있으면 펴고, 목록이 길면 그 카드가 보이도록 스크롤한다

3번이 없으면 "눌렀는데 아무 일도 안 일어나는" 경우가 생긴다. 주석 패널을 접어 둔 사용자에겐 클릭이 그냥 먹통으로 보인다.

if(card){
  if(shelf.classList.contains("collapsed")){
    shelf.classList.remove("collapsed");
    /* ... */
  }
  var cr = card.getBoundingClientRect(), lr = shelfList.getBoundingClientRect();
  if(cr.top < lr.top || cr.bottom > lr.bottom){
    shelfList.scrollTop += (cr.top - lr.top) - 8;
  }
}

결과

각주 번호가 붙은 화면

세제 (지난번 산 것과 같은 것)[1], 생활용품 | 20,000[2], 한 번에 몰아서 가는 편이 싸게 끝난다[3]. 아래 카드 번호와 정확히 맞물린다. 표에 걸친 2번 주석은 셀 두 개에 하이라이트가 걸려 있지만 번호는 한 번만 나온다.

어떻게 검증했나

개발 환경에 Rust 툴체인이 없어서 앱을 띄워 볼 수 없는 상태였다. 그래서 UI는 로컬 크롬을 헤드리스로 돌려 검증하고 있다. 프론트엔드가 번들러 없는 정적 파일이라 브라우저로 그냥 열리는 구조라 가능한 방법이다.

핵심은 함수를 직접 부르지 않고 실제 DOM 이벤트로 사용자 경로를 밟는 것이다.

function annotate(surface, needle, body){
  if(!selectIn(surface, needle)) return false;   // 텍스트 선택
  ev(surface, "contextmenu");                    // 우클릭
  ev(document.querySelector('#menu [data-act="annotate"]'), "click");
  $("composerText").value = body;                // 메모 입력
  ev($("composerSave"), "click");                // 저장
  return true;
}

각주 기능으로 추가한 검사는 여덟 개다.

검사 확인하는 것
원문·미리보기에 번호 두 화면 모두 3개씩
번호 1..3 빠지거나 겹치지 않는가
카드 번호와 일치 목록 번호와 같은 숫자인가
여러 조각에 번호 하나 표에 걸친 주석의 조각 수 > 1, 번호는 1개
본문 오염 없음 src.innerText[1]이 없는가
저장될 내용에 없음 파일에 쓸 텍스트에 [1]이 없는가
클릭하면 이동 활성 주석이 바뀌고 카드가 선택되는가

굵게 표시한 두 개가 이번 구현의 핵심이었다. 화면에는 보이는데 파일에는 없어야 한다는, 말로는 쉽지만 잘못 짜면 조용히 깨지는 조건이다.

전체 52개 통과. 결과는 <pre> 태그로 DOM에 붙이고 --dump-dom으로 긁어 센다.

chrome --headless=new --dump-dom --virtual-time-budget=9000 \
  "file:///.../index.html" | grep -o 'PASS\|FAIL'

배포

태그를 올리면 GitHub Actions가 윈도우 러너에서 빌드해 릴리스를 만든다.

git tag v0.1.2 && git push origin v0.1.2

CI가 컴파일을 먼저 확인하고(1분 29초), 통과하면 태그를 올리도록 걸어 뒀다. 깨진 상태로 태그를 붙이면 빈 릴리스가 공개로 남기 때문이다.

결과물은 설치 파일 1.11MB, 포터블 exe 3.13MB. Electron이었으면 이 자리가 90MB를 넘는다.

남은 질문 하나

이슈에 이렇게 되물어 두었다.

지금은 "전통적인" 각주 형태 중 번호만 가져오고 하이라이트 배경은 그대로 뒀는데 (올려주신 목업이 그런 형태여서요), 하이라이트를 아예 빼고 번호만 남기는 쪽이 더 나을까요? 본문이 훨씬 깨끗해지는 대신 어디에 주석이 걸렸는지 범위가 안 보입니다.

목업은 "무엇을 원하는지"는 정확히 알려 주지만 "왜"까지 담지는 못한다. 번호를 원한 이유가 연결 단서가 없어서였다면 하이라이트는 남는 게 맞고, 본문이 어수선해서였다면 빼는 게 맞다. 같은 화면을 두고도 답이 갈린다. 그래서 물어봤다.

정리하며

작은 요청이었는데 판단이 세 번 갈렸다.

  1. 번호를 문서에 넣을 것인가, 그리기만 할 것인가 — 파일 형식이 곧 제품인 앱에서는 화면에 뭔가를 더할 때마다 "이게 파일에 들어가도 되는 것인가"를 먼저 물어야 한다.
  2. 조각난 구간에 몇 번 붙일 것인가 — 자료 구조가 화면과 1:1이 아닐 때 늘 나오는 문제.
  3. 죽은 주석에 번호를 줄 것인가 — 예외 상태를 어떻게 세느냐가 정상 상태의 표시를 바꾼다.

셋 다 "각주 번호를 붙여 주세요"라는 문장에는 없던 것들이다. 요청은 화면을 말하지만 구현은 늘 그 아래를 건드린다.