본문 바로가기

진리는어디에

[JavaScript] ToC(Table of Conents) 만들기

글의 내용이 길어지다 보면 내게 필요한 내용을 찾기 위해 끊이 없이 화면을 스크롤 해야 하는 경우가 있다. 이럴 때 목차를 자동으로 만들어 주면 좋지 않을까 항상 생각해 왔는데 이미 그런 것이 존재 했다. ToC라고 매번 저게 뭘까 궁금했었는데 Table of Contents라고 글 제목들을 추려 자동으로 목차를 만들어 주는 기능라고 한다.

여기저기 기웃 거리며 쉽게 가져다 쓸만한 것이 없나 살펴 보았지만 이것들 역시 블로그 스킨 처럼 뭔가 나에게 2% 부족했다. 그래서 나름 나도 개발자라는 호승심에 직접 만들어 보고자 한다.

jQuery(https://jquery.com)를 기본 바탕으로 만들 예정이다. 요즘 javascript가 많이 개선되고 필요한 다양한 기능들이 지원되서 더 이상 jQuery에 의존하지 않는 것이 대세인것 같은데 복잡한 사이트를 만들 것도 아니고 간단한 컨텐츠 하나 만드는 것에 불과하고 레퍼런스도 많아 별생각 없이 jQuery를 선택했다.

jQuery 사용자 정의 메소드 만들기

Q : $(selector).css(...) 처럼 jQuery 셀렉터로 엘리먼트를 찾은 뒤 내가 만든 함수를 적용하고 싶다.
A : jQuery.fn을 이용해 jQuery 객체에 내가 만든 사용자 정의 메소드를 추가하면 된다.

$.fn.table_of_contents = function() {
};

위에서는 jQuery.fn이라고 쓰고 코드에서는 $.fn이라고 적어서 혼동이 있을 수도 있어 설명을 덧붙인다. 결론을 먼저 말하자면 jQuery와 $는 완전히 동일하다. $는 jQuery 객체의 짧은 이름이라고 생각하면 된다. 다른 사람들이 만들어 놓은 코드를 보면 간략성을 위해 jQuery 대신 $를 주로 사용하는 것을 많이 볼 수 있다.

셀렉터를 사용하지 않는 $.get, $.post 같은 메소드의 경우는 'jQuery.fn.메소드이름'으로 만드는 것이 아니라 'jQuery.메소드이름'으로 만들어야 한다.

이제 부터 사용자 메소드 안에 ToC를 만들어줄 자바스크립트를 천천히 만들어 보자.

헤드라인(H1..H6) 태그들을 모아 인덱스 테이블 만들기

문서에서 H1 부터 H6까지 태그들을 읽어 인덱스 테이블을 구성 할 것이다.

let this_container = $(this);
let headlines = $(":header", this_container);

if(0 == headlines.length)
{
    return;
}
	
let top_level = headlines[0].tagName.replace(/[^\d]/g, "");
    • 2 라인 : 보통 $(selector) 형식으로 사용하는데 뒤에 selector가 하나 더 붙었다. 이것은 this_container(현재 select된 엘리먼트) 안에서만 헤드라인 태그들을 찾으라는 뜻이다. 
    • 9 라인 : 인덱스 테이블의 뎁스를 조절하기 위해 헤드라인 번호를 이용하기로 한다. /[^\d]/g 정규 표현식을 이용해 문자들을 제외한 숫자만 얻어 온다. 보통 가장 큰 제목은 가장 앞에 올 것이므로 제일 앞에 있는 헤드라인 태그의 번호를 기준으로 한다.
let toc_container = $(target_selector);
let tocHTML = "<ul>";
		
headlines.each(function(headline_index, headline) {
    var sub_level = headline.tagName.replace(/[^\d]/g, "");
    if (sub_level > top_level) { 
        for (let i = sub_level; i > top_level; i--) {
            tocHTML += "<ul>";
        }
    } 
    else if(sub_level < top_level) {
        for (let i = sub_level; i < top_level; i++) {
            tocHTML += "</ul>";
        }
    }
    
    top_level = sub_level;
    
    let headline_unique_id = "headline_unique_id_" + headline_index;
    let headlineElmt = $(headline);
    let headlineText = headlineElmt.text();
    
    headlineElmt.prop("id", headline_unique_id);
    tocHTML += "<li><"+headline.tagName + "><a href='#" + headline_unique_id + "'>" + headlineText + "</" +headline.tagName +"></a></li>";
});
		
tocHTML += "</ul>";
toc_container.append($(tocHTML));
  • 1 라인 : toc 테이블이 그려질 엘리먼트의 selector를 지정한다.
  • 6 라인 : 헤드라인 태그로 부터 레벨을 얻어 온다. 레벨이 크다면(하위 라는 의미) ul 태그를 추가하여 뎁스를 하나 증가하고 11라인 처럼 레벨이 작다면 뎁스를 감소 시킨다.
  • 23 라인 : ToC에서 목차를 클릭 했을 때 점프 할 문서의 로컬 링크를 위해 고유한 아이디를 만들고 헤드라인 태그에 아이디를 부여한다.
  • 24 라인 : 목차에 링크를 추가 한다.
  • 28 라인 : toc_container에 완성된 <ul>...</ul> 태그를 추가한다.

이렇게 (아직은) 누가봐도 아름답지 않은 ToC가 만들어졌다. 딱 제목들을 모아 목차를 생성하고 문서의 해당 부분으로 점프하는 기능까지 구현되었다. 하지만 심미적 관점은 둘째치더라도 너무 불.편.하.다. 링크를 따라 화면이 스크롤 되면 ToC도 같이 스크롤 되어 사라져버리기 때문에 다시 처음 위치로 돌아오지 않으면 무용지물이다. 그리고 ToC가 길어져 목차가 화면을 넘어가버리면 목차를 뒤지기 위해 스크롤을 해야 한다. 이러면 문서를 스크롤하는 것과 다를 것이 없다.

지금 보고 있는 제목이 ToC의 가운데 위치하게 하기

위에 언급한 문제들을 해결하기 위해 내가 보고 있는 문서의 현재 위치를 가리키는 제목이 ToC의 항상 가운데 오도록 기능을 추가해보자. 이 부분이 ToC를 만들면서 가장 까다로운 부분이었는데 자바스크립트의 좌표계에 익숙치 않아 뷰포트를 찾는데 많은 삽질을 했다. 본격적으로 코드를 보기 전에 javascript에서 윈도우 내의 좌표를 어떻게 얻을 수 있는지 알아 보자.

엘리먼트의 윈도우 상대 좌표 구하기

jQuery에서 엘리먼트 셀렉터를 offset(), position() 이라는 두 개의 현재 좌표를 돌려주는 함수를 사용할 수 있다.

  • offset() : 전체 문서에서 해당 엘리먼트의 위치
  • position() : position 속성을 가진 바로 위의 조상(부모가 아니다)으로 부터의 위치

 여기서 중요하게 보아야 할 것은 offset()인데 개념을 보면 대략 아래 그림과 같다

윈도우는 우리가 보는 브라우저의 위의 메뉴바와 아래 상태바를 제외한 순수한 뷰포트를 의미한다. 문제는 우리가 알고 있는것은 전체 문서에서의 상대 위치(offset) 뿐이고, 우리가 원하는 것은 뷰포트에서의 위치다. offset은 변하지 않지만 뷰포트(윈도우) 상에서 엘리먼트의 위치는 스크롤이나 화면 리사이징에 따라 계속 변한다. 자바스크립트에서는 윈도우 내의 엘리먼트 위치를 구하기 위해 scrollTop(), scrollLeft() 함수를 제공한다. 현재 뷰포트 내에서의 위치는 offset().top - scrollTop() 또는 offset().left - scrollLeft()로 구할 수 있다.

현재 위치 헤드라인 찾기

$(document).scroll(function() {
    headlines.each(function(index, headline) {
        let headline_id = $(headline).prop("id");
        let href = $("li a[href='#" + headline_id + "']");
        
        if(window.scrollY <= $(headline).offset().top && $(headline).offset().top <= window.scrollY + window.innerHeight) {
            href.attr("selected", "selected");
        }
    }
});

전체 코드

github : https://github.com/ChoiIngon/tistory-skin/blob/1c2dd89ea9755a45965e3d60804629c7879d4566/images/table_of_contents.js

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!