Basics of Web Word Processor (2)


Web Word Processor (“Web Word”) is attractive software that allows users to edit documents anywhere as long as they have access to a browser. It does not support all the features of the native Word processor, but I hope it will do so in the near future. I have been through hard times developing Web Word for the two years working on this, only looking on the bright side. I've experienced a variety of thrills and excitement in the course of implementing each feature. I wish that you would feel the same as you read this article.

My previous article explained the criteria for categorizing Web Words, the necessity and complexity of implementing pages, contentEditable, and the principles behind displaying and placing layouts of pages in HTML. In this article, I will discuss the ways to implement simple page views and editing features using actual code.

What to Implement

As discussed in the previous article, the simple requirements for implementing page views are as follows:

  • When a paragraph overhangs between pages, it should be able to be wrapped in two separate pages.
  • Text should be updated in real time as user enters or deletes characters.

A lot more implementations and considerations are needed when it comes to implementing tables. This article discusses mainly focusing on texts. In CSS, the element handled as display: Inline is the target for layout.

Let's Get Started

In the previous article, I explained the steps to implement Layout. This process is done in the following procedure:

  • Dividing a Paragraph between Two Pages
  • Dividing a Paragraph into Lines
  • Events Requiring Page Layouts Again

Let's see the actual code implementing these steps.

Dividing a Paragraph between Two Pages

Each page has its own margins, and text can be typed in any area inside the margins. Therefore, the actual area for text display is anywhere except the margins. In the code below, it is an element that has class="page-body". Simply put, I will call it pageBodyElement.

The actual size of an A4 sheet is 210mm x 297mm, but this is rather oversized to fit in the space of this article, so I will use an arbitrary size of 150mm x 80mm for our convenience.

Write HTML code to implement pages as shown below:

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

Setting pageBodyElement

contentEditable="true” is set to pageBodyElement, so it is in an editable mode at the moment. style="outline: 0px;" is used to remove the outlines that are visible in the edit mode. I used the p tag to indicate paragraphs. For an empty paragraph, I added a bogus (br tag) to display cursor.

Now we've got a very basic document editor.

2018-02-18 23_10_39

We will now move the leftover part of the paragraph to the next page.

If additional texts are entered in this state, the texts will exceed the margin as shown below. image

If we were going to create a Word Processor that didn't require page implementation, we could have simply specified overflow-y: hidden or overflow-y: scroll. However, since our aim is higher, let's take a look at how we can deal with this.

Simply put, we are going to find all paragraph elements of which bottom value is greater than page 1, and then move them all to the next page.

This task is done by firstly find whether such a paragraph exists by using _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;
}

As it is clearly shown in the code, only p tags are currently regarded as paragraphs. As there are many more types of Block-Level Elements, visit MDN to see what else you can add.

The second task is to find all the exceeding paragraphs (_getExceedAllParagraphs()) and move them across to the next page (_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;
}

Please note the presence of the line of code in the _getExceedAllParagraphs() function, which handles a case when the height of a single paragraph is greater than the that of a page.

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

This happens when the height of the first paragraph is greater than that of the page. When this happens, an infinite number of pages will be created unless it is properly handled within the layout flow. If this kind of oversized paragraph is left as it is, the text will cross the margin as shown below. We will take care of this problem in the Dividing a Paragraph into Lines section. In reality, a paragraph that contains a large picture or a tall table is likely to cause this problem. If this is the case, we need more advanced handling process of the layout.

/**
 * 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() moves all the exceeding paragraphs across to the next page. If the next page is blank, we could simply add a paragraph element to pageBodyElement. If the page is not blank, we could insert it at the top of the page. Any paragraphs previously split must be combined back into one at this stage. Otherwise, we will see two separate paragraphs that should've been one.

Applying layout to a page results in the increased number of pages. Layout must be applied to the newly created pages up to the very last page. Let's take a look at the code of the entire page layout.

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

_layout() applies page layout to the first page through the very last page. _layoutPage() uses the function mentioned earlier to apply Layout to the specified page.

/**
 * 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;
}

This is the picture of applying Layout to all pages. I applied a bit of delay to see the process of Page Layout in action.

2018-02-19 00_44_29

Dividing a Paragraph into Lines

Now is the right time to take care of a paragraphs of which height is greater than that of the page. This is shown in the image. image

As you can see, the last line crossed the margin. As I mentioned earlier, you cannot gain the coordinates of letters with Text Node only; you actually need to wrap all text nodes with span tags. There are two points you need to know here. First is to detect lines within the paragraph, and the second is to split a paragraph that goes out of the page. Let’s take a look at the actual code.

/**
 * 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() has been added. If there is an over-the-margin paragraph, we need to separate the paragraph in two, starting with the exceeding line of the paragraph. All other paragraphs separated as a result of executing _getExceedAllParagraphs() are also collected and moved across to the next page.

/**
 * 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);
    });
}

For better understanding of wrapping text nodes with spans, I highlighted the wrapping of each letter in red. (In reality, borders should not be displayed in order to avoid paragraph distortion.)

image

Another thing you've got to focus is how cursor is kept. I assumed the case of cursor being collapsed, but this must be dealt with to keep the current cursor. (In fact, implementing this alone in depth requires a lot of work.)

// 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);
}

This is the step where the number of lines in a paragraph is detected.

// 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;
});

This is the step where the exceeding lines in a paragraph are detected.

// 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;
  }
}

This is where exceeding paragraphs are split into two. Store the ID of a split paragraph so that you can combine it back later.

// 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;
}

Remove the wrapping span tags, maintain Cursor, and normalize() the split texts.

// 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();

Now when text overhangs between pages, it should be able to be wrapped on a line basis and displayed in the next page.

Before image After image

Events Requiring Page Layouts Again

In this example, I made a layout to be applied starting from the first page when the keyup event letter is entered. We also need to handle other events such as Copy & Paste and Delete.

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

Page layout during typing in text 2018-02-19 05_21_17

Closing

We have looked at how we could implement pages, which is essential in developing a Web Word. I do not encourage you to jump right into the development of a Web Word. Rather, be cautious and take a careful approach because there are a lot of features still to be considered as follows:

  • Adding more Block-Level Elements
  • Processing Block-Level Elements when they are in deep places within the DOM Tree
  • In this case, paragraphs must be split until pageBodyElement becomes a parent
  • Processing a single paragraph that does not fit in the remaining area of a page (images, tables, etc.).
  • Splitting and rejoining the table which has exceeded the margin of the previous page in the next page (splitting cells is a quite difficult task).
  • For Korean letters, maintaining cursor and text compositing
  • And taking care of those subtle 1-pixel differences!

In my experience, you are better to discuss the necessary features in the Web Word in advance with your client. Otherwise, your software will constantly be compared to the native Word processor. In my opinion, if the specification of providing coordinates from Text Node is added or DOM access support in WebAssembly is supported, the development and performance of your Web Word will be a lot better.

If you are a front-end developer who would dare to give his or her best shot despite all the challenges, give it a try, brave one! Here are my little gifts: the source code (html-page-layout) and the demo.

DongSik Yoo2018.02.19
Back to list