NHN Cloud Meetup 編集部
Webワードの基礎作成(2)
2018.02.26
194
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();
段落がページ間にわたる場合、段落を行単位に分けて、前と次のページに表現できます。
BeforeAfter
ページレイアウト実行が必要なイベント処理
ここでは簡単に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に、デモはここにありますので、よかったら参考にしてください。