웹 워드프로세서(이하 웹 워드)는 브라우저만 있으면 어디서나 문서 편집을 할 수 있다는 장점이 있어 매력적인 소프트웨어이다. 아직 네이티브 워드프로세서로 할 수 있는 모든 기능을 다 지원하는 것은 아니지만 가까운 미래에는 가능하리라고 믿는다. 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
는 contentEditable="true"
를 설정하여 편집이 가능한 상태로 만들었고 style="outline: 0px;"
으로 편집 상태가 되었을 때 표시되는 테두리를 제거하였다. 내부에는 문단을 표현하기 위해서는 p
태그를 사용하였고 빈 문단인 경우 커서 표시를 위하여 bogus(br
태그)를 추가하였다.
가장 단순한 형태의 문서 편집기가 만들어졌다.
이 상태에서 내용을 많이 넣은 경우 아래 그림처럼 내용이 넘쳐 나오게 된다.
쪽 표현을 하는 워드 프로세서가 아니라면 단순히 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;
}
전체 레이아웃이 되는 그림이다. 쪽 레이아웃이 되는 것을 보기 위하여 약간의 딜레이를 주었다.
이제 쪽 높이보다 높은 문단을 처리할 시간이다. 그림으로 보면 이와 같은 상태이다.
마지막 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 표시를 하지 않아야 문단의 모양이 틀어지지 않는다.)
그리고 여기서 눈여겨보아야 할 부분이 커서 유지다. 커서가 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 After
여기에서는 간단하게 keyup
이벤틀 글자가 입력되면 1쪽부터 레이아웃이 수행되도록 처리하였다. 이외에도 복사 붙여 넣기, 삭제 등의 이벤트 처리도 필요하다.
/**
* Add event listners to layout pages
*/
_addEventListener() {
document.addEventListener('keyup', event => {
if (event.target.isContentEditable) {
this._layout();
}
});
}
글자 입력 중 쪽 레이아웃
웹 워드를 만들기 위해서 필수적인 쪽 표현 기능을 구현하는 방법과 코드를 소개하였다. 이 글만 보고 웹 워드 프로젝트를 시작하겠다고 덤벼들기보다는 좀 더 신중하게 접근하기를 권한다. 왜냐하면, 이 외에도 고려할 사항은 매우 많기 때문이다.
경험 상 기획 단계에서 적정선에서 기능 협의를 해두지 않으면 네이티브 워드와 끝없이 비교당할 수도 있다. 개인적인 생각으로는 Text 노드에서 좌표를 제공하는 스펙이 추가 된다면 혹은 WebAssembly에서 DOM에 접근하는 것이 지원된다면 좀 더 수월하게 더 나은 성능으로 웹 워드를 구현할 수 있지 않을까 생각한다.
온갖 어려움을 겪고도 웹 워드를 만들고자 하는 프론트엔드 개발자의 생각에 조금이나마 공감한다면 작성한 소스 코드는 여기 html-page-layout에 있고 데모는 여기에 있으니 참고 바란다.