본문 바로가기
프로젝트

[JS]노션 기능을 간단히 따라 만들어보며

by ISA(류) 2022. 3. 7.

의외로 사람들이 노션 기능 구현 자체를 어려운 것으로 생각한다는 소리를 듣고 간단히 구현해보았다.

나도 노션은 잘 만든 소프트웨어라고 생각하고 잘 사용하고 있지만 사실 기능 자체를 구현하는데 어려운게 있을거라고 생각해본 적은 없었기에 그냥 바닐라 js로 간단히 생각대로 만들어보고 그 점에 대해서 간단히 정리해본다.(물론 기능이 아닌 노션의 UI/UX는 만들기 쉽지 않다.)

 

먼저 노션은 본질적으로 메모장처럼 데이터를 저장하고 불러오고 수정하는 기능을 지원해야한다. 노션 자체의 기본 틀은 여러 페이지의 관계로 구성 되어 있어서 간단하게 자료구조를 잡아보면 페이지 목록 과 그 페이지에 해당하는 컨텐츠 2가지로 나뉠 수 있다. 그리고 초기 로딩시 그 데이터를 불러오는 것과 데이터를 저장하는 부분이 필요하다.

 

    loadData() {
        const check = localStorage.getItem('rotions');
        if (check === null) this.initData();
        const { pages, views } = JSON.parse(localStorage.getItem('rotions'));
        this.pages = pages;
        this.views = views;
    }
    saveData(data) {
        localStorage.setItem('rotions', JSON.stringify(data));
    }
    initData() {
        const initState = {
            pages: [
                {
                    idx: 1,
                    title: '기본페이지'
                },
                {
                    idx: 2,
                    title: 'page1'
                }
            ],
            views: {
                '1': [
                    {
                        type: 'title',
                        text: '기본페이지'
                    },
                    {
                        type: 'h1',
                        text: '안녕하세요.'
                    }
                ],
                '2': [
                    {
                        type: 'title',
                        text: 'page1'
                    },
                ]
            }
        };
        this.saveData(initState);
    }

노션의 경우처럼 첫 join시 초기 더미 데이터를 넣어주어서 init하는 부분과 이후 save, load하는 부분이다. 저장소로는 벡엔드가 필요 없으면서 세션처럼 휘발되지 않는 로컬스토리지를 활용했다. 각 메서드들의 역할이 비교적으로 잘 분리 되어있다.

 

데이터(state, model)를 불러오는 것을 구현했다면 이제 데이터를 보여줄 기초적인 view 그러니까 레이아웃을 만들어 볼 차례다. 여기서는 내가 미리 만들어둔 만든 css in js 모듈(같은 사이드내에 있다.)을 활용했다.

    createContainer() {
        const container = this.styled.article`
            display: flex;
            flex-flow: column;
            flex: 1;
        `;
        container.id = "rotion-container";
        this.container = container;
        this.createArea();
        this.root.appendChild(container);
    }
    createArea() {
        this.createPageList();
        this.createContents();
    }

간단하게 컨테이너를 만들고 그 컨테이너를 pageList(페이지 목록)과  contents(페이지 내용물)로 area를 구분했다.

pageList는 아래의 페이지 목록이고, 페이지를 선택해서 라우팅 하는 것과 새로운 page를 추가하는 기능을 간단히 구현해줬다.

결과물

    createPageList() {
        const pageList = this.styled.ul`
            display: flex;
            width: 1000px;
            height: 58px;
            box-sizing: border-box;
            margin: 0;
            padding: 12px;
            overflow: hidden;
            overflow-x: auto;
            white-space:nowrap;
            &:hover {
                border-bottom: 1px solid green;
            }
        `;
        this.pageList = pageList;
        /**
         * @description 페이지 전환 및 페이지 추가 이벤트
         */
        pageList.addEventListener('click', (e) => {
            if (e.target.dataset.idx) this.renderContents(e.target.dataset.idx);
            if (e.target.dataset.add) {
                this.addPage();
            }
        });
        this.renderPageList();
        this.container.appendChild(pageList);
    }
    createContents() {
        const contents = this.styled.div`
            height: 100%;
        `;
        this.contents = contents;
        /**
         * @description 메모 내용 수정 & 새로운 단락 생성 & 삭제 이벤트
         */
        contents.addEventListener('keyup', (e) => {
            e.target.style.height = 'auto';
            e.target.style.height = `${e.target.scrollHeight}px`;

            const { page, idx } = e.target.dataset;
            if (e.key === 'Enter') {
                this.addTextLine(page, parseInt(idx), 'p');
                contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            } else {
                const state = contents.querySelector(`textarea[data-idx='${idx}']`).value;
                if (idx === '0') this.changeTitle(parseInt(page), state);
                if (idx !== '0' && e.key === 'Backspace' && this.views[page][idx].text.length === 0) {
                    this.removeTextLine(page, parseInt(idx));
                    contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
                } else {
                    this.inputText(page, idx, state);
                }
            }
        });
        /**
         * @description 같은 페이지내 방향키을 이용한 이동 이벤트
         */
        contents.addEventListener('keyup', (e) => {
            const { page, idx } = e.target.dataset;
            const cursor = e.target.selectionStart;
            if (e.key === 'ArrowUp' && idx !== '0') {
                if (cursor === 0) contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
            } else if (e.key === 'ArrowLeft' && idx !== '0') {
                if (cursor === 0) contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
            } else if (e.key === 'ArrowDown' && this.views[page].length - 1 > parseInt(idx)) {
                if (cursor === this.views[page][idx].text.length) contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            } else if (e.key === 'ArrowRight' && this.views[page].length - 1 > parseInt(idx)) {
                if (cursor === this.views[page][idx].text.length) contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            }
        })
        this.renderContents(this.pages[0].idx);
        this.container.appendChild(contents);
    }

페이지 목록에는 이벤트 위임을 통해서 ul에 각 li의 dataset를 살펴서 원하는 값이 있는지 확인하고 이벤트를 실행시켜준다. 컨텐츠의 경우 노션의 경우 페이지내에서 방향키나 엔터 백스페이스를 통해서 라인을 추가하고 삭제하고 이동하는게 가능한데 해당 이벤트들 역시 이벤트 위임 방식으로 컨텐츠에서 각 라인의 dataset 등을 확인하고 이벤트를 조작해준다.

 

    renderPageList() {
        this.pageList.innerText = '';
        this.pages.forEach((item) => {
            const page = makeItem.call(this);
            page.innerText = item.title;
            page.setAttribute('data-idx', item.idx);
            this.pageList.appendChild(page);
        });

        const addPage = makeItem.call(this);
        addPage.innerText = 'Add Page';
        addPage.setAttribute('data-add', true);
        this.pageList.appendChild(addPage);

        function makeItem() {
            return this.styled.li`
                display: inline-block;
                margin-right: 12px;
                color: #8f8f8f;
                &:hover {
                    color: cyan;
                }
            `;
        }
    }
    renderContents(page) {
        this.contents.innerText = '';
        this.views[page]?.forEach((item, idx) => {
            const textBox = this.styled.p`
                position: relative;
                width: 98%;
                margin-top: 12px;
                padding: 0 8px;
                &:hover button {
                    opacity: 0.8;
                }
            `;
            textBox.draggable = 'true';
            textBox.setAttribute('data-page', page);
            textBox.setAttribute('data-idx', idx);
            textBox.setAttribute('data-drag', true);
            const addLine = this.styled.button`
                float: left;
                background: none;
                border: none;
                opacity: 0;
            `;
            addLine.innerText = 'A';
            addLine.setAttribute('data-page', page);
            addLine.setAttribute('data-idx', idx);
            addLine.addEventListener('click', (e) => {
                const { page, idx } = e.target.dataset;
                this.renderAddLineType(textBox, (type) => this.addTextLine(page, parseInt(idx), type));
            });
            textBox.appendChild(addLine);

            const handle = this.styled.button`
                float: left;
                background: none;
                border: none;
                opacity: 0;
            `;
            handle.innerText = 'H';
            handle.setAttribute('data-page', page);
            handle.setAttribute('data-idx', idx);
            textBox.appendChild(handle);
            this.dragTextLine(textBox, handle);

            const text = this.styled.textarea`
                width: 95%;
                padding: 0;
                background: none;
                border: none;
                border-bottom: 1px solid #4d4a4a3d;
                outline: none;
                resize: none;
                overflow: hidden;
                ${this.getTypeStyle(item.type)}
                &::placeholder {
                    opacity: 0;
                }
                &:focus::placeholder{
                    opacity: 1;
                }
            `;
            text.value = item.text;
            text.placeholder = idx === 0 ?
                'Untitled' :
                'Type Memo';
            text.setAttribute('data-page', page);
            text.setAttribute('data-idx', idx);
            textBox.appendChild(text);

            this.contents.appendChild(textBox);
        });
    }

컨텐츠의 각 라인의 경우 노션을 모방하려면 글자 입력이 가능한 node를 사용해야하는데 input의 경우 글자수가 늘어남에 따라서 줄바꿈이 되지 않는 점 때문에 textarea tag를 활용했다. 기본 스타일을 설정하고 각 라인 타입(h1,h2,등)에 따라서 커스텀 style를 추가하는 방식으로 노션의 페이지 요소들 대부분을 해당 방식을 응용해서 만들 수 있다. 그외 타이틀을 연동하는 기능과 placeholder를 통해서 타이틀이나 type / command 같은 문구를 보여주는 방식을 모방했다.

사실상 위지윅 에디터에서 사용자 입력을 textArea에 넣어주고 보여주는건 div같은 걸로 보여주는 것보다 한단계 더 직접적으로 입력하는 정도라고 생각한다.

 

핸들과 라인 type들

간단히 구현해본 것이라 디테일을 신경쓰지 않아서 허접한데 마우스를 hover할 시 텍스트 라인 옆에 A 와 H 라는 요소가 노출된다. 해당 H요소의 경우 handle으로 title을 제외한 내부 모든 라인을 붙잡고 드래그 해서 서로 순서를 변경 할 수 있다. 또 A는 addline으로 클릭시 스크린샷과 같이 추가 가능한 h1,h2,p등 type목록을 보여준다.

해당 타입을 선택하면 원하는 type의 line을 추가 할 수 있다.

 

    renderAddLineType(textBox, addLine) {
        const typeBox = this.styled.ul`
            position: absolute;
            top: 0;
            margin: 0;
            padding: 8px;
            list-style: none;
            z-index: 999999;
            background: lightblue;
        `;
        typeBox.addEventListener('click', (e) => {
            const { type } = e.target.dataset;
            if (type) {
                addLine(type);
            }
        });
        this.types.forEach((type) => {
            const item = this.styled.li`
            `
            item.innerText = type;
            item.setAttribute('data-type', type);

            typeBox.appendChild(item);
        });

        textBox.appendChild(typeBox);
    }
    dragTextLine(textBox, handle) {
        textBox.addEventListener("dragstart", (e) => {
            handle.style.opacity = 0;
        });
        textBox.addEventListener("dragend", (e) => {
            const { page, idx } = e.target.dataset;
            if (this.drag.page === page && this.drag.idx !== idx) {
                if (this.drag.idx !== '0' && idx !== '0') this.changeTextLine(page, this.drag.idx, idx);
            }
            this.drag = {
                page: 0,
                idx: 0
            };
            handle.style.opacity = '';
        });

        /* 드롭 대상에서 이벤트 발생 */
        textBox.addEventListener("dragover", (e) => {
            const { page, idx } = e.target.dataset;
            this.drag = {
                page,
                idx
            };
            if (e.target.dataset.drag) e.target.style.borderBottom = "5px solid red";
        });
        textBox.addEventListener("dragleave", (e) => {
            // 요소를 드래그하여 드롭하려던 대상으로부터 벗어났을 때 배경색 리셋
            if (e.target.dataset.drag) e.target.style.borderBottom = "";
        });
    }
    /**
     * @description 각 라인 타입에 따른 커스텀 스타일
     * @param {string} type 
     * @returns string css
     */
    getTypeStyle(type) {
        const styles = {
            title: `
                font-size: 2.5rem;
                font-weight: bold;
                &::placeholder {
                    opacity: 1 !important;
                }
            `,
            h1: `
                font-size: 1.8rem;
                font-weight: bold;
            `,
            h2: `
                font-size: 1.5rem;
                font-weight: bold;
            `,
            p: `
                font-size: 1rem;
            `,
        }

        return styles[type];
    }

드래그 이벤트의 경우 전역 state를 이용해서 드래그 되는 대상과 드래그 over되는 대상을 특정해서 서로 스위칭 시켜주는 방식으로 구현했다. 커스텀style의 경우 개인적으로 css in js을 만들어서 적용했기에 css 문자열을 반환하고 그를 그대로 css에 추가하는 방식으로 간단히 구현 해줬다.

 

아래는 전체 코드와 결과물 링크다. 전체 코드는 아래와 같고 현재는 416라인 정도 된다.

class Rotion {
    constructor(root, styled) {
        this.root = root;
        this.styled = styled;
        this.pages = [];
        this.views = {};
        this.loadData();
        this.container = '';
        this.pageList = '';
        this.contents = '';
        this.createContainer();
        this.drag = {
            page: 0,
            idx: 0,
        }
        this.types = [
            'h1',
            'h2',
            'p'
        ];
    }
    /**
     * @description 노션을 구성하는 페이지 리스트 & 메모 레이아웃 DOM 구성
     */
    createContainer() {
        const container = this.styled.article`
            display: flex;
            flex-flow: column;
            flex: 1;
        `;
        container.id = "rotion-container";
        this.container = container;
        this.createArea();
        this.root.appendChild(container);
    }
    createArea() {
        this.createPageList();
        this.createContents();
    }
    createPageList() {
        const pageList = this.styled.ul`
            display: flex;
            width: 1000px;
            height: 58px;
            box-sizing: border-box;
            margin: 0;
            padding: 12px;
            overflow: hidden;
            overflow-x: auto;
            white-space:nowrap;
            &:hover {
                border-bottom: 1px solid green;
            }
        `;
        this.pageList = pageList;
        /**
         * @description 페이지 전환 및 페이지 추가 이벤트
         */
        pageList.addEventListener('click', (e) => {
            if (e.target.dataset.idx) this.renderContents(e.target.dataset.idx);
            if (e.target.dataset.add) {
                this.addPage();
            }
        });
        this.renderPageList();
        this.container.appendChild(pageList);
    }
    createContents() {
        const contents = this.styled.div`
            height: 100%;
        `;
        this.contents = contents;
        /**
         * @description 메모 내용 수정 & 새로운 단락 생성 & 삭제 이벤트
         */
        contents.addEventListener('keyup', (e) => {
            e.target.style.height = 'auto';
            e.target.style.height = `${e.target.scrollHeight}px`;

            const { page, idx } = e.target.dataset;
            if (e.key === 'Enter') {
                this.addTextLine(page, parseInt(idx), 'p');
                contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            } else {
                const state = contents.querySelector(`textarea[data-idx='${idx}']`).value;
                if (idx === '0') this.changeTitle(parseInt(page), state);
                if (idx !== '0' && e.key === 'Backspace' && this.views[page][idx].text.length === 0) {
                    this.removeTextLine(page, parseInt(idx));
                    contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
                } else {
                    this.inputText(page, idx, state);
                }
            }
        });
        /**
         * @description 같은 페이지내 방향키을 이용한 이동 이벤트
         */
        contents.addEventListener('keyup', (e) => {
            const { page, idx } = e.target.dataset;
            const cursor = e.target.selectionStart;
            if (e.key === 'ArrowUp' && idx !== '0') {
                if (cursor === 0) contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
            } else if (e.key === 'ArrowLeft' && idx !== '0') {
                if (cursor === 0) contents.querySelector(`textarea[data-idx='${parseInt(idx) - 1}']`).focus();
            } else if (e.key === 'ArrowDown' && this.views[page].length - 1 > parseInt(idx)) {
                if (cursor === this.views[page][idx].text.length) contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            } else if (e.key === 'ArrowRight' && this.views[page].length - 1 > parseInt(idx)) {
                if (cursor === this.views[page][idx].text.length) contents.querySelector(`textarea[data-idx='${parseInt(idx) + 1}']`).focus();
            }
        })
        this.renderContents(this.pages[0].idx);
        this.container.appendChild(contents);
    }
    /** 
     * @description 페이지 목록 & 해당 페이지 메모 렌더링
     */
    renderPageList() {
        this.pageList.innerText = '';
        this.pages.forEach((item) => {
            const page = makeItem.call(this);
            page.innerText = item.title;
            page.setAttribute('data-idx', item.idx);
            this.pageList.appendChild(page);
        });

        const addPage = makeItem.call(this);
        addPage.innerText = 'Add Page';
        addPage.setAttribute('data-add', true);
        this.pageList.appendChild(addPage);

        function makeItem() {
            return this.styled.li`
                display: inline-block;
                margin-right: 12px;
                color: #8f8f8f;
                &:hover {
                    color: cyan;
                }
            `;
        }
    }
    renderContents(page) {
        this.contents.innerText = '';
        this.views[page]?.forEach((item, idx) => {
            const textBox = this.styled.p`
                position: relative;
                width: 98%;
                margin-top: 12px;
                padding: 0 8px;
                &:hover button {
                    opacity: 0.8;
                }
            `;
            textBox.draggable = 'true';
            textBox.setAttribute('data-page', page);
            textBox.setAttribute('data-idx', idx);
            textBox.setAttribute('data-drag', true);
            const addLine = this.styled.button`
                float: left;
                background: none;
                border: none;
                opacity: 0;
            `;
            addLine.innerText = 'A';
            addLine.setAttribute('data-page', page);
            addLine.setAttribute('data-idx', idx);
            addLine.addEventListener('click', (e) => {
                const { page, idx } = e.target.dataset;
                this.renderAddLineType(textBox, (type) => this.addTextLine(page, parseInt(idx), type));
            });
            textBox.appendChild(addLine);

            const handle = this.styled.button`
                float: left;
                background: none;
                border: none;
                opacity: 0;
            `;
            handle.innerText = 'H';
            handle.setAttribute('data-page', page);
            handle.setAttribute('data-idx', idx);
            textBox.appendChild(handle);
            this.dragTextLine(textBox, handle);

            const text = this.styled.textarea`
                width: 95%;
                padding: 0;
                background: none;
                border: none;
                border-bottom: 1px solid #4d4a4a3d;
                outline: none;
                resize: none;
                overflow: hidden;
                ${this.getTypeStyle(item.type)}
                &::placeholder {
                    opacity: 0;
                }
                &:focus::placeholder{
                    opacity: 1;
                }
            `;
            text.value = item.text;
            text.placeholder = idx === 0 ?
                'Untitled' :
                'Type Memo';
            text.setAttribute('data-page', page);
            text.setAttribute('data-idx', idx);
            textBox.appendChild(text);

            this.contents.appendChild(textBox);
        });
    }
    renderAddLineType(textBox, addLine) {
        const typeBox = this.styled.ul`
            position: absolute;
            top: 0;
            margin: 0;
            padding: 8px;
            list-style: none;
            z-index: 999999;
            background: lightblue;
        `;
        typeBox.addEventListener('click', (e) => {
            const { type } = e.target.dataset;
            if (type) {
                addLine(type);
            }
        });
        this.types.forEach((type) => {
            const item = this.styled.li`
            `
            item.innerText = type;
            item.setAttribute('data-type', type);

            typeBox.appendChild(item);
        });

        textBox.appendChild(typeBox);
    }
    dragTextLine(textBox, handle) {
        textBox.addEventListener("dragstart", (e) => {
            handle.style.opacity = 0;
        });
        textBox.addEventListener("dragend", (e) => {
            const { page, idx } = e.target.dataset;
            if (this.drag.page === page && this.drag.idx !== idx) {
                if (this.drag.idx !== '0' && idx !== '0') this.changeTextLine(page, this.drag.idx, idx);
            }
            this.drag = {
                page: 0,
                idx: 0
            };
            handle.style.opacity = '';
        });

        /* 드롭 대상에서 이벤트 발생 */
        textBox.addEventListener("dragover", (e) => {
            const { page, idx } = e.target.dataset;
            this.drag = {
                page,
                idx
            };
            if (e.target.dataset.drag) e.target.style.borderBottom = "5px solid red";
        });
        textBox.addEventListener("dragleave", (e) => {
            // 요소를 드래그하여 드롭하려던 대상으로부터 벗어났을 때 배경색 리셋
            if (e.target.dataset.drag) e.target.style.borderBottom = "";
        });
    }
    /**
     * @description 각 라인 타입에 따른 커스텀 스타일
     * @param {string} type 
     * @returns string css
     */
    getTypeStyle(type) {
        const styles = {
            title: `
                font-size: 2.5rem;
                font-weight: bold;
                &::placeholder {
                    opacity: 1 !important;
                }
            `,
            h1: `
                font-size: 1.8rem;
                font-weight: bold;
            `,
            h2: `
                font-size: 1.5rem;
                font-weight: bold;
            `,
            p: `
                font-size: 1rem;
            `,
        }

        return styles[type];
    }
    /**
     * @description 노션 데이터 처리 로직
     */
    addPage() {
        const idx = this.pages[this.pages.length - 1].idx + 1;
        this.pages = [
            ...this.pages,
            {
                idx: idx,
                title: 'Untitled'
            }
        ]
        this.views[idx] = [
            {
                type: 'title',
                text: ''
            },
        ];
        this.saveData({
            pages: this.pages,
            views: this.views
        });
        this.renderPageList();
    }
    addTextLine(page, idx, type) {
        this.views[page] = [
            ...this.views[page].slice(0, idx + 1),
            {
                type: type,
                text: '',
            },
            ...this.views[page].slice(idx + 1, this.views[page].length)
        ];
        this.saveData({
            pages: this.pages,
            views: this.views
        });
        this.renderContents(page);
    }
    removeTextLine(page, idx) {
        this.views[page] = [
            ...this.views[page].slice(0, idx),
            ...this.views[page].slice(idx + 1, this.views[page].length)
        ];
        this.saveData({
            pages: this.pages,
            views: this.views
        });
        this.renderContents(page);
    }
    changeTextLine(page, ldx, rdx) {
        const state = this.views[page][ldx];
        this.views[page][ldx] = this.views[page][rdx];
        this.views[page][rdx] = state;
        this.saveData({
            pages: this.pages,
            views: this.views
        });
        this.renderContents(page);
    }
    inputText(page, idx, state) {
        this.views[page][idx].text = state;
        this.saveData({
            pages: this.pages,
            views: this.views
        });
    }
    changeTitle(idx, state) {
        const pageIndex = this.pages.findIndex((page) => page.idx === idx);
        this.pages[pageIndex].title = state.length > 0 ?
            state :
            'Untitled';
        this.renderPageList();
    }
    loadData() {
        const check = localStorage.getItem('rotions');
        if (check === null) this.initData();
        const { pages, views } = JSON.parse(localStorage.getItem('rotions'));
        this.pages = pages;
        this.views = views;
    }
    saveData(data) {
        localStorage.setItem('rotions', JSON.stringify(data));
    }
    initData() {
        const initState = {
            pages: [
                {
                    idx: 1,
                    title: '기본페이지'
                },
                {
                    idx: 2,
                    title: 'page1'
                }
            ],
            views: {
                '1': [
                    {
                        type: 'title',
                        text: '기본페이지'
                    },
                    {
                        type: 'h1',
                        text: '안녕하세요.'
                    }
                ],
                '2': [
                    {
                        type: 'title',
                        text: 'page1'
                    },
                ]
            }
        };
        this.saveData(initState);
    }
}

결과물 관련 링크

https://yoonjonglyu.github.io/js-util/rotion/

 

Rotion - 메모

 

yoonjonglyu.github.io

https://github.com/yoonjonglyu/js-util

 

GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

html, css, js, vanillajs, animation, module & util - GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

github.com

 

반응형