Webワードの基礎作成(2)

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設定

pageBodyElementcontentEditable=”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に、デモはここにありますので、よかったら参考にしてください。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop