웹 워드프로세서 기초 만들어 보기(2)


웹 워드프로세서(이하 웹 워드)는 브라우저만 있으면 어디서나 문서 편집을 할 수 있다는 장점이 있어 매력적인 소프트웨어이다. 아직 네이티브 워드프로세서로 할 수 있는 모든 기능을 다 지원하는 것은 아니지만 가까운 미래에는 가능하리라고 믿는다. 2년 동안 웹 워드를 만드는 동안 가능성을 믿고 험난한 과정을 헤쳐 나갔고, 기능을 구현해 나갈 때마다 느꼈던 흥분과 희열은 아직도 강하게 남아있다. 이 글을 읽는 여러분 또한 그런 느낌을 받게 되기를 소망한다.

지난 글에는 웹 워드의 분류 기준과 쪽 표현의 필요성 및 복잡성, contentEditable, 그리고 HTML에서 쪽을 표현하고 레이아웃을 하는 원리에 관해서 설명하였다. 이번 글에는 이어서 실제 코드를 보면서 간단한 쪽 표현과 편집 기능을 구현하는 방법에 대해 나눈다.

구현 요구 사항

지난 글에서 언급했듯이 쪽 표현 기능의 간단한 요구 사항은 아래와 같다.

  • 문단이 쪽 사이에 걸칠 경우 문단을 줄 단위로 나누어 이전과 다음 쪽에 표현할 수 있다.
  • 글자 입력/삭제를 하면서 실시간으로 쪽 표현이 된다.

표의 경우 더 많은 구현과 고려가 필요하다. 여기서 내용은 텍스트를 기준으로 작성한다. CSS로 보면 display: inline으로 처리되는 엘리먼트가 레이아웃 대상이다.

자, 이제 시작해볼까

지난 글에서 쪽을 레이아웃하기 위한 각 단계를 설명하였다. 이 과정은 아래와 같은 순서로 이루어진다.

각 단계를 실제로 코드에서 구현해 보자.

쪽 사이에 걸쳐진 문단을 나누는 단계

쪽은 쪽 여백을 가지고 있고 쪽 여백을 제외한 영역부터 글자를 입력할 수 있다. 그러므로 실제로 내용이 쪽에 들어갈 곳은 쪽 여백을 제외한 영역이다. 아래 코드에서부터 class="page-body"를 가지는 엘리먼트이며 pageBodyElement라고 부르겠다.

A4 용지의 종이 크기는 210mm X 297mm인데, 글에서 그림으로 표현하기에는 다소 크므로 편의상 크기를 150mm X 80mm로 한다.

아래와 같이 쪽을 표현하기 위한 HTML을 작성한다.

<div
  style="padding: 10mm; background-color: rgb(245, 245, 245); width: calc(170mm); height: calc(110mm);"
>
  <div
    data-page-number="1"
    style="padding: 20mm 10mm; margin: 0px 0px 10mm; width: 150mm; height: 70mm; background-color: rgb(255, 255, 255);"
  >
    <div
      class="page-body"
      contenteditable="true"
      style="outline: 0px; height: 100%; border: 1px dashed black;"
    >
      <p><br /></p>
    </div>
  </div>
</div>

pageBodyElement 설정

pageBodyElementcontentEditable="true"를 설정하여 편집이 가능한 상태로 만들었고 style="outline: 0px;"으로 편집 상태가 되었을 때 표시되는 테두리를 제거하였다. 내부에는 문단을 표현하기 위해서는 p태그를 사용하였고 빈 문단인 경우 커서 표시를 위하여 bogus(br태그)를 추가하였다.

가장 단순한 형태의 문서 편집기가 만들어졌다.

2018-02-18 23_10_39

쪽에서 넘치는 문단을 다음 쪽으로 넘겨보자

이 상태에서 내용을 많이 넣은 경우 아래 그림처럼 내용이 넘쳐 나오게 된다. image

쪽 표현을 하는 워드 프로세서가 아니라면 단순히 overflow-y: hidden이나 overflow-y: scroll을 지정하면 되겠지만, 우리의 목표는 그것이 아니므로 한 단계 더 발전시켜보자.

가장 간단하게 설명하자면 1쪽에서 쪽보다 큰 값의 bottom을 가지는 모든 문단을 찾아 다음 쪽으로 옮기는 것이다.

첫 번째로 할 일은 넘치는 문단이 있는지를 찾는 것(_findExceedParagraph())부터이다.

/**
 * Find a first exceed paragraph
 * @param {HTMLElement} pageBodyElement - page body element
 * @param {number} pageBodyBottom - page bottom
 * @returns {HtmlElement} a first exceed paragraph
 */
_findExceedParagraph(pageBodyElement, pageBodyBottom) {
    const paragraphs = pageBodyElement.querySelectorAll('p');
    const {length} = paragraphs;

    for (let i = 0; i < length; i += 1) {
        const paragraph = paragraphs[i];
        const paragraphBottom = this._getBottom(paragraph);
        if (pageBodyBottom < paragraphBottom) {
            return paragraph;
        }
    }

    return null;
}

코드를 보면 알겠지만 여기서는 단순히 p태그만을 문단으로 처리하고 있다. 블럭-레벨 엘리먼트의 종류는 더 많으므로 MDN을 참고하여 추가하자.

두 번째로 할 일은 넘치는 모든 문단을 찾아서(_getExceedAllParagraphs()) 다음 쪽으로 옮기는 것(_insertParagraphsToBodyAtFirst())이다.

/**
 * Get all exceed paragraphs
 * @param {HTMLElement} pageBodyElement - page body element
 * @param {number} pageBodyBottom - page bottom
 * @returns {Array.<HTMLElement>} all exceed paragraph array
 */
_getExceedAllParagraphs(pageBodyElement, pageBodyBottom) {
    const paragraphs = pageBodyElement.querySelectorAll('p');
    const {length} = paragraphs;
    const exceedParagraphs = [];

    for (let i = 0; i < length; i += 1) {
        const paragraph = paragraphs[i];
        const paragraphBottom = this._getBottom(paragraph);
        if (pageBodyBottom < paragraphBottom) {
            exceedParagraphs.push(paragraph);
        }
    }

    // Remain a bigger paragraph than page height.
    if (paragraphs.length === exceedParagraphs.length) {
        exceedParagraphs.shift();
    }

    return exceedParagraphs;
}

여기서 한 가지 주의할 것은 _getExceedAllParagraphs()함수 내에서 하나의 문단이 쪽 높이보다 큰 경우를 처리하는 코드이다.

// Remain a bigger paragraph than page height.
if (paragraphs.length === exceedParagraphs.length) {
  exceedParagraphs.shift();
}

첫 번째 문단이 쪽 높이보다 큰 문단일 경우 발생하게 되는데 레이아웃 흐름에서 이 처리를 하지 않으면 무한 쪽 생성을 맛보게 될 것이다. 문단이 쪽보다 높은 것을 그대로 둘 경우 아래와 그림과 같이 여전히 문단이 넘쳐 나오게 되는데, 이는 문단을 줄로 나누는 단계에서 해결할 것이다. 실전에서는 큰 크기의 이미지를 포함하는 문단, 높이가 높은 표에서 발생하는 경우가 많은데, 좀 더 고난도의 레이아웃 처리가 필요하다.

/**
 * Insert paragraphs to body at first
 * @param {HTMLElement} pageBodyElement - page body element
 * @param {Array.<HTMLElement>} paragraphs - paragraph array
 */
_insertParagraphsToBodyAtFirst(pageBodyElement, paragraphs) {
    if (pageBodyElement.firstChild) {
        // merge split paragraphs before.
        paragraphs.slice().reverse().forEach(paragraph => {
            const splitParagraphId = paragraph.getAttribute(SPLIT_PARAGRAPH_ID);
            let appended = false;
            if (splitParagraphId) {
                const nextParagraph = pageBodyElement.querySelector(`[${SPLIT_PARAGRAPH_ID}="${splitParagraphId}"]`);
                if (nextParagraph) {
                    const {firstChild} = nextParagraph;
                    paragraph.childNodes.forEach(
                        node => nextParagraph.insertBefore(node, firstChild)
                    );

                    paragraph.parentElement.removeChild(paragraph);
                    appended = true;
                }
            }

            if (!appended) {
                pageBodyElement.insertBefore(paragraph, pageBodyElement.firstChild);
            }
        });
    } else {
        paragraphs.forEach(
            paragraph => pageBodyElement.appendChild(paragraph)
        );
    }
}

_insertParagraphsToBodyAtFirst()는 넘치는 모든 문단을 다음 쪽으로 옮기는 것인데, 다음 쪽이 빈 경우는 pageBodyElement에 간단히 문단을 추가하면 된다. 쪽이 비어 있지 않다면 제일 처음에 문단을 삽입하게 된다. 이때 이전에 분리된 적이 있는 같은 문단은 합치는 과정을 거쳐야 원래 문단이 두 문단으로 보이는 일이 없다.

하나의 쪽을 레이아웃하게 되면 쪽의 개수가 늘어나게 되고 늘어난 쪽을 대상으로 마지막 쪽까지 레이아웃을 진행해야 한다. 전체 쪽 레이아웃의 코드를 살펴보자.

/**
 * Layout pages
 */
async _layout() {
    let pageNumber = 1;
    while (pageNumber <= this.pageBodyElements.length) {
        pageNumber = await this._layoutPage(pageNumber);
    }
}

_layout()은 1쪽부터 마지막 쪽까지 쪽 레이아웃을 수행한다. _layoutPage()은 위에서 설명한 함수를 사용하여 지정된 쪽을 레이아웃하게 된다.

/**
 * Layout a page and return next page number
 * @param {number} pageNumber - page number
 * @returns {Promise} promise
 */
_layoutPage(pageNumber = 1) {
    const promise = new Promise((resolve, reject) => {
        const pageIndex = pageNumber - 1;
        const totalPageCount = this.pageBodyElements.length;
        if (pageNumber > totalPageCount || pageNumber > 100) {
            reject(pageNumber + 1);
        }

        const pageBodyElement = this.pageBodyElements[pageIndex];
        const pageBodyBottom = this._getBottom(pageBodyElement);
        const exceedParagraph = this._findExceedParagraph(pageBodyElement, pageBodyBottom);
        const insertBodyParagraph = false;
        let allExceedParagraphs, nextPageBodyElement;

        if (exceedParagraph) {
            allExceedParagraphs = this._getExceedAllParagraphs(pageBodyElement, pageBodyBottom);
            if (pageNumber >= totalPageCount) {
                this._appendPage(insertBodyParagraph);
            }

            nextPageBodyElement = this.pageBodyElements[pageIndex + 1];
            this._insertParagraphsToBodyAtFirst(nextPageBodyElement, allExceedParagraphs);
        }

        resolve(pageNumber + 1);
    });

    return promise;
}

전체 레이아웃이 되는 그림이다. 쪽 레이아웃이 되는 것을 보기 위하여 약간의 딜레이를 주었다.

2018-02-19 00_44_29

문단을 줄로 나누는 단계

이제 쪽 높이보다 높은 문단을 처리할 시간이다. 그림으로 보면 이와 같은 상태이다. image

마지막 1줄이 넘쳐있는 것을 볼 수가 있을 것이다. 이전에 언급한 것처럼 Text 노드만으로는 글자의 좌표를 파악할 수 없으므로 모든 글자을 span 태그로 감싸주어 좌표를 알아내야 한다. 여기서 핵심은 두 가지이다. 문단 내의 줄을 인식하는 것쪽을 넘어서는 줄부터 문단을 분리하는 것이다. 코드를 살펴보자.

/**
 * Layout a page and return next page number
 * @param {number} pageNumber - page number
 * @returns {Promise} promise
 */
_layoutPage(pageNumber = 1) {
....
        let allExceedParagraphs, nextPageBodyElement;

        if (exceedParagraph) {
            this._splitParagraph(exceedParagraph, pageBodyBottom);

            allExceedParagraphs = this._getExceedAllParagraphs(pageBodyElement, pageBodyBottom);
....
}

_splitParagraph()가 추가 되었다. 넘치는 문단이 있는 경우 넘치는 줄부터 문단을 둘로 분리하는 것이다. 그 후에 수행되는 _getExceedAllParagraphs()에서 분리된 분단도 모두 수집되어 다음 쪽으로 옮겨지게 된다.

/**
 * Split a paragraph to two paragraphs
 * @param {HTMLElement} paragraph - paragraph element
 */
_splitParagraph(paragraph) {
    const textNodes = [];
    const treeWalker = document.createTreeWalker(paragraph);
    while (treeWalker.nextNode()) {
        const node = treeWalker.currentNode;
        if (node.nodeType === Node.TEXT_NODE) {
            textNodes.push(node);
        }
    }

    // wrap text nodes with span
    textNodes.forEach(textNode => {
        const texts = textNode.textContent.split('');
        texts.forEach((chararcter, index) => {
            const span = document.createElement('span');
            span.innerText = chararcter;
            wrappers.push(span);

            textNode.parentElement.insertBefore(span, textNode);

            // for keeping the cursor
            if (range
                && range.startContainer === textNode
                && range.startOffset === index) {
                range.setStartBefore(span);
                range.setEndBefore(span);
            }
        });

        textNode.parentElement.removeChild(textNode);
    });
}

텍스트를 span으로 감싼다는 것의 이해를 돕기 위하여 글자마다 붉은색으로 표시하였다. (실제로는 border 표시를 하지 않아야 문단의 모양이 틀어지지 않는다.)

image

그리고 여기서 눈여겨보아야 할 부분이 커서 유지다. 커서가 collapsed인 경우를 가정했지만, 현재 커서를 유지하기 위해 반드시 처리해야 할 부분이다. (사실 이 부분만 해도 정교하게 구현하려면 상당한 노력이 필요하다.)

// for keeping the cursor
if (range
    && range.startContainer === textNode
    && range.startOffset === index) {
    range.setStartBefore(span);
    range.setEndBefore(span);
}
...
...

// keep the cursor
if (range) {
    selection.removeAllRanges();
    selection.addRange(range);
}

문단 내에서 줄을 인식하는 단계이다.

// recognize lines
let prevSpan;
wrappers.forEach(span => {
  const prevSpanBottom = prevSpan ? prevSpan.getBoundingClientRect().bottom : 0;
  const spanTop = span.getBoundingClientRect().top;
  if (prevSpanBottom < spanTop) {
    lines.push(span);
  }
  prevSpan = span;
});

문단에서 쪽을 넘는 줄을 찾는 단계이다.

// find a exceed first line
let nextParagraphCharacters = [];
const { length } = lines;
for (let i = 0; i < length; i += 1) {
  const line = lines[i];
  const lineBottom = this._getBottom(line);
  if (lineBottom > pageBodyBottom) {
    const splitIndex = wrappers.indexOf(line);
    nextParagraphCharacters = wrappers.slice(splitIndex);
    break;
  }
}

넘치는 줄을 기준으로 문단을 둘로 나누는 단계이다. 문단을 나눌 때 나중에 합칠 수 있도록 아이디를 저장해 둔다.

// split the paragraph to two paragraphs
const extractRange = document.createRange();
extractRange.setStartBefore(nextParagraphCharacters[0]);
extractRange.setEndAfter(
  nextParagraphCharacters[nextParagraphCharacters.length - 1]
);

const fragment = extractRange.extractContents();
const nextParagraph = paragraph.cloneNode();
nextParagraph.innerHTML = "";

nextParagraph.appendChild(fragment);
paragraph.parentElement.insertBefore(nextParagraph, paragraph.nextSibling);

if (!paragraph.hasAttribute(SPLIT_PARAGRAPH_ID)) {
  paragraph.setAttribute(SPLIT_PARAGRAPH_ID, this.splitParagraphId);
  nextParagraph.setAttribute(SPLIT_PARAGRAPH_ID, this.splitParagraphId);
  this.splitParagraphId += 1;
}

감쌌던 span태그를 제거한 후 커서를 유지시키고, 분리된 텍스트를 normalize() 한다.

// unwrap text nodes
wrappers.forEach(span => {
  if (span.parentElement) {
    const textNode = span.firstChild;
    span.removeChild(textNode);
    span.parentElement.insertBefore(textNode, span);
    span.parentElement.removeChild(span);
  }
});

// keep the cursor
if (range) {
  selection.removeAllRanges();
  selection.addRange(range);
}

paragraph.normalize();
nextParagraph.normalize();

이제 문단이 쪽 사이에 걸칠 경우 문단을 줄 단위로 나누어 이전과 다음 쪽에 표현할 수 있다.

Before image After image

쪽 레이아웃 수행이 필요한 이벤트 처리

여기에서는 간단하게 keyup 이벤틀 글자가 입력되면 1쪽부터 레이아웃이 수행되도록 처리하였다. 이외에도 복사 붙여 넣기, 삭제 등의 이벤트 처리도 필요하다.

/**
 * Add event listners to layout pages
 */
_addEventListener() {
    document.addEventListener('keyup', event => {
        if (event.target.isContentEditable) {
            this._layout();
        }
    });
}

글자 입력 중 쪽 레이아웃 2018-02-19 05_21_17

마치며

웹 워드를 만들기 위해서 필수적인 쪽 표현 기능을 구현하는 방법과 코드를 소개하였다. 이 글만 보고 웹 워드 프로젝트를 시작하겠다고 덤벼들기보다는 좀 더 신중하게 접근하기를 권한다. 왜냐하면, 이 외에도 고려할 사항은 매우 많기 때문이다.

  • 더 많은 종류의 블록-레벨 요소의 추가
  • 블록-레벨 요소가 트리에서 depth가 깊은 곳에 있는 경우 처리
  • 그 경우 문단을 분리할 때 pageBodyElement가 부모일 때까지 분리
  • 한 문단이 쪽의 남은 영역보다 큰 경우 처리(이미지, 표 등)
  • 쪽을 넘치는 표를 분리하여 다음 쪽에 이어서 표현하기(셀 분리가 고난도다.)
  • 한글일 경우 커서 및 compositing을 유지하기
  • 이외 그 미세한 1픽셀 차이들!

경험 상 기획 단계에서 적정선에서 기능 협의를 해두지 않으면 네이티브 워드와 끝없이 비교당할 수도 있다. 개인적인 생각으로는 Text 노드에서 좌표를 제공하는 스펙이 추가 된다면 혹은 WebAssembly에서 DOM에 접근하는 것이 지원된다면 좀 더 수월하게 더 나은 성능으로 웹 워드를 구현할 수 있지 않을까 생각한다.

온갖 어려움을 겪고도 웹 워드를 만들고자 하는 프론트엔드 개발자의 생각에 조금이나마 공감한다면 작성한 소스 코드는 여기 html-page-layout에 있고 데모는 여기에 있으니 참고 바란다.

유동식2018.02.16
Back to list