마우스 올리면 말풍선처럼 내용 보여주는 각주/주석 만들기 (나무위키 스타일)

여러분 각주를 아시나요? 저는 좋아합니다. 맥락에 맞지 않는 TMI를 쓰고 싶을 때 그냥 각주로 우겨넣으면 되거든요. 이번 글에서는 그 각주, 특히 나무위키에서 사용 중인 마우스를 올리면 말풍선으로 표시해주는 그 방식을 HTML 기반에서 CSS와 자바스크립트로 어떻게 구현하는지 설명드리겠습니다.

시작하기에 앞서서 직접 보여드리는게 좋겠습니다. 이런1거예요.

※블로그가 점점 유지보수 되어감에 따라 모습이 변할 수도 있습니다.

기본 모습
기본 모습
마우스 올렸을 때 모습
마우스를 올렸을 때
각주
하단 각주의 모습
목차
  1. 원리
    1. 디자인
    2. 나타나게 하기
    3. 위치 계산
      1. onmouseover
  2. 위 내용도 자바스크립트로 대체
    1. 모바일 대응
  3. 마무리
설명 없이 그대로 복붙을 원하는 분들을 위한 코드 뭉치

[대괄호] 안에 들어간 부분은 직접 채워넣으셔야 합니다!

  • HTML
    • 본문에 넣는 부분
      <span class='footnote t' id='[본문아이디]' style='--left:[내용 가로의 절반 길이]'><!--
        --><a class='n' href='#[아래아이디]'>[숫자]</a><!--
        --><<-- 생략 가능 -->span>[내용]</span><!--
      --></span>

      가독성 때문에 주석으로 줄바꿈을 했지만 필수는 아닙니다. 다만 주석 없이 줄바꿈을 하면 본문이 줄바꿈돼버립니다.

    • 맨 아래 넣는 부분
      <div class='footnote b'>
        <div id='[아래아이디]'>
          <a class='n' href='#[본문아이디]'>1</a>
          <span>[내용]</span>
        </div>
      </div>
  • CSS
    .footnote .n{
      cursor: help;
      text-decoration: none;
      color: [숫자 색깔];
      margin-right:.1em;
    }
    .footnote.t .n{
      vertical-align: super;
      font-size:.8em;
      letter-spacing: -.1em;
    }
    .footnote.b .n{
      font-size: .8rem;
    }
    .footnote.t{
      display: inline;
      position: relative;
      scroll-margin-top: 20vh;
    }
    .footnote.b>div{
      scroll-margin-top: 20vh;
    }
    .footnote.b{
      border-top: .2rem solid [말풍선 색깔];
      padding-top: .3rem;
      padding-left: .6rem;
      margin-top: 2rem;
    }
    .footnote.b:not(.n){
      font-size: .8rem;
    }
    .footnote.t>:not(.n)::before{
      content: '';
      position: absolute;
      top: -.56rem;
      left: calc(var(--left,0px));
      width: 0;
      height: 0;
      border-style: solid;
      border-width: .3rem;
      border-color: transparent transparent [말풍선 색깔] transparent;
    }
    .footnote.t>:not(.n){
      display: none;
      z-index: 3;
      position: absolute;
      box-sizing: content-box;
      top: 1.25rem;
      left: calc(0px - var(--left,0px));
      background-color: [말풍선 색깔];
      padding: 1em;
      box-shadow: .18rem .18rem .47rem 0 rgba(0,0,0,.1);
      width: 90vw;
      max-width: max-content;
      white-space: pre-line;
    }
    @media (hover:hover){
      .footnote.t .n:hover + *{
        display: block;
      }
      .footnote.t>:not(.n):hover{
        display: block;
      }
    }
    .footnote.b>*>.n{
      float: left;
      clear: left;
    }
    .footnote.b>*>:not(.c-n){
      float: left;
      margin-left: .7em;
      max-width: calc(100% - 2em);
    }
    @media (hover:none){
      .footnote.t>:not(.n){
        padding-top:2rem
      }
    }
    .footnote.t .bar{
      display: block;
      margin-bottom: 1em;
      display: flex;
      justify-content: space-between;
      font-size: 1rem;
      background-color: [모바일 조작 바 색깔];
      position: absolute;
      top: 0;
      right: 0;
      width: 100%;
    }
    .footnote.t .bar a{
      border: hidden;
      cursor: pointer;
      text-align: center;
      width: 1.7em;
    }
    
  • 자바스크립트
    function footnote(obj){
      var position = obj.getBoundingClientRect().left;
      var balloon = obj.parentNode.querySelector(":not(.n)");
      var width = balloon.offsetWidth;
      var win = document.body.clientWidth;
      if(width >= win-20){
        balloon.style.width = (win-20) + "px";
        obj.parentNode.setAttribute("style","--left:"+(position-10)+"px;");
      } else if(width/2 > (position-10)){
        balloon.style.width = width + "px";
        obj.parentNode.setAttribute("style","--left:"+(position-10)+"px;");
      } else if(width/2 > (win-position-15)){
        balloon.style.width = width + "px";
        obj.parentNode.setAttribute("style","--left:"+(width-win+position+15)+"px;");
      } else {
        balloon.style.width = width + "px";
        obj.parentNode.setAttribute("style","--left:"+(width/2-10)+"px;");
      }
    }
    {
      let canHover = window.matchMedia("(hover:hover)").matches;
      let fnlist = document.querySelectorAll(".footnote.t");
      for(let i=0;i<fnlist.length;i++){
        let fnnum = fnlist[i].getElementsByClassName("n")[0];
        if(fnnum){
          let fnhref = fnnum.getAttribute("href");
          if(fnhref){
            if(!fnlist[i].querySelector(":not(.n)")){
              let fnid = document.getElementById(fnhref.replace("#",""));
              if(fnid){
                let fndetail = fnid.querySelector(":not(.n)");
                if(fndetail){
                  let fninner = fndetail.innerHTML;
                  if(fninner){
                    let span = document.createElement("span");
                    span.innerHTML = fninner;
                    fnlist[i].appendChild(span);
                  }
                }
              }
            }
            if(!canHover){
              let fnbar = document.createElement("span");
              fnbar.setAttribute("class","bar");
              fnbar.innerHTML = "<a href=\'"+fnhref+"\' onclick=\'this.parentNode.parentNode.style.display = \"none\"\;\'>↓</a><a onclick=\'this.parentNode.parentNode.style.display = \"none\"\;\'>×</a>";
              fnlist[i].querySelector(":not(.n)").prepend(fnbar);
            }
          }
          switch(canHover){
            case true: fnnum.addEventListener("mouseover",()=>{footnoteP(fnnum);}); break;
            case false:{
              fnnum.removeAttribute("href");
              fnnum.addEventListener("click",()=>{
                let fnbl = fnlist[i].querySelector(":not(.n)");
                if(fnbl) fnbl.style.display = "block";
                footnoteP(fnnum);
              });
            } break;
          }
        }
      }
    }

원리

크게 두 파트로 나눌 수 있습니다. 하나는 디자인과 마우스를 올리면 나타나는 기능, 다른 하나는 위치 계산입니다.

기본 구조

코드 같이 보기
  • 본문에 넣는 부분
    <span class='footnote t' id='[본문아이디]' style='--left:[내용 가로의 절반 길이]'><!--
      --><a class='n' href='#[아래아이디]'>[숫자]</a><!--
      --><<-- 생략 가능 -->span>[내용]</span><!--
    --></span>

    가독성 때문에 주석으로 줄바꿈을 했지만 필수는 아닙니다. 다만 주석 없이 줄바꿈을 하면 본문이 줄바꿈돼버립니다.

  • 맨 아래 넣는 부분
    <div class='footnote b'>
      <div id='[아래아이디]'>
        <a class='n' href='#[본문아이디]'>1</a>
        <span>[내용]</span>
      </div>
    </div>

HTML 상에서의 구조를 보시면 <span>으로 크게 감싸져 있고, 그 아래 숫자와 또 하나의 <span>이 들어있습니다.

이렇게 된 이유는 <span>이 inline 요소이기 때문입니다. 그 외 별 이유는 없어요. 저 안에 block 요소가 하나라도 들어가면 그 특성이 본문에도 영향을 미쳐 주석 바로 뒤에서 줄바꿈이 돼버립니다.2근데 <span> 태그로 한번 더 감싼 후(주석의 내용 부분처럼)에 CSS로 설정한 block이 들어오면 또 문제 없더라고요? 그래서 display:block 넣은 태그로 이렇게 문단 나누기 하고 있고요? 그러니까 직접 실험해보시면서 알아봐요 우리. 물론 CSS 상에서 display: inline을 해도 되지만 취향차이인 것 같습니다. 저는 <span>이 있기 때문에 그냥 쓴 거여서요.

숫자 부분은 class로 표시해뒀습니다. CSS로 조건 설정해서 생략할 수 있긴 한데 혹시 모를 특수한 상황이나 관리 편의성이나 생각하면 그냥 class 쓰는게 더 나을 것 같았습니다. vertical-align: super를 통해 위로 띄워주고 색을 연하게 해줬습니다. 저는 user-select:none;을 통해 혹시 본문을 긁어 복사할 때 각주 숫자까지 딸려가지 않도록 해줬는데 이건 취향이니 위 코드에 넣지는 않았습니다.

디자인

코드 같이 보기
.footnote .n{
  cursor: help;
  text-decoration: none;
  color: [숫자 색깔];
  margin-right:.1em;
}
.footnote.t .n{
  vertical-align: super;
  font-size:.8em;
  letter-spacing: -.1em;
}
.footnote.b .n{
  font-size: .8rem;
}
.footnote.t{
  display: inline;
  position: relative;
  scroll-margin-top: 20vh;
}
.footnote.b>div{
  scroll-margin-top: 20vh;
}
.footnote.b{
  border-top: .2rem solid [말풍선 색깔];
  padding-top: .3rem;
  padding-left: .6rem;
  margin-top: 2rem;
}
.footnote.b:not(.n){
  font-size: .8rem;
}
.footnote.t>:not(.n)::before{
  content: '';
  position: absolute;
  top: -.56rem;
  left: calc(var(--left,0px));
  width: 0;
  height: 0;
  border-style: solid;
  border-width: .3rem;
  border-color: transparent transparent [말풍선 색깔] transparent;
}
.footnote.t>:not(.n){
  display: none;
  z-index: 3;
  position: absolute;
  box-sizing: content-box;
  top: 1.25rem;
  left: calc(0px - var(--left,0px));
  background-color: [말풍선 색깔];
  padding: 1em;
  box-shadow: .18rem .18rem .47rem 0 rgba(0,0,0,.1);
  width: 90vw;
  max-width: max-content;
  white-space: pre-line;
}
@media (hover:hover){
  .footnote.t .n:hover + *{
    display: block;
  }
  .footnote.t>:not(.n):hover{
    display: block;
  }
}
.footnote.b>*>.n{
  float: left;
  clear: left;
}
.footnote.b>*>:not(.c-n){
  float: left;
  margin-left: .7em;
  max-width: calc(100% - 2em);
}
@media (hover:none){
  .footnote.t>:not(.n){
    padding-top:2rem
  }
}
.footnote.t .bar{
  display: block;
  margin-bottom: 1em;
  display: flex;
  justify-content: space-between;
  font-size: 1rem;
  background-color: [모바일 조작 바 색깔];
  position: absolute;
  top: 0;
  right: 0;
  width: 100%;
}
.footnote.t .bar a{
  border: hidden;
  cursor: pointer;
  text-align: center;
  width: 1.7em;
}

이 부분은 말풍선 꼬리 만드는 거 말고는 뭐 없습니다. 여기를 참고했어요. 핵심은 ::before에 테두리를 줘서 만드는 것입니다.

max-width: max-content는 중요합니다. 오래돼서 기억나지는 않는데 width에 바로 넣어버리면 너비 적용이 제대로 안됐던 것 같아요.

상위 요소에 overflow: visible은 필수입니다. 이걸 넣지 않으면 말풍선이 짤려요. 특정 요소에서 튀어나오지 않게 하려면 자바스크립트 쪽에서 값들을 좀 만지면 됩니다.

var(--left)라는 값을 이용하고 있습니다. 자바스크립트로 결정되는 말풍선의 위치값이라고 보시면 되는데요, 대충 '왼쪽으로 얼마나 이동시킬 것이냐' 라고 생각하시면 됩니다. 저 부분에 0이 들어오면 말풍선 꼬리가 가장 왼쪽에 있어요.

나타나게 하기

:hover를 이용합니다. .footnote.t .n:hover + *이 부분이 되게 중요한데요, 숫자 부분에 :hover가 붙어 있지만 뒤에 + 선택자를 이용해서 내용 부분에 display: block을 적용시킬 수 있게 됩니다. 당연히 말풍선 위에 마우스가 있을 때도 계속 보여지고 있어야 하니 내용 부분에도 :hover{display: none}을 적용해 줬습니다.

위치 계산

코드 같이 보기
function footnote(obj){
  var position = obj.getBoundingClientRect().left;
  var balloon = obj.parentNode.querySelector(":not(.n)");
  var width = balloon.offsetWidth;
  var win = document.body.clientWidth;
  if(width >= win-20){
    balloon.style.width = (win-20) + "px";
    obj.parentNode.setAttribute("style","--left:"+(position-10)+"px;");
  } else if(width/2 > (position-10)){
    balloon.style.width = width + "px";
    obj.parentNode.setAttribute("style","--left:"+(position-10)+"px;");
  } else if(width/2 > (win-position-15)){
    balloon.style.width = width + "px";
    obj.parentNode.setAttribute("style","--left:"+(width-win+position+15)+"px;");
  } else {
    balloon.style.width = width + "px";
    obj.parentNode.setAttribute("style","--left:"+(width/2-10)+"px;");
  }
}

가장 핵심 포인트입니다! 여기서 자바스크립트가 들어가는데요, 위 HTML 부분에서 함수를 호출한 이유가 여기 나옵니다.

간단한 과정은 이렇습니다.

  1. 말풍선 너비, 창 크기, 각주 위치(숫자 있는 곳)를 비교하기
  2. 각 상황별로 적절한 --left값을 전달
  3. --left를 기준으로 CSS 단에서 위치 결정

그래서 .getBoundingClientRect().left로 위치를 가져오고, .parentNode.querySelector(":not(.n)").offsetWidth로 말풍선 너비를, document.body.clientWidth로 창 크기를 가져와서 비교합니다.

조건과 대응은 이렇게 됩니다.

코드에서 20, 10 이런 숫자들이 등장하는 이유는 제가 임의로 여유 픽셀을 줬기 때문입니다. 길이에 의미가 있지는 않습니다.

onmouseover

{
  let tags = document.querySelectorAll(".footnote.t .n");
  for(let i=0;i<tags.length;i++) tags[i].addEventListener("mouseover",()=>{footnote(tags[i]);});
}

이전에는 일일이 onmouseover 속성을 넣어줬었는데요, 그럴 필요가 없었더라고요. 자바스크립트로 넣어줬습니다.

위 내용도 자바스크립트로 대체

코드 같이 보기
{
  let canHover = window.matchMedia("(hover:hover)").matches;
  let fnlist = document.querySelectorAll(".footnote.t");
  for(let i=0;i<fnlist.length;i++){
    let fnnum = fnlist[i].getElementsByClassName("n")[0];
    if(fnnum){
      let fnhref = fnnum.getAttribute("href");
      if(fnhref){
        if(!fnlist[i].querySelector(":not(.n)")){
          let fnid = document.getElementById(fnhref.replace("#",""));
          if(fnid){
            let fndetail = fnid.querySelector(":not(.n)");
            if(fndetail){
              let fninner = fndetail.innerHTML;
              if(fninner){
                let span = document.createElement("span");
                span.innerHTML = fninner;
                fnlist[i].appendChild(span);
              }
            }
          }
        }
        if(!canHover){
          let fnbar = document.createElement("span");
          fnbar.setAttribute("class","bar");
          fnbar.innerHTML = "<a href=\'"+fnhref+"\' onclick=\'this.parentNode.parentNode.style.display = \"none\"\;\'>↓</a><a onclick=\'this.parentNode.parentNode.style.display = \"none\"\;\'>×</a>";
          fnlist[i].querySelector(":not(.n)").prepend(fnbar);
        }
      }
      switch(canHover){
        case true: fnnum.addEventListener("mouseover",()=>{footnoteP(fnnum);}); break;
        case false:{
          fnnum.removeAttribute("href");
          fnnum.addEventListener("click",()=>{
            let fnbl = fnlist[i].querySelector(":not(.n)");
            if(fnbl) fnbl.style.display = "block";
            footnoteP(fnnum);
          });
        } break;
      }
    }
  }
}

사실 저는 이런 방식을 좋아하지 않습니다. 자바스크립트가 개입하기 전에, 아니, 개입하지 않고도 글의 구조가 HTML 상에서 완성되어 있어야 한다고 생각하거든요.

이런 계기3로 생각을 바꾸고 위쪽 각주 내용은 자바스크립트로 아래 각주의 내용을 가져다 붙이기로 했습니다.

이거 처음에는 반대 순서로 만들었었는데, 중복 각주에 대응하기 위해서 갈아엎었습니다. 중복 각주는 이MMM 말합니다.

이 부분의 자바스크립트는 반복문이 기본입니다. .footnote.t에 해당하는 모든 요소들을 찾아 반복해줍니다. 수많은 if들은 사실 에러 방지입니다. 스크립트가 뭔가 하나만 삑나도 전체 작동을 멈춰버려서 좀 꼼꼼하게 해놨습니다. 대충 위 .n이 가리키는 id을 찾아 그 내용을 복사해서 appendChild로 붙여넣는 방식입니다.

덤으로 mouseover 이벤트도 여기 합쳤습니다. 반복 두 번은 효율적이지 못한 거 같아서요.

모바일 대응

모바일은 hover가 안되니까 말풍선을 쓰지 않는 쪽으로 방치했었습니다. 누르면 대충 번호쪽으로 가니까 책처럼 직접 찾아봐라는 생각이었습니다. 근데 나무위키를 보다 보니까 이거처럼 모바일 UI도 만들고 싶더라고요. 그래서 만들었습니다.

일단 window.matchMedia()로 hover를 체크합니다. 굳이 이 방식을 택한 이유는 css의 미디어쿼리와 결과를 일치시키기 위함입니다. 그 후 hover가 안되는 녀석들은 prepend()를 통해 아래쪽 각주 보기 버튼과 닫기 버튼을 만들어줬습니다. 중간의 복잡한 문자열이 바로 그것입니다.

mouseover 이벤트도 click으로 바꿔줬습니다.

마무리

맨 위에 복붙용으로 코드를 올려놓긴 했지만 본문만 보고는 만들 수 없는 형태가 되어버렸네요. 죄송합니다. 이게 다시 쓰는 거라 디테일한 분량까지 챙기기는 너무 힘들었어요. 그래도 이 미루고 미루던 숙원사업을 해내서 다행입니다.

1 이런 방식입니다.
2 근데 <span> 태그로 한번 더 감싼 후(주석의 내용 부분처럼)에 CSS로 설정한 block이 들어오면 또 문제 없더라고요? 그래서 display:block 넣은 태그로 이렇게 문단 나누기 하고 있고요?
그러니까 직접 실험해보시면서 알아봐요 우리.
3 시작은 모바일 환경에 대한 고민이었습니다. 모바일은 hover가 안 되니까 onclick으로 바꾸고 전용 UI를 따로 디자인해야 하나? 이런 고민을 하고 있었는데요, 이 당시 저는 css를 통해서 이미 모바일에서는 hover가 작동하지 않도록 만들어 놓은 상황이었습니다. 스크롤 할 때 자꾸 걸리더라고요. 근데 그러고보니 이렇게 되면 모바일에서는 주석 말풍선 내용이 없는 것이나 다름없잖아요? 결국 각주의 '기본' 구성은 '번호를 누르면 페이지 아래쪽 같은 번호를 가진 문항을 찾아서 보여주는 것'이었습니다. 그러니까 말풍선은 기본 구성에 포함되지 않은 것이죠.
M 같은 내용을 보여줍니다.
🏠📑