NHN Cloud Meetup 編集部
Webワードの基礎作成(2)
2018.02.26
237
Webワードプロセッサ(以下、Webワード)は、ブラウザさえあれば、どこでも文書の編集ができるという利点があり、魅力的なソフトウェアです。まだネイティブワードプロセッサとしての機能をすべてサポートしているわけではありませんが、近い将来には可能だと思われます。
前回の記事では、Webワードの分類基準、表現の必要性と複雑さ、contentEditable、HTMLでの表現、レイアウトの原理について説明しました。今回は、実際のコードを見ながら、簡単なページ表現と編集機能の実装方法について紹介したいと思います。
実装要件
ページの表現機能の簡単な要件は以下の通りです。
- 段落がページ間にわたる場合、段落を単位に分けて、前と次ページに表現できる
- 文字入力/削除をしながら、リアルタイムで表現する
表の場合、より多くの実装と考慮が必要です。ここではテキストを基準に作成します。CSSでみると、display: inlineで処理された要素がレイアウトの対象です。
開始前に
前回の記事でページをレイアウトするための各ステップを説明しました。このプロセスは、以下のような手順で行われます。
- ページを分ける段階
- 段落を分離する段階
- 再びページのレイアウト実行が必要なイベント処理
各ステップを実際にコードで実装しよう。
ページを分ける段階
ページは余白を持っており、ページの余白を除いた領域から文字を入力することができます。したがって実際に内容がページに入るのはページの余白を除いた領域です。次のコードではclass=”page-body”を持つ要素であり、pageBodyElementと呼びます。
A4用紙のサイズは210mm×297mmだが、サンプルとしてはサイズが大きいため、便宜上150mm×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設定
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;
}
ここで1つ注意することは、_getExceedAllParagraphs()関数内で1段落のページが高さよりも大きい場合、処理するコードが必要であるということです。
// 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に段落を追加すればよいでしょう。ページが空でない場合、最初に段落を挿入します。このとき、以前に分離した段落は連結過程を経なければ、元の段落が2つの段落に見えることはありません。
ページをレイアウトするとページ数が増え、増えたページを対象に最後までレイアウトを進行する必要があります。全体のページレイアウトのコードを見てみよう。
/**
* 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タグで囲んで座標を調べる必要があります。ここで重要なことは2つ、段落内の行を認識することとページを超えるところから段落を分離することです。コードを見てみよう。
/**
* 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()が追加されました。溢れる段落がある場合、段落を2つに分離するものです。その後に実行される_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;
}
}
溢れる行を基準に段落を2つに分けるステップです。段落を分けるときに後で結合できるようにIDを保存しておきます。
// 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();
}
});
}
文字入力中のページレイアウト

おわりに
Webワードを作るために欠かせないページ表現機能を実装する方法とコードを紹介しました。この記事だけ見てWebワードを開始するのは勇み足で、もう少し慎重にアプローチされることをお勧めします。なぜなら、この他にも考慮すべき事項が非常に多いからです。
- より多くのブロック – レベル要素の追加
- ブロック – レベル要素がツリーでdepthが深いところにある場合の処理
- その場合、段落を分離する際にpageBodyElementが親であるまで分離
- 1つの段落がページの残領域よりも大きい場合の処理(画像、表など)
- ページをまたぐ表を分離して、次のページに続けて表現する(セル分離が高難度)
経験上、企画段階で適切なスコープを検討し、機能協議をしておかないと、ネイティブワードと延々と比較され続けるでしょう。Textノードで座標を提供する仕様が追加されたり、あるいはWebAssemblyでDOMにアクセスすることがサポートされれば、より容易に、より良い性能で、Webワードを実装できると思います。
Webワードのソースコードはhtml-page-layoutに、デモはここにありますので、よかったら参考にしてください。