From 7a74c3c31ece7788e82c46f22cb4327ffe08307a Mon Sep 17 00:00:00 2001
From: toasted-nutbread <toasted-nutbread@users.noreply.github.com>
Date: Sat, 13 Feb 2021 23:13:53 -0500
Subject: Move fg/js (#1384)

* Move fg/js/frame-ancestry-handler.js to js/comm/frame-ancestry-handler.js

* Move fg/js/frame-offset-forwarder.js to js/comm/frame-offset-forwarder.js

* Move fg/js/dom-text-scanner.js to js/dom/dom-text-scanner.js

* Move fg/js/text-source-element.js to js/dom/text-source-element.js

* Move fg/js/text-source-range.js to js/dom/text-source-range.js

* Move fg/js/float-main.js to js/display/popup-main.js

* Move fg/js/content-script-main.js to js/app/content-script-main.js

* Move fg/js/frontend.js to js/app/frontend.js

* Move fg/js/popup-factory.js to js/app/popup-factory.js

* Move fg/js/popup-proxy.js to js/app/popup-proxy.js

* Move fg/js/popup-window.js to js/app/popup-window.js

* Move fg/js/popup.js to js/app/popup.js
---
 dev/data/manifest-variants.json       |  22 +-
 ext/fg/js/content-script-main.js      |  59 ---
 ext/fg/js/dom-text-scanner.js         | 551 ---------------------------
 ext/fg/js/float-main.js               |  56 ---
 ext/fg/js/frame-ancestry-handler.js   | 269 -------------
 ext/fg/js/frame-offset-forwarder.js   |  70 ----
 ext/fg/js/frontend.js                 | 691 ----------------------------------
 ext/fg/js/popup-factory.js            | 319 ----------------
 ext/fg/js/popup-proxy.js              | 218 -----------
 ext/fg/js/popup-window.js             | 169 ---------
 ext/fg/js/popup.js                    | 687 ---------------------------------
 ext/fg/js/text-source-element.js      | 139 -------
 ext/fg/js/text-source-range.js        | 170 ---------
 ext/js/app/content-script-main.js     |  59 +++
 ext/js/app/frontend.js                | 691 ++++++++++++++++++++++++++++++++++
 ext/js/app/popup-factory.js           | 319 ++++++++++++++++
 ext/js/app/popup-proxy.js             | 218 +++++++++++
 ext/js/app/popup-window.js            | 169 +++++++++
 ext/js/app/popup.js                   | 687 +++++++++++++++++++++++++++++++++
 ext/js/comm/frame-ancestry-handler.js | 269 +++++++++++++
 ext/js/comm/frame-offset-forwarder.js |  70 ++++
 ext/js/display/display.js             |  14 +-
 ext/js/display/popup-main.js          |  56 +++
 ext/js/dom/dom-text-scanner.js        | 551 +++++++++++++++++++++++++++
 ext/js/dom/text-source-element.js     | 139 +++++++
 ext/js/dom/text-source-range.js       | 170 +++++++++
 ext/manifest.json                     |  22 +-
 ext/popup-preview.html                |  16 +-
 ext/popup.html                        |   8 +-
 ext/search.html                       |   6 +-
 test/test-document-util.js            |   6 +-
 test/test-dom-text-scanner.js         |   2 +-
 32 files changed, 3446 insertions(+), 3446 deletions(-)
 delete mode 100644 ext/fg/js/content-script-main.js
 delete mode 100644 ext/fg/js/dom-text-scanner.js
 delete mode 100644 ext/fg/js/float-main.js
 delete mode 100644 ext/fg/js/frame-ancestry-handler.js
 delete mode 100644 ext/fg/js/frame-offset-forwarder.js
 delete mode 100644 ext/fg/js/frontend.js
 delete mode 100644 ext/fg/js/popup-factory.js
 delete mode 100644 ext/fg/js/popup-proxy.js
 delete mode 100644 ext/fg/js/popup-window.js
 delete mode 100644 ext/fg/js/popup.js
 delete mode 100644 ext/fg/js/text-source-element.js
 delete mode 100644 ext/fg/js/text-source-range.js
 create mode 100644 ext/js/app/content-script-main.js
 create mode 100644 ext/js/app/frontend.js
 create mode 100644 ext/js/app/popup-factory.js
 create mode 100644 ext/js/app/popup-proxy.js
 create mode 100644 ext/js/app/popup-window.js
 create mode 100644 ext/js/app/popup.js
 create mode 100644 ext/js/comm/frame-ancestry-handler.js
 create mode 100644 ext/js/comm/frame-offset-forwarder.js
 create mode 100644 ext/js/display/popup-main.js
 create mode 100644 ext/js/dom/dom-text-scanner.js
 create mode 100644 ext/js/dom/text-source-element.js
 create mode 100644 ext/js/dom/text-source-range.js

diff --git a/dev/data/manifest-variants.json b/dev/data/manifest-variants.json
index aa06bf58..72e07086 100644
--- a/dev/data/manifest-variants.json
+++ b/dev/data/manifest-variants.json
@@ -48,17 +48,17 @@
                     "js/language/text-scanner.js",
                     "js/dom/document-util.js",
                     "js/input/hotkey-handler.js",
-                    "fg/js/dom-text-scanner.js",
-                    "fg/js/popup.js",
-                    "fg/js/text-source-range.js",
-                    "fg/js/text-source-element.js",
-                    "fg/js/popup-factory.js",
-                    "fg/js/frame-ancestry-handler.js",
-                    "fg/js/frame-offset-forwarder.js",
-                    "fg/js/popup-proxy.js",
-                    "fg/js/popup-window.js",
-                    "fg/js/frontend.js",
-                    "fg/js/content-script-main.js"
+                    "js/dom/dom-text-scanner.js",
+                    "js/app/popup.js",
+                    "js/dom/text-source-range.js",
+                    "js/dom/text-source-element.js",
+                    "js/app/popup-factory.js",
+                    "js/comm/frame-ancestry-handler.js",
+                    "js/comm/frame-offset-forwarder.js",
+                    "js/app/popup-proxy.js",
+                    "js/app/popup-window.js",
+                    "js/app/frontend.js",
+                    "js/app/content-script-main.js"
                 ],
                 "match_about_blank": true,
                 "all_frames": true
diff --git a/ext/fg/js/content-script-main.js b/ext/fg/js/content-script-main.js
deleted file mode 100644
index 5dee4c56..00000000
--- a/ext/fg/js/content-script-main.js
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright (C) 2019-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * Frontend
- * HotkeyHandler
- * PopupFactory
- * api
- */
-
-(async () => {
-    try {
-        api.forwardLogsToBackend();
-        await yomichan.backendReady();
-
-        const {tabId, frameId} = await api.frameInformationGet();
-        if (typeof frameId !== 'number') {
-            throw new Error('Failed to get frameId');
-        }
-
-        const hotkeyHandler = new HotkeyHandler();
-        hotkeyHandler.prepare();
-
-        const popupFactory = new PopupFactory(frameId);
-        popupFactory.prepare();
-
-        const frontend = new Frontend({
-            tabId,
-            frameId,
-            popupFactory,
-            depth: 0,
-            parentPopupId: null,
-            parentFrameId: null,
-            useProxyPopup: false,
-            pageType: 'web',
-            allowRootFramePopupProxy: true,
-            hotkeyHandler
-        });
-        await frontend.prepare();
-
-        yomichan.ready();
-    } catch (e) {
-        yomichan.logError(e);
-    }
-})();
diff --git a/ext/fg/js/dom-text-scanner.js b/ext/fg/js/dom-text-scanner.js
deleted file mode 100644
index 71e74fc3..00000000
--- a/ext/fg/js/dom-text-scanner.js
+++ /dev/null
@@ -1,551 +0,0 @@
-/*
- * Copyright (C) 2020-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/**
- * A class used to scan text in a document.
- */
-class DOMTextScanner {
-    /**
-     * Creates a new instance of a DOMTextScanner.
-     * @param node The DOM Node to start at.
-     * @param offset The character offset in to start at when node is a text node.
-     *   Use 0 for non-text nodes.
-     */
-    constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) {
-        const ruby = DOMTextScanner.getParentRubyElement(node);
-        const resetOffset = (ruby !== null);
-        if (resetOffset) { node = ruby; }
-
-        this._node = node;
-        this._offset = offset;
-        this._content = '';
-        this._remainder = 0;
-        this._resetOffset = resetOffset;
-        this._newlines = 0;
-        this._lineHasWhitespace = false;
-        this._lineHasContent = false;
-        this._forcePreserveWhitespace = forcePreserveWhitespace;
-        this._generateLayoutContent = generateLayoutContent;
-    }
-
-    /**
-     * Gets the current node being scanned.
-     * @returns A DOM Node.
-     */
-    get node() {
-        return this._node;
-    }
-
-    /**
-     * Gets the current offset corresponding to the node being scanned.
-     * This value is only applicable for text nodes.
-     * @returns An integer.
-     */
-    get offset() {
-        return this._offset;
-    }
-
-    /**
-     * Gets the remaining number of characters that weren't scanned in the last seek() call.
-     * This value is usually 0 unless the end of the document was reached.
-     * @returns An integer.
-     */
-    get remainder() {
-        return this._remainder;
-    }
-
-    /**
-     * Gets the accumulated content string resulting from calls to seek().
-     * @returns A string.
-     */
-    get content() {
-        return this._content;
-    }
-
-    /**
-     * Seeks a given length in the document and accumulates the text content.
-     * @param length A positive or negative integer corresponding to how many characters
-     *   should be added to content. Content is only added to the accumulation string,
-     *   never removed, so mixing seek calls with differently signed length values
-     *   may give unexpected results.
-     * @returns this
-     */
-    seek(length) {
-        const forward = (length >= 0);
-        this._remainder = (forward ? length : -length);
-        if (length === 0) { return this; }
-
-        const TEXT_NODE = Node.TEXT_NODE;
-        const ELEMENT_NODE = Node.ELEMENT_NODE;
-
-        const generateLayoutContent = this._generateLayoutContent;
-        let node = this._node;
-        let lastNode = node;
-        let resetOffset = this._resetOffset;
-        let newlines = 0;
-        while (node !== null) {
-            let enterable = false;
-            const nodeType = node.nodeType;
-
-            if (nodeType === TEXT_NODE) {
-                lastNode = node;
-                if (!(
-                    forward ?
-                    this._seekTextNodeForward(node, resetOffset) :
-                    this._seekTextNodeBackward(node, resetOffset)
-                )) {
-                    // Length reached
-                    break;
-                }
-            } else if (nodeType === ELEMENT_NODE) {
-                lastNode = node;
-                this._offset = 0;
-                [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node);
-                if (newlines > this._newlines && generateLayoutContent) {
-                    this._newlines = newlines;
-                }
-            }
-
-            const exitedNodes = [];
-            node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes);
-
-            for (const exitedNode of exitedNodes) {
-                if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
-                newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1];
-                if (newlines > this._newlines && generateLayoutContent) {
-                    this._newlines = newlines;
-                }
-            }
-
-            resetOffset = true;
-        }
-
-        this._node = lastNode;
-        this._resetOffset = resetOffset;
-
-        return this;
-    }
-
-    // Private
-
-    /**
-     * Seeks forward in a text node.
-     * @param textNode The text node to use.
-     * @param resetOffset Whether or not the text offset should be reset.
-     * @returns true if scanning should continue, or false if the scan length has been reached.
-     */
-    _seekTextNodeForward(textNode, resetOffset) {
-        const nodeValue = textNode.nodeValue;
-        const nodeValueLength = nodeValue.length;
-        const [preserveNewlines, preserveWhitespace] = (
-            this._forcePreserveWhitespace ?
-            [true, true] :
-            DOMTextScanner.getWhitespaceSettings(textNode)
-        );
-
-        let lineHasWhitespace = this._lineHasWhitespace;
-        let lineHasContent = this._lineHasContent;
-        let content = this._content;
-        let offset = resetOffset ? 0 : this._offset;
-        let remainder = this._remainder;
-        let newlines = this._newlines;
-
-        while (offset < nodeValueLength) {
-            const char = nodeValue[offset];
-            const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
-            ++offset;
-
-            if (charAttributes === 0) {
-                // Character should be ignored
-                continue;
-            } else if (charAttributes === 1) {
-                // Character is collapsable whitespace
-                lineHasWhitespace = true;
-            } else {
-                // Character should be added to the content
-                if (newlines > 0) {
-                    if (content.length > 0) {
-                        const useNewlineCount = Math.min(remainder, newlines);
-                        content += '\n'.repeat(useNewlineCount);
-                        remainder -= useNewlineCount;
-                        newlines -= useNewlineCount;
-                    } else {
-                        newlines = 0;
-                    }
-                    lineHasContent = false;
-                    lineHasWhitespace = false;
-                    if (remainder <= 0) {
-                        --offset; // Revert character offset
-                        break;
-                    }
-                }
-
-                lineHasContent = (charAttributes === 2); // 3 = character is a newline
-
-                if (lineHasWhitespace) {
-                    if (lineHasContent) {
-                        content += ' ';
-                        lineHasWhitespace = false;
-                        if (--remainder <= 0) {
-                            --offset; // Revert character offset
-                            break;
-                        }
-                    } else {
-                        lineHasWhitespace = false;
-                    }
-                }
-
-                content += char;
-
-                if (--remainder <= 0) { break; }
-            }
-        }
-
-        this._lineHasWhitespace = lineHasWhitespace;
-        this._lineHasContent = lineHasContent;
-        this._content = content;
-        this._offset = offset;
-        this._remainder = remainder;
-        this._newlines = newlines;
-
-        return (remainder > 0);
-    }
-
-    /**
-     * Seeks backward in a text node.
-     * This function is nearly the same as _seekTextNodeForward, with the following differences:
-     * - Iteration condition is reversed to check if offset is greater than 0.
-     * - offset is reset to nodeValueLength instead of 0.
-     * - offset is decremented instead of incremented.
-     * - offset is decremented before getting the character.
-     * - offset is reverted by incrementing instead of decrementing.
-     * - content string is prepended instead of appended.
-     * @param textNode The text node to use.
-     * @param resetOffset Whether or not the text offset should be reset.
-     * @returns true if scanning should continue, or false if the scan length has been reached.
-     */
-    _seekTextNodeBackward(textNode, resetOffset) {
-        const nodeValue = textNode.nodeValue;
-        const nodeValueLength = nodeValue.length;
-        const [preserveNewlines, preserveWhitespace] = (
-            this._forcePreserveWhitespace ?
-            [true, true] :
-            DOMTextScanner.getWhitespaceSettings(textNode)
-        );
-
-        let lineHasWhitespace = this._lineHasWhitespace;
-        let lineHasContent = this._lineHasContent;
-        let content = this._content;
-        let offset = resetOffset ? nodeValueLength : this._offset;
-        let remainder = this._remainder;
-        let newlines = this._newlines;
-
-        while (offset > 0) {
-            --offset;
-            const char = nodeValue[offset];
-            const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
-
-            if (charAttributes === 0) {
-                // Character should be ignored
-                continue;
-            } else if (charAttributes === 1) {
-                // Character is collapsable whitespace
-                lineHasWhitespace = true;
-            } else {
-                // Character should be added to the content
-                if (newlines > 0) {
-                    if (content.length > 0) {
-                        const useNewlineCount = Math.min(remainder, newlines);
-                        content = '\n'.repeat(useNewlineCount) + content;
-                        remainder -= useNewlineCount;
-                        newlines -= useNewlineCount;
-                    } else {
-                        newlines = 0;
-                    }
-                    lineHasContent = false;
-                    lineHasWhitespace = false;
-                    if (remainder <= 0) {
-                        ++offset; // Revert character offset
-                        break;
-                    }
-                }
-
-                lineHasContent = (charAttributes === 2); // 3 = character is a newline
-
-                if (lineHasWhitespace) {
-                    if (lineHasContent) {
-                        content = ' ' + content;
-                        lineHasWhitespace = false;
-                        if (--remainder <= 0) {
-                            ++offset; // Revert character offset
-                            break;
-                        }
-                    } else {
-                        lineHasWhitespace = false;
-                    }
-                }
-
-                content = char + content;
-
-                if (--remainder <= 0) { break; }
-            }
-        }
-
-        this._lineHasWhitespace = lineHasWhitespace;
-        this._lineHasContent = lineHasContent;
-        this._content = content;
-        this._offset = offset;
-        this._remainder = remainder;
-        this._newlines = newlines;
-
-        return (remainder > 0);
-    }
-
-    // Static helpers
-
-    /**
-     * Gets the next node in the document for a specified scanning direction.
-     * @param node The current DOM Node.
-     * @param forward Whether to scan forward in the document or backward.
-     * @param visitChildren Whether the children of the current node should be visited.
-     * @param exitedNodes An array which stores nodes which were exited.
-     * @returns The next node in the document, or null if there is no next node.
-     */
-    static getNextNode(node, forward, visitChildren, exitedNodes) {
-        let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
-        if (next === null) {
-            while (true) {
-                exitedNodes.push(node);
-
-                next = (forward ? node.nextSibling : node.previousSibling);
-                if (next !== null) { break; }
-
-                next = node.parentNode;
-                if (next === null) { break; }
-
-                node = next;
-            }
-        }
-        return next;
-    }
-
-    /**
-     * Gets the parent element of a given Node.
-     * @param node The node to check.
-     * @returns The parent element if one exists, otherwise null.
-     */
-    static getParentElement(node) {
-        while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
-            node = node.parentNode;
-        }
-        return node;
-    }
-
-    /**
-     * Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes,
-     * this only checks the immediate parent elements and does not check all ancestors, so
-     * there are cases where the node may be in a ruby element but it is not returned.
-     * @param node The node to check.
-     * @returns A <ruby> node if the input node is contained in one, otherwise null.
-     */
-    static getParentRubyElement(node) {
-        node = DOMTextScanner.getParentElement(node);
-        if (node !== null && node.nodeName.toUpperCase() === 'RT') {
-            node = node.parentNode;
-            if (node !== null && node.nodeName.toUpperCase() === 'RUBY') {
-                return node;
-            }
-        }
-        return null;
-    }
-
-    /**
-     * @returns [enterable: boolean, newlines: integer]
-     *   The enterable value indicates whether the content of this node should be entered.
-     *   The newlines value corresponds to the number of newline characters that should be added.
-     *     1 newline corresponds to a simple new line in the layout.
-     *     2 newlines corresponds to a significant visual distinction since the previous content.
-     */
-    static getElementSeekInfo(element) {
-        let enterable = true;
-        switch (element.nodeName.toUpperCase()) {
-            case 'HEAD':
-            case 'RT':
-            case 'SCRIPT':
-            case 'STYLE':
-                return [false, 0];
-            case 'BR':
-                return [false, 1];
-            case 'TEXTAREA':
-            case 'INPUT':
-            case 'BUTTON':
-                enterable = false;
-                break;
-        }
-
-        const style = window.getComputedStyle(element);
-        const display = style.display;
-
-        const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style));
-        let newlines = 0;
-
-        if (!visible) {
-            enterable = false;
-        } else {
-            switch (style.position) {
-                case 'absolute':
-                case 'fixed':
-                case 'sticky':
-                    newlines = 2;
-                    break;
-            }
-            if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) {
-                newlines = 1;
-            }
-        }
-
-        return [enterable, newlines];
-    }
-
-    /**
-     * Gets information about how whitespace characters are treated.
-     * @param textNode The Text node to check.
-     * @returns [preserveNewlines: boolean, preserveWhitespace: boolean]
-     *   The value of preserveNewlines indicates whether or not newline characters are treated as line breaks.
-     *   The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed.
-     */
-    static getWhitespaceSettings(textNode) {
-        const element = DOMTextScanner.getParentElement(textNode);
-        if (element !== null) {
-            const style = window.getComputedStyle(element);
-            switch (style.whiteSpace) {
-                case 'pre':
-                case 'pre-wrap':
-                case 'break-spaces':
-                    return [true, true];
-                case 'pre-line':
-                    return [true, false];
-            }
-        }
-        return [false, false];
-    }
-
-    /**
-     * Gets attributes for the specified character.
-     * @param character A string containing a single character.
-     * @returns An integer representing the attributes of the character.
-     *   0: Character should be ignored.
-     *   1: Character is collapsable whitespace.
-     *   2: Character should be added to the content.
-     *   3: Character should be added to the content and is a newline.
-     */
-    static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) {
-        switch (character.charCodeAt(0)) {
-            case 0x09: // Tab ('\t')
-            case 0x0c: // Form feed ('\f')
-            case 0x0d: // Carriage return ('\r')
-            case 0x20: // Space (' ')
-                return preserveWhitespace ? 2 : 1;
-            case 0x0a: // Line feed ('\n')
-                return preserveNewlines ? 3 : 1;
-            case 0x200c: // Zero-width non-joiner ('\u200c')
-                return 0;
-            default: // Other
-                return 2;
-        }
-    }
-
-    /**
-     * Checks whether a given style is visible or not.
-     * This function does not check style.display === 'none'.
-     * @param style An object implementing the CSSStyleDeclaration interface.
-     * @returns true if the style should result in an element being visible, otherwise false.
-     */
-    static isStyleVisible(style) {
-        return !(
-            style.visibility === 'hidden' ||
-            parseFloat(style.opacity) <= 0 ||
-            parseFloat(style.fontSize) <= 0 ||
-            (
-                !DOMTextScanner.isStyleSelectable(style) &&
-                (
-                    DOMTextScanner.isCSSColorTransparent(style.color) ||
-                    DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor)
-                )
-            )
-        );
-    }
-
-    /**
-     * Checks whether a given style is selectable or not.
-     * @param style An object implementing the CSSStyleDeclaration interface.
-     * @returns true if the style is selectable, otherwise false.
-     */
-    static isStyleSelectable(style) {
-        return !(
-            style.userSelect === 'none' ||
-            style.webkitUserSelect === 'none' ||
-            style.MozUserSelect === 'none' ||
-            style.msUserSelect === 'none'
-        );
-    }
-
-    /**
-     * Checks whether a CSS color is transparent or not.
-     * @param cssColor A CSS color string, expected to be encoded in rgb(a) form.
-     * @returns true if the color is transparent, otherwise false.
-     */
-    static isCSSColorTransparent(cssColor) {
-        return (
-            typeof cssColor === 'string' &&
-            cssColor.startsWith('rgba(') &&
-            /,\s*0.?0*\)$/.test(cssColor)
-        );
-    }
-
-    /**
-     * Checks whether a CSS display value will cause a layout change for text.
-     * @param cssDisplay A CSS string corresponding to the value of the display property.
-     * @returns true if the layout is changed by this value, otherwise false.
-     */
-    static doesCSSDisplayChangeLayout(cssDisplay) {
-        let pos = cssDisplay.indexOf(' ');
-        if (pos >= 0) {
-            // Truncate to <display-outside> part
-            cssDisplay = cssDisplay.substring(0, pos);
-        }
-
-        pos = cssDisplay.indexOf('-');
-        if (pos >= 0) {
-            // Truncate to first part of kebab-case value
-            cssDisplay = cssDisplay.substring(0, pos);
-        }
-
-        switch (cssDisplay) {
-            case 'block':
-            case 'flex':
-            case 'grid':
-            case 'list': // list-item
-            case 'table': // table, table-*
-                return true;
-            case 'ruby': // rubt-*
-                return (pos >= 0);
-            default:
-                return false;
-        }
-    }
-}
diff --git a/ext/fg/js/float-main.js b/ext/fg/js/float-main.js
deleted file mode 100644
index 7c048b62..00000000
--- a/ext/fg/js/float-main.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright (C) 2020-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * Display
- * DisplayProfileSelection
- * DocumentFocusController
- * HotkeyHandler
- * JapaneseUtil
- * api
- */
-
-(async () => {
-    try {
-        const documentFocusController = new DocumentFocusController();
-        documentFocusController.prepare();
-
-        api.forwardLogsToBackend();
-        await yomichan.backendReady();
-
-        const {tabId, frameId} = await api.frameInformationGet();
-
-        const japaneseUtil = new JapaneseUtil(null);
-
-        const hotkeyHandler = new HotkeyHandler();
-        hotkeyHandler.prepare();
-
-        const display = new Display(tabId, frameId, 'popup', japaneseUtil, documentFocusController, hotkeyHandler);
-        await display.prepare();
-
-        const displayProfileSelection = new DisplayProfileSelection(display);
-        displayProfileSelection.prepare();
-
-        display.initializeState();
-
-        document.documentElement.dataset.loaded = 'true';
-
-        yomichan.ready();
-    } catch (e) {
-        yomichan.logError(e);
-    }
-})();
diff --git a/ext/fg/js/frame-ancestry-handler.js b/ext/fg/js/frame-ancestry-handler.js
deleted file mode 100644
index b1ed7114..00000000
--- a/ext/fg/js/frame-ancestry-handler.js
+++ /dev/null
@@ -1,269 +0,0 @@
-/*
- * Copyright (C) 2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * api
- */
-
-/**
- * This class is used to return the ancestor frame IDs for the current frame.
- * This is a workaround to using the `webNavigation.getAllFrames` API, which
- * would require an additional permission that is otherwise unnecessary.
- * It is also used to track the correlation between child frame elements and their IDs.
- */
-class FrameAncestryHandler {
-    /**
-     * Creates a new instance.
-     * @param frameId The frame ID of the current frame the instance is instantiated in.
-     */
-    constructor(frameId) {
-        this._frameId = frameId;
-        this._isPrepared = false;
-        this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo';
-        this._responseMessageIdBase = `${this._requestMessageId}.response.`;
-        this._getFrameAncestryInfoPromise = null;
-        this._childFrameMap = new Map();
-    }
-
-    /**
-     * Gets the frame ID that the instance is instantiated in.
-     */
-    get frameId() {
-        return this._frameId;
-    }
-
-    /**
-     * Initializes event event listening.
-     */
-    prepare() {
-        if (this._isPrepared) { return; }
-        window.addEventListener('message', this._onWindowMessage.bind(this), false);
-        this._isPrepared = true;
-    }
-
-    /**
-     * Returns whether or not this frame is the root frame in the tab.
-     * @returns `true` if it is the root, otherwise `false`.
-     */
-    isRootFrame() {
-        return (window === window.parent);
-    }
-
-    /**
-     * Gets the frame ancestry information for the current frame. If the frame is the
-     * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned,
-     * starting from the nearest ancestor.
-     * @param timeout The maximum time to wait to receive a response to frame information requests.
-     * @returns An array of frame IDs corresponding to the ancestors of the current frame.
-     */
-    async getFrameAncestryInfo() {
-        if (this._getFrameAncestryInfoPromise === null) {
-            this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000);
-        }
-        return await this._getFrameAncestryInfoPromise;
-    }
-
-    /**
-     * Gets the frame element of a child frame given a frame ID.
-     * For this function to work, the `getFrameAncestryInfo` function needs to have
-     * been invoked previously.
-     * @param frameId The frame ID of the child frame to get.
-     * @returns The element corresponding to the frame with ID `frameId`, otherwise `null`.
-     */
-    getChildFrameElement(frameId) {
-        const frameInfo = this._childFrameMap.get(frameId);
-        if (typeof frameInfo === 'undefined') { return null; }
-
-        let {frameElement} = frameInfo;
-        if (typeof frameElement === 'undefined') {
-            frameElement = this._findFrameElementWithContentWindow(frameInfo.window);
-            frameInfo.frameElement = frameElement;
-        }
-
-        return frameElement;
-    }
-
-    // Private
-
-    _getFrameAncestryInfo(timeout=5000) {
-        return new Promise((resolve, reject) => {
-            const targetWindow = window.parent;
-            if (window === targetWindow) {
-                resolve([]);
-                return;
-            }
-
-            const uniqueId = generateId(16);
-            let nonce = generateId(16);
-            const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
-            const results = [];
-            let timer = null;
-
-            const cleanup = () => {
-                if (timer !== null) {
-                    clearTimeout(timer);
-                    timer = null;
-                }
-                api.crossFrame.unregisterHandler(responseMessageId);
-            };
-            const onMessage = (params) => {
-                if (params.nonce !== nonce) { return null; }
-
-                // Add result
-                const {frameId, more} = params;
-                results.push(frameId);
-                nonce = generateId(16);
-
-                if (!more) {
-                    // Cleanup
-                    cleanup();
-
-                    // Finish
-                    resolve(results);
-                }
-                return {nonce};
-            };
-            const onTimeout = () => {
-                timer = null;
-                cleanup();
-                reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`));
-            };
-            const resetTimeout = () => {
-                if (timer !== null) { clearTimeout(timer); }
-                timer = setTimeout(onTimeout, timeout);
-            };
-
-            // Start
-            api.crossFrame.registerHandlers([[responseMessageId, {async: false, handler: onMessage}]]);
-            resetTimeout();
-            const frameId = this._frameId;
-            this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce);
-        });
-    }
-
-    _onWindowMessage(event) {
-        const {source} = event;
-        if (source === window || source.parent !== window) { return; }
-
-        const {data} = event;
-        if (
-            typeof data === 'object' &&
-            data !== null &&
-            data.action === this._requestMessageId
-        ) {
-            this._onRequestFrameInfo(data.params, source);
-        }
-    }
-
-    async _onRequestFrameInfo(params, source) {
-        try {
-            let {originFrameId, childFrameId, uniqueId, nonce} = params;
-            if (
-                !this._isNonNegativeInteger(originFrameId) ||
-                typeof uniqueId !== 'string' ||
-                typeof nonce !== 'string'
-            ) {
-                return;
-            }
-
-            const frameId = this._frameId;
-            const {parent} = window;
-            const more = (window !== parent);
-            const responseParams = {frameId, nonce, more};
-            const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
-
-            try {
-                const response = await api.crossFrame.invoke(originFrameId, responseMessageId, responseParams);
-                if (response === null) { return; }
-                nonce = response.nonce;
-            } catch (e) {
-                return;
-            }
-
-            if (!this._childFrameMap.has(childFrameId)) {
-                this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0});
-            }
-
-            if (more) {
-                this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce);
-            }
-        } catch (e) {
-            // NOP
-        }
-    }
-
-    _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) {
-        targetWindow.postMessage({
-            action: this._requestMessageId,
-            params: {originFrameId, childFrameId, uniqueId, nonce}
-        }, '*');
-    }
-
-    _isNonNegativeInteger(value) {
-        return (
-            typeof value === 'number' &&
-            Number.isFinite(value) &&
-            value >= 0 &&
-            Math.floor(value) === value
-        );
-    }
-
-    _findFrameElementWithContentWindow(contentWindow) {
-        // Check frameElement, for non-null same-origin frames
-        try {
-            const {frameElement} = contentWindow;
-            if (frameElement !== null) { return frameElement; }
-        } catch (e) {
-            // NOP
-        }
-
-        // Check frames
-        const frameTypes = ['iframe', 'frame', 'embed'];
-        for (const frameType of frameTypes) {
-            for (const frame of document.getElementsByTagName(frameType)) {
-                if (frame.contentWindow === contentWindow) {
-                    return frame;
-                }
-            }
-        }
-
-        // Check for shadow roots
-        const rootElements = [document.documentElement];
-        while (rootElements.length > 0) {
-            const rootElement = rootElements.shift();
-            const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
-            while (walker.nextNode()) {
-                const element = walker.currentNode;
-
-                if (element.contentWindow === contentWindow) {
-                    return element;
-                }
-
-                const shadowRoot = (
-                    element.shadowRoot ||
-                    element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
-                );
-                if (shadowRoot) {
-                    rootElements.push(shadowRoot);
-                }
-            }
-        }
-
-        // Not found
-        return null;
-    }
-}
diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js
deleted file mode 100644
index 0a0b4a18..00000000
--- a/ext/fg/js/frame-offset-forwarder.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright (C) 2020-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * FrameAncestryHandler
- * api
- */
-
-class FrameOffsetForwarder {
-    constructor(frameId) {
-        this._frameId = frameId;
-        this._frameAncestryHandler = new FrameAncestryHandler(frameId);
-    }
-
-    prepare() {
-        this._frameAncestryHandler.prepare();
-        api.crossFrame.registerHandlers([
-            ['FrameOffsetForwarder.getChildFrameRect', {async: false, handler: this._onMessageGetChildFrameRect.bind(this)}]
-        ]);
-    }
-
-    async getOffset() {
-        if (this._frameAncestryHandler.isRootFrame()) {
-            return [0, 0];
-        }
-
-        const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo();
-
-        let childFrameId = this._frameId;
-        const promises = [];
-        for (const frameId of ancestorFrameIds) {
-            promises.push(api.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId}));
-            childFrameId = frameId;
-        }
-
-        const results = await Promise.all(promises);
-
-        let xOffset = 0;
-        let yOffset = 0;
-        for (const {x, y} of results) {
-            xOffset += x;
-            yOffset += y;
-        }
-        return [xOffset, yOffset];
-    }
-
-    // Private
-
-    _onMessageGetChildFrameRect({frameId}) {
-        const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId);
-        if (frameElement === null) { return null; }
-
-        const {x, y, width, height} = frameElement.getBoundingClientRect();
-        return {x, y, width, height};
-    }
-}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
deleted file mode 100644
index a62b06bf..00000000
--- a/ext/fg/js/frontend.js
+++ /dev/null
@@ -1,691 +0,0 @@
-/*
- * Copyright (C) 2016-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * DocumentUtil
- * TextScanner
- * TextSourceElement
- * TextSourceRange
- * api
- */
-
-class Frontend {
-    constructor({
-        pageType,
-        popupFactory,
-        depth,
-        tabId,
-        frameId,
-        parentPopupId,
-        parentFrameId,
-        useProxyPopup,
-        canUseWindowPopup=true,
-        allowRootFramePopupProxy,
-        childrenSupported=true,
-        hotkeyHandler
-    }) {
-        this._pageType = pageType;
-        this._popupFactory = popupFactory;
-        this._depth = depth;
-        this._tabId = tabId;
-        this._frameId = frameId;
-        this._parentPopupId = parentPopupId;
-        this._parentFrameId = parentFrameId;
-        this._useProxyPopup = useProxyPopup;
-        this._canUseWindowPopup = canUseWindowPopup;
-        this._allowRootFramePopupProxy = allowRootFramePopupProxy;
-        this._childrenSupported = childrenSupported;
-        this._hotkeyHandler = hotkeyHandler;
-        this._popup = null;
-        this._disabledOverride = false;
-        this._options = null;
-        this._pageZoomFactor = 1.0;
-        this._contentScale = 1.0;
-        this._lastShowPromise = Promise.resolve();
-        this._documentUtil = new DocumentUtil();
-        this._textScanner = new TextScanner({
-            node: window,
-            ignoreElements: this._ignoreElements.bind(this),
-            ignorePoint: this._ignorePoint.bind(this),
-            getSearchContext: this._getSearchContext.bind(this),
-            documentUtil: this._documentUtil,
-            searchTerms: true,
-            searchKanji: true
-        });
-        this._popupCache = new Map();
-        this._popupEventListeners = new EventListenerCollection();
-        this._updatePopupToken = null;
-        this._clearSelectionTimer = null;
-        this._isPointerOverPopup = false;
-        this._optionsContextOverride = null;
-
-        this._runtimeMessageHandlers = new Map([
-            ['requestFrontendReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}],
-            ['setAllVisibleOverride',         {async: true,  handler: this._onApiSetAllVisibleOverride.bind(this)}],
-            ['clearAllVisibleOverride',       {async: true,  handler: this._onApiClearAllVisibleOverride.bind(this)}]
-        ]);
-
-        this._hotkeyHandler.registerActions([
-            ['scanSelectedText', this._onActionScanSelectedText.bind(this)]
-        ]);
-    }
-
-    get canClearSelection() {
-        return this._textScanner.canClearSelection;
-    }
-
-    set canClearSelection(value) {
-        this._textScanner.canClearSelection = value;
-    }
-
-    get popup() {
-        return this._popup;
-    }
-
-    async prepare() {
-        await this.updateOptions();
-        try {
-            const {zoomFactor} = await api.getZoom();
-            this._pageZoomFactor = zoomFactor;
-        } catch (e) {
-            // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank)
-        }
-
-        this._textScanner.prepare();
-
-        window.addEventListener('resize', this._onResize.bind(this), false);
-        DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this));
-
-        const visualViewport = window.visualViewport;
-        if (visualViewport !== null && typeof visualViewport === 'object') {
-            visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this));
-            visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this));
-        }
-
-        yomichan.on('optionsUpdated', this.updateOptions.bind(this));
-        yomichan.on('zoomChanged', this._onZoomChanged.bind(this));
-        yomichan.on('closePopups', this._onClosePopups.bind(this));
-        chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this));
-
-        this._textScanner.on('clearSelection', this._onClearSelection.bind(this));
-        this._textScanner.on('searched', this._onSearched.bind(this));
-
-        api.crossFrame.registerHandlers([
-            ['closePopup',              {async: false, handler: this._onApiClosePopup.bind(this)}],
-            ['copySelection',           {async: false, handler: this._onApiCopySelection.bind(this)}],
-            ['getSelectionText',        {async: false, handler: this._onApiGetSelectionText.bind(this)}],
-            ['getPopupInfo',            {async: false, handler: this._onApiGetPopupInfo.bind(this)}],
-            ['getPageInfo',             {async: false, handler: this._onApiGetPageInfo.bind(this)}],
-            ['getFrameSize',            {async: true,  handler: this._onApiGetFrameSize.bind(this)}],
-            ['setFrameSize',            {async: true,  handler: this._onApiSetFrameSize.bind(this)}]
-        ]);
-
-        this._updateContentScale();
-        this._signalFrontendReady();
-    }
-
-    setDisabledOverride(disabled) {
-        this._disabledOverride = disabled;
-        this._updateTextScannerEnabled();
-    }
-
-    setOptionsContextOverride(optionsContext) {
-        this._optionsContextOverride = optionsContext;
-    }
-
-    async setTextSource(textSource) {
-        this._textScanner.setCurrentTextSource(null);
-        await this._textScanner.search(textSource);
-    }
-
-    async updateOptions() {
-        try {
-            await this._updateOptionsInternal();
-        } catch (e) {
-            if (!yomichan.isExtensionUnloaded) {
-                throw e;
-            }
-        }
-    }
-
-    showContentCompleted() {
-        return this._lastShowPromise;
-    }
-
-    // Message handlers
-
-    _onMessageRequestFrontendReadyBroadcast({frameId}) {
-        this._signalFrontendReady(frameId);
-    }
-
-    // Action handlers
-
-    _onActionScanSelectedText() {
-        this._scanSelectedText();
-    }
-
-    // API message handlers
-
-    _onApiGetUrl() {
-        return window.location.href;
-    }
-
-    _onApiClosePopup() {
-        this._clearSelection(false);
-    }
-
-    _onApiCopySelection() {
-        // This will not work on Firefox if a popup has focus, which is usually the case when this function is called.
-        document.execCommand('copy');
-    }
-
-    _onApiGetSelectionText() {
-        return document.getSelection().toString();
-    }
-
-    _onApiGetPopupInfo() {
-        return {
-            popupId: (this._popup !== null ? this._popup.id : null)
-        };
-    }
-
-    _onApiGetPageInfo() {
-        return {
-            url: window.location.href,
-            documentTitle: document.title
-        };
-    }
-
-    async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) {
-        const result = await this._popupFactory.setAllVisibleOverride(value, priority);
-        if (awaitFrame) {
-            await promiseAnimationFrame(100);
-        }
-        return result;
-    }
-
-    async _onApiClearAllVisibleOverride({token}) {
-        return await this._popupFactory.clearAllVisibleOverride(token);
-    }
-
-    async _onApiGetFrameSize() {
-        return await this._popup.getFrameSize();
-    }
-
-    async _onApiSetFrameSize({width, height}) {
-        return await this._popup.setFrameSize(width, height);
-    }
-
-    // Private
-
-    _onResize() {
-        this._updatePopupPosition();
-    }
-
-    _onRuntimeMessage({action, params}, sender, callback) {
-        const messageHandler = this._runtimeMessageHandlers.get(action);
-        if (typeof messageHandler === 'undefined') { return false; }
-        return yomichan.invokeMessageHandler(messageHandler, params, callback, sender);
-    }
-
-    _onZoomChanged({newZoomFactor}) {
-        this._pageZoomFactor = newZoomFactor;
-        this._updateContentScale();
-    }
-
-    _onClosePopups() {
-        this._clearSelection(true);
-    }
-
-    _onVisualViewportScroll() {
-        this._updatePopupPosition();
-    }
-
-    _onVisualViewportResize() {
-        this._updateContentScale();
-    }
-
-    _onClearSelection({passive}) {
-        this._stopClearSelectionDelayed();
-        if (this._popup !== null) {
-            this._popup.hide(!passive);
-            this._popup.clearAutoPlayTimer();
-            this._isPointerOverPopup = false;
-        }
-    }
-
-    _onSearched({type, definitions, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) {
-        const scanningOptions = this._options.scanning;
-
-        if (error !== null) {
-            if (yomichan.isExtensionUnloaded) {
-                if (textSource !== null && !passive) {
-                    this._showExtensionUnloaded(textSource);
-                }
-            } else {
-                yomichan.logError(error);
-            }
-        } if (type !== null) {
-            this._stopClearSelectionDelayed();
-            let focus = (eventType === 'mouseMove');
-            if (isObject(detail)) {
-                const focus2 = detail.focus;
-                if (typeof focus2 === 'boolean') { focus = focus2; }
-            }
-            this._showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext);
-        } else {
-            if (scanningOptions.autoHideResults) {
-                this._clearSelectionDelayed(scanningOptions.hideDelay, false);
-            }
-        }
-    }
-
-    _onPopupFramePointerOver() {
-        this._isPointerOverPopup = true;
-        this._stopClearSelectionDelayed();
-    }
-
-    _onPopupFramePointerOut() {
-        this._isPointerOverPopup = false;
-    }
-
-    _clearSelection(passive) {
-        this._stopClearSelectionDelayed();
-        this._textScanner.clearSelection(passive);
-    }
-
-    _clearSelectionDelayed(delay, restart, passive) {
-        if (!this._textScanner.hasSelection()) { return; }
-        if (delay > 0) {
-            if (this._clearSelectionTimer !== null && !restart) { return; } // Already running
-            this._stopClearSelectionDelayed();
-            this._clearSelectionTimer = setTimeout(() => {
-                this._clearSelectionTimer = null;
-                if (this._isPointerOverPopup) { return; }
-                this._clearSelection(passive);
-            }, delay);
-        } else {
-            this._clearSelection(passive);
-        }
-    }
-
-    _stopClearSelectionDelayed() {
-        if (this._clearSelectionTimer !== null) {
-            clearTimeout(this._clearSelectionTimer);
-            this._clearSelectionTimer = null;
-        }
-    }
-
-    async _updateOptionsInternal() {
-        const optionsContext = await this._getOptionsContext();
-        const options = await api.optionsGet(optionsContext);
-        const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
-        this._options = options;
-
-        this._hotkeyHandler.setHotkeys('web', options.inputs.hotkeys);
-
-        await this._updatePopup();
-
-        const preventMiddleMouse = this._getPreventMiddleMouseValueForPageType(scanningOptions.preventMiddleMouse);
-        this._textScanner.setOptions({
-            inputs: scanningOptions.inputs,
-            deepContentScan: scanningOptions.deepDomScan,
-            selectText: scanningOptions.selectText,
-            delay: scanningOptions.delay,
-            touchInputEnabled: scanningOptions.touchInputEnabled,
-            pointerEventsEnabled: scanningOptions.pointerEventsEnabled,
-            scanLength: scanningOptions.length,
-            layoutAwareScan: scanningOptions.layoutAwareScan,
-            preventMiddleMouse,
-            sentenceParsingOptions
-        });
-        this._updateTextScannerEnabled();
-
-        if (this._pageType !== 'web') {
-            const excludeSelectors = ['.scan-disable', '.scan-disable *'];
-            if (!scanningOptions.enableOnPopupExpressions) {
-                excludeSelectors.push('.source-text', '.source-text *');
-            }
-            this._textScanner.excludeSelector = excludeSelectors.join(',');
-        }
-
-        this._updateContentScale();
-
-        await this._textScanner.searchLast();
-    }
-
-    async _updatePopup() {
-        const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general;
-        const isIframe = !this._useProxyPopup && (window !== window.parent);
-
-        const currentPopup = this._popup;
-
-        let popupPromise;
-        if (usePopupWindow && this._canUseWindowPopup) {
-            popupPromise = this._popupCache.get('window');
-            if (typeof popupPromise === 'undefined') {
-                popupPromise = this._getPopupWindow();
-                this._popupCache.set('window', popupPromise);
-            }
-        } else if (
-            isIframe &&
-            showIframePopupsInRootFrame &&
-            DocumentUtil.getFullscreenElement() === null &&
-            this._allowRootFramePopupProxy
-        ) {
-            popupPromise = this._popupCache.get('iframe');
-            if (typeof popupPromise === 'undefined') {
-                popupPromise = this._getIframeProxyPopup();
-                this._popupCache.set('iframe', popupPromise);
-            }
-        } else if (this._useProxyPopup) {
-            popupPromise = this._popupCache.get('proxy');
-            if (typeof popupPromise === 'undefined') {
-                popupPromise = this._getProxyPopup();
-                this._popupCache.set('proxy', popupPromise);
-            }
-        } else {
-            popupPromise = this._popupCache.get('default');
-            if (typeof popupPromise === 'undefined') {
-                popupPromise = this._getDefaultPopup();
-                this._popupCache.set('default', popupPromise);
-            }
-        }
-
-        // The token below is used as a unique identifier to ensure that a new _updatePopup call
-        // hasn't been started during the await.
-        const token = {};
-        this._updatePopupToken = token;
-        const popup = await popupPromise;
-        const optionsContext = await this._getOptionsContext();
-        if (this._updatePopupToken !== token) { return; }
-        if (popup !== null) {
-            await popup.setOptionsContext(optionsContext);
-        }
-        if (this._updatePopupToken !== token) { return; }
-
-        if (popup !== currentPopup) {
-            this._clearSelection(true);
-        }
-
-        this._popupEventListeners.removeAllEventListeners();
-        this._popup = popup;
-        if (popup !== null) {
-            this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this));
-            this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this));
-        }
-        this._isPointerOverPopup = false;
-    }
-
-    async _getDefaultPopup() {
-        const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument);
-        if (isXmlDocument) {
-            return null;
-        }
-
-        return await this._popupFactory.getOrCreatePopup({
-            frameId: this._frameId,
-            depth: this._depth,
-            childrenSupported: this._childrenSupported
-        });
-    }
-
-    async _getProxyPopup() {
-        return await this._popupFactory.getOrCreatePopup({
-            frameId: this._parentFrameId,
-            depth: this._depth,
-            parentPopupId: this._parentPopupId,
-            childrenSupported: this._childrenSupported
-        });
-    }
-
-    async _getIframeProxyPopup() {
-        const targetFrameId = 0; // Root frameId
-        try {
-            await this._waitForFrontendReady(targetFrameId);
-        } catch (e) {
-            // Root frame not available
-            return await this._getDefaultPopup();
-        }
-
-        const {popupId} = await api.crossFrame.invoke(targetFrameId, 'getPopupInfo');
-        if (popupId === null) {
-            return null;
-        }
-
-        const popup = await this._popupFactory.getOrCreatePopup({
-            frameId: targetFrameId,
-            id: popupId,
-            childrenSupported: this._childrenSupported
-        });
-        popup.on('offsetNotFound', () => {
-            this._allowRootFramePopupProxy = false;
-            this._updatePopup();
-        });
-        return popup;
-    }
-
-    async _getPopupWindow() {
-        return await this._popupFactory.getOrCreatePopup({
-            depth: this._depth,
-            popupWindow: true,
-            childrenSupported: this._childrenSupported
-        });
-    }
-
-    _ignoreElements() {
-        if (this._popup !== null) {
-            const container = this._popup.container;
-            if (container !== null) {
-                return [container];
-            }
-        }
-        return [];
-    }
-
-    async _ignorePoint(x, y) {
-        try {
-            return this._popup !== null && await this._popup.containsPoint(x, y);
-        } catch (e) {
-            if (!yomichan.isExtensionUnloaded) {
-                throw e;
-            }
-            return false;
-        }
-    }
-
-    _showExtensionUnloaded(textSource) {
-        if (textSource === null) {
-            textSource = this._textScanner.getCurrentTextSource();
-            if (textSource === null) { return; }
-        }
-        this._showPopupContent(textSource, null);
-    }
-
-    _showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext) {
-        const query = textSource.text();
-        const {url} = optionsContext;
-        const details = {
-            focus,
-            history: false,
-            params: {
-                type,
-                query,
-                wildcards: 'off'
-            },
-            state: {
-                focusEntry: 0,
-                optionsContext,
-                url,
-                sentence,
-                documentTitle
-            },
-            content: {
-                definitions,
-                contentOrigin: {
-                    tabId: this._tabId,
-                    frameId: this._frameId
-                }
-            }
-        };
-        if (textSource instanceof TextSourceElement && textSource.fullContent !== query) {
-            details.params.full = textSource.fullContent;
-            details.params['full-visible'] = 'true';
-        }
-        this._showPopupContent(textSource, optionsContext, details);
-    }
-
-    _showPopupContent(textSource, optionsContext, details=null) {
-        this._lastShowPromise = (
-            this._popup !== null ?
-            this._popup.showContent(
-                {
-                    optionsContext,
-                    elementRect: textSource.getRect(),
-                    writingMode: textSource.getWritingMode()
-                },
-                details
-            ) :
-            Promise.resolve()
-        );
-        this._lastShowPromise.catch((error) => {
-            if (yomichan.isExtensionUnloaded) { return; }
-            yomichan.logError(error);
-        });
-        return this._lastShowPromise;
-    }
-
-    _updateTextScannerEnabled() {
-        const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride);
-        this._textScanner.setEnabled(enabled);
-    }
-
-    _updateContentScale() {
-        const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general;
-        let contentScale = popupScalingFactor;
-        if (popupScaleRelativeToPageZoom) {
-            contentScale /= this._pageZoomFactor;
-        }
-        if (popupScaleRelativeToVisualViewport) {
-            const visualViewport = window.visualViewport;
-            const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0);
-            contentScale /= visualViewportScale;
-        }
-        if (contentScale === this._contentScale) { return; }
-
-        this._contentScale = contentScale;
-        if (this._popup !== null) {
-            this._popup.setContentScale(this._contentScale);
-        }
-        this._updatePopupPosition();
-    }
-
-    async _updatePopupPosition() {
-        const textSource = this._textScanner.getCurrentTextSource();
-        if (
-            textSource !== null &&
-            this._popup !== null &&
-            await this._popup.isVisible()
-        ) {
-            this._showPopupContent(textSource, null);
-        }
-    }
-
-    _signalFrontendReady(targetFrameId=null) {
-        const params = {frameId: this._frameId};
-        if (targetFrameId === null) {
-            api.broadcastTab('frontendReady', params);
-        } else {
-            api.sendMessageToFrame(targetFrameId, 'frontendReady', params);
-        }
-    }
-
-    async _waitForFrontendReady(frameId) {
-        const promise = yomichan.getTemporaryListenerResult(
-            chrome.runtime.onMessage,
-            ({action, params}, {resolve}) => {
-                if (
-                    action === 'frontendReady' &&
-                    params.frameId === frameId
-                ) {
-                    resolve();
-                }
-            },
-            10000
-        );
-        api.broadcastTab('requestFrontendReadyBroadcast', {frameId: this._frameId});
-        await promise;
-    }
-
-    _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) {
-        switch (this._pageType) {
-            case 'web': return preventMiddleMouseOptions.onWebPages;
-            case 'popup': return preventMiddleMouseOptions.onPopupPages;
-            case 'search': return preventMiddleMouseOptions.onSearchPages;
-            default: return false;
-        }
-    }
-
-    async _getOptionsContext() {
-        let optionsContext = this._optionsContextOverride;
-        if (optionsContext === null) {
-            optionsContext = (await this._getSearchContext()).optionsContext;
-        }
-        return optionsContext;
-    }
-
-    async _getSearchContext() {
-        let url = window.location.href;
-        let documentTitle = document.title;
-        if (this._useProxyPopup) {
-            try {
-                ({url, documentTitle} = await api.crossFrame.invoke(this._parentFrameId, 'getPageInfo', {}));
-            } catch (e) {
-                // NOP
-            }
-        }
-
-        let optionsContext = this._optionsContextOverride;
-        if (optionsContext === null) {
-            optionsContext = {depth: this._depth, url};
-        }
-
-        return {
-            optionsContext,
-            detail: {documentTitle}
-        };
-    }
-
-    async _scanSelectedText() {
-        const range = this._getFirstNonEmptySelectionRange();
-        if (range === null) { return false; }
-        const source = new TextSourceRange(range, range.toString(), null, null);
-        await this._textScanner.search(source, {focus: true});
-        return true;
-    }
-
-    _getFirstNonEmptySelectionRange() {
-        const selection = window.getSelection();
-        for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
-            const range = selection.getRangeAt(i);
-            if (range.toString().length > 0) {
-                return range;
-            }
-        }
-        return null;
-    }
-}
diff --git a/ext/fg/js/popup-factory.js b/ext/fg/js/popup-factory.js
deleted file mode 100644
index 7571d7ab..00000000
--- a/ext/fg/js/popup-factory.js
+++ /dev/null
@@ -1,319 +0,0 @@
-/*
- * Copyright (C) 2019-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * FrameOffsetForwarder
- * Popup
- * PopupProxy
- * PopupWindow
- * api
- */
-
-class PopupFactory {
-    constructor(frameId) {
-        this._frameId = frameId;
-        this._frameOffsetForwarder = new FrameOffsetForwarder(frameId);
-        this._popups = new Map();
-        this._allPopupVisibilityTokenMap = new Map();
-    }
-
-    // Public functions
-
-    prepare() {
-        this._frameOffsetForwarder.prepare();
-        api.crossFrame.registerHandlers([
-            ['getOrCreatePopup',     {async: true,  handler: this._onApiGetOrCreatePopup.bind(this)}],
-            ['setOptionsContext',    {async: true,  handler: this._onApiSetOptionsContext.bind(this)}],
-            ['hide',                 {async: false, handler: this._onApiHide.bind(this)}],
-            ['isVisible',            {async: true,  handler: this._onApiIsVisibleAsync.bind(this)}],
-            ['setVisibleOverride',   {async: true,  handler: this._onApiSetVisibleOverride.bind(this)}],
-            ['clearVisibleOverride', {async: true,  handler: this._onApiClearVisibleOverride.bind(this)}],
-            ['containsPoint',        {async: true,  handler: this._onApiContainsPoint.bind(this)}],
-            ['showContent',          {async: true,  handler: this._onApiShowContent.bind(this)}],
-            ['setCustomCss',         {async: false, handler: this._onApiSetCustomCss.bind(this)}],
-            ['clearAutoPlayTimer',   {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}],
-            ['setContentScale',      {async: false, handler: this._onApiSetContentScale.bind(this)}],
-            ['updateTheme',          {async: false, handler: this._onApiUpdateTheme.bind(this)}],
-            ['setCustomOuterCss',    {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}],
-            ['popup.getFrameSize',   {async: true,  handler: this._onApiGetFrameSize.bind(this)}],
-            ['popup.setFrameSize',   {async: true,  handler: this._onApiSetFrameSize.bind(this)}]
-        ]);
-    }
-
-    async getOrCreatePopup({
-        frameId=null,
-        id=null,
-        parentPopupId=null,
-        depth=null,
-        popupWindow=false,
-        childrenSupported=false
-    }) {
-        // Find by existing id
-        if (id !== null) {
-            const popup = this._popups.get(id);
-            if (typeof popup !== 'undefined') {
-                return popup;
-            }
-        }
-
-        // Find by existing parent id
-        let parent = null;
-        if (parentPopupId !== null) {
-            parent = this._popups.get(parentPopupId);
-            if (typeof parent !== 'undefined') {
-                const popup = parent.child;
-                if (popup !== null) {
-                    return popup;
-                }
-            } else {
-                parent = null;
-            }
-        }
-
-        // Depth
-        if (parent !== null) {
-            if (depth !== null) {
-                throw new Error('Depth cannot be set when parent exists');
-            }
-            depth = parent.depth + 1;
-        } else if (depth === null) {
-            depth = 0;
-        }
-
-        if (popupWindow) {
-            // New unique id
-            if (id === null) {
-                id = generateId(16);
-            }
-            const popup = new PopupWindow({
-                id,
-                depth,
-                frameId: this._frameId
-            });
-            this._popups.set(id, popup);
-            return popup;
-        } else if (frameId === this._frameId) {
-            // New unique id
-            if (id === null) {
-                id = generateId(16);
-            }
-            const popup = new Popup({
-                id,
-                depth,
-                frameId: this._frameId,
-                childrenSupported
-            });
-            if (parent !== null) {
-                if (parent.child !== null) {
-                    throw new Error('Parent popup already has a child');
-                }
-                popup.parent = parent;
-                parent.child = popup;
-            }
-            this._popups.set(id, popup);
-            popup.prepare();
-            return popup;
-        } else {
-            if (frameId === null) {
-                throw new Error('Invalid frameId');
-            }
-            const useFrameOffsetForwarder = (parentPopupId === null);
-            ({id, depth, frameId} = await api.crossFrame.invoke(frameId, 'getOrCreatePopup', {
-                id,
-                parentPopupId,
-                frameId,
-                childrenSupported
-            }));
-            const popup = new PopupProxy({
-                id,
-                depth,
-                frameId,
-                frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null
-            });
-            this._popups.set(id, popup);
-            return popup;
-        }
-    }
-
-    async setAllVisibleOverride(value, priority) {
-        const promises = [];
-        const errors = [];
-        for (const popup of this._popups.values()) {
-            const promise = popup.setVisibleOverride(value, priority)
-                .then(
-                    (token) => ({popup, token}),
-                    (error) => { errors.push(error); return null; }
-                );
-            promises.push(promise);
-        }
-
-        const results = (await Promise.all(promises)).filter(({token}) => token !== null);
-
-        if (errors.length === 0) {
-            const token = generateId(16);
-            this._allPopupVisibilityTokenMap.set(token, results);
-            return token;
-        }
-
-        // Revert on error
-        await this._revertPopupVisibilityOverrides(results);
-        throw errors[0];
-    }
-
-    async clearAllVisibleOverride(token) {
-        const results = this._allPopupVisibilityTokenMap.get(token);
-        if (typeof results === 'undefined') { return false; }
-
-        this._allPopupVisibilityTokenMap.delete(token);
-        await this._revertPopupVisibilityOverrides(results);
-        return true;
-    }
-
-    // API message handlers
-
-    async _onApiGetOrCreatePopup(details) {
-        const popup = await this.getOrCreatePopup(details);
-        return {
-            id: popup.id,
-            depth: popup.depth,
-            frameId: popup.frameId
-        };
-    }
-
-    async _onApiSetOptionsContext({id, optionsContext, source}) {
-        const popup = this._getPopup(id);
-        return await popup.setOptionsContext(optionsContext, source);
-    }
-
-    _onApiHide({id, changeFocus}) {
-        const popup = this._getPopup(id);
-        return popup.hide(changeFocus);
-    }
-
-    async _onApiIsVisibleAsync({id}) {
-        const popup = this._getPopup(id);
-        return await popup.isVisible();
-    }
-
-    async _onApiSetVisibleOverride({id, value, priority}) {
-        const popup = this._getPopup(id);
-        return await popup.setVisibleOverride(value, priority);
-    }
-
-    async _onApiClearVisibleOverride({id, token}) {
-        const popup = this._getPopup(id);
-        return await popup.clearVisibleOverride(token);
-    }
-
-    async _onApiContainsPoint({id, x, y}) {
-        const popup = this._getPopup(id);
-        [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y);
-        return await popup.containsPoint(x, y);
-    }
-
-    async _onApiShowContent({id, details, displayDetails}) {
-        const popup = this._getPopup(id);
-        if (!this._popupCanShow(popup)) { return; }
-
-        const {elementRect} = details;
-        if (typeof elementRect !== 'undefined') {
-            details.elementRect = this._convertJsonRectToDOMRect(popup, elementRect);
-        }
-
-        return await popup.showContent(details, displayDetails);
-    }
-
-    _onApiSetCustomCss({id, css}) {
-        const popup = this._getPopup(id);
-        return popup.setCustomCss(css);
-    }
-
-    _onApiClearAutoPlayTimer({id}) {
-        const popup = this._getPopup(id);
-        return popup.clearAutoPlayTimer();
-    }
-
-    _onApiSetContentScale({id, scale}) {
-        const popup = this._getPopup(id);
-        return popup.setContentScale(scale);
-    }
-
-    _onApiUpdateTheme({id}) {
-        const popup = this._getPopup(id);
-        return popup.updateTheme();
-    }
-
-    _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) {
-        const popup = this._getPopup(id);
-        return popup.setCustomOuterCss(css, useWebExtensionApi);
-    }
-
-    async _onApiGetFrameSize({id}) {
-        const popup = this._getPopup(id);
-        return await popup.getFrameSize();
-    }
-
-    async _onApiSetFrameSize({id, width, height}) {
-        const popup = this._getPopup(id);
-        return await popup.setFrameSize(width, height);
-    }
-
-    // Private functions
-
-    _getPopup(id) {
-        const popup = this._popups.get(id);
-        if (typeof popup === 'undefined') {
-            throw new Error(`Invalid popup ID ${id}`);
-        }
-        return popup;
-    }
-
-    _convertJsonRectToDOMRect(popup, jsonRect) {
-        const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y);
-        return new DOMRect(x, y, jsonRect.width, jsonRect.height);
-    }
-
-    _convertPopupPointToRootPagePoint(popup, x, y) {
-        const parent = popup.parent;
-        if (parent !== null) {
-            const popupRect = parent.getFrameRect();
-            x += popupRect.x;
-            y += popupRect.y;
-        }
-        return [x, y];
-    }
-
-    _popupCanShow(popup) {
-        const parent = popup.parent;
-        return parent === null || parent.isVisibleSync();
-    }
-
-    async _revertPopupVisibilityOverrides(overrides) {
-        const promises = [];
-        for (const value of overrides) {
-            if (value === null) { continue; }
-            const {popup, token} = value;
-            const promise = popup.clearVisibleOverride(token)
-                .then(
-                    (v) => v,
-                    () => false
-                );
-            promises.push(promise);
-        }
-        return await Promise.all(promises);
-    }
-}
diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js
deleted file mode 100644
index b2e81824..00000000
--- a/ext/fg/js/popup-proxy.js
+++ /dev/null
@@ -1,218 +0,0 @@
-/*
- * Copyright (C) 2019-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * api
- */
-
-class PopupProxy extends EventDispatcher {
-    constructor({
-        id,
-        depth,
-        frameId,
-        frameOffsetForwarder
-    }) {
-        super();
-        this._id = id;
-        this._depth = depth;
-        this._frameId = frameId;
-        this._frameOffsetForwarder = frameOffsetForwarder;
-
-        this._frameOffset = [0, 0];
-        this._frameOffsetPromise = null;
-        this._frameOffsetUpdatedAt = null;
-        this._frameOffsetExpireTimeout = 1000;
-    }
-
-    // Public properties
-
-    get id() {
-        return this._id;
-    }
-
-    get parent() {
-        return null;
-    }
-
-    set parent(value) {
-        throw new Error('Not supported on PopupProxy');
-    }
-
-    get child() {
-        return null;
-    }
-
-    set child(value) {
-        throw new Error('Not supported on PopupProxy');
-    }
-
-    get depth() {
-        return this._depth;
-    }
-
-    get frameContentWindow() {
-        return null;
-    }
-
-    get container() {
-        return null;
-    }
-
-    get frameId() {
-        return this._frameId;
-    }
-
-    // Public functions
-
-    setOptionsContext(optionsContext, source) {
-        return this._invokeSafe('setOptionsContext', {id: this._id, optionsContext, source});
-    }
-
-    hide(changeFocus) {
-        return this._invokeSafe('hide', {id: this._id, changeFocus});
-    }
-
-    isVisible() {
-        return this._invokeSafe('isVisible', {id: this._id}, false);
-    }
-
-    setVisibleOverride(value, priority) {
-        return this._invokeSafe('setVisibleOverride', {id: this._id, value, priority}, null);
-    }
-
-    clearVisibleOverride(token) {
-        return this._invokeSafe('clearVisibleOverride', {id: this._id, token}, false);
-    }
-
-    async containsPoint(x, y) {
-        if (this._frameOffsetForwarder !== null) {
-            await this._updateFrameOffset();
-            [x, y] = this._applyFrameOffset(x, y);
-        }
-        return await this._invokeSafe('containsPoint', {id: this._id, x, y}, false);
-    }
-
-    async showContent(details, displayDetails) {
-        const {elementRect} = details;
-        if (typeof elementRect !== 'undefined') {
-            let {x, y, width, height} = elementRect;
-            if (this._frameOffsetForwarder !== null) {
-                await this._updateFrameOffset();
-                [x, y] = this._applyFrameOffset(x, y);
-            }
-            details.elementRect = {x, y, width, height};
-        }
-        return await this._invokeSafe('showContent', {id: this._id, details, displayDetails});
-    }
-
-    setCustomCss(css) {
-        return this._invokeSafe('setCustomCss', {id: this._id, css});
-    }
-
-    clearAutoPlayTimer() {
-        return this._invokeSafe('clearAutoPlayTimer', {id: this._id});
-    }
-
-    setContentScale(scale) {
-        return this._invokeSafe('setContentScale', {id: this._id, scale});
-    }
-
-    isVisibleSync() {
-        throw new Error('Not supported on PopupProxy');
-    }
-
-    updateTheme() {
-        return this._invokeSafe('updateTheme', {id: this._id});
-    }
-
-    setCustomOuterCss(css, useWebExtensionApi) {
-        return this._invokeSafe('setCustomOuterCss', {id: this._id, css, useWebExtensionApi});
-    }
-
-    getFrameRect() {
-        return new DOMRect(0, 0, 0, 0);
-    }
-
-    getFrameSize() {
-        return this._invokeSafe('popup.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false});
-    }
-
-    setFrameSize(width, height) {
-        return this._invokeSafe('popup.setFrameSize', {id: this._id, width, height});
-    }
-
-    // Private
-
-    _invoke(action, params={}) {
-        return api.crossFrame.invoke(this._frameId, action, params);
-    }
-
-    async _invokeSafe(action, params={}, defaultReturnValue) {
-        try {
-            return await this._invoke(action, params);
-        } catch (e) {
-            if (!yomichan.isExtensionUnloaded) { throw e; }
-            return defaultReturnValue;
-        }
-    }
-
-    async _updateFrameOffset() {
-        const now = Date.now();
-        const firstRun = this._frameOffsetUpdatedAt === null;
-        const expired = firstRun || this._frameOffsetUpdatedAt < now - this._frameOffsetExpireTimeout;
-        if (this._frameOffsetPromise === null && !expired) { return; }
-
-        if (this._frameOffsetPromise !== null) {
-            if (firstRun) {
-                await this._frameOffsetPromise;
-            }
-            return;
-        }
-
-        const promise = this._updateFrameOffsetInner(now);
-        if (firstRun) {
-            await promise;
-        }
-    }
-
-    async _updateFrameOffsetInner(now) {
-        this._frameOffsetPromise = this._frameOffsetForwarder.getOffset();
-        try {
-            let offset = null;
-            try {
-                offset = await this._frameOffsetPromise;
-            } catch (e) {
-                // NOP
-            }
-            this._frameOffset = offset !== null ? offset : [0, 0];
-            if (offset === null) {
-                this.trigger('offsetNotFound');
-                return;
-            }
-            this._frameOffsetUpdatedAt = now;
-        } catch (e) {
-            yomichan.logError(e);
-        } finally {
-            this._frameOffsetPromise = null;
-        }
-    }
-
-    _applyFrameOffset(x, y) {
-        const [offsetX, offsetY] = this._frameOffset;
-        return [x + offsetX, y + offsetY];
-    }
-}
diff --git a/ext/fg/js/popup-window.js b/ext/fg/js/popup-window.js
deleted file mode 100644
index 5fa0c647..00000000
--- a/ext/fg/js/popup-window.js
+++ /dev/null
@@ -1,169 +0,0 @@
-/*
- * Copyright (C) 2020-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * api
- */
-
-class PopupWindow extends EventDispatcher {
-    constructor({
-        id,
-        depth,
-        frameId
-    }) {
-        super();
-        this._id = id;
-        this._depth = depth;
-        this._frameId = frameId;
-        this._popupTabId = null;
-    }
-
-    // Public properties
-
-    get id() {
-        return this._id;
-    }
-
-    get parent() {
-        return null;
-    }
-
-    set parent(value) {
-        throw new Error('Not supported on PopupProxy');
-    }
-
-    get child() {
-        return null;
-    }
-
-    set child(value) {
-        throw new Error('Not supported on PopupProxy');
-    }
-
-    get depth() {
-        return this._depth;
-    }
-
-    get frameContentWindow() {
-        return null;
-    }
-
-    get container() {
-        return null;
-    }
-
-    get frameId() {
-        return this._frameId;
-    }
-
-
-    // Public functions
-
-    setOptionsContext(optionsContext, source) {
-        return this._invoke(false, 'setOptionsContext', {id: this._id, optionsContext, source});
-    }
-
-    hide(_changeFocus) {
-        // NOP
-    }
-
-    async isVisible() {
-        return (this._popupTabId !== null && await api.isTabSearchPopup(this._popupTabId));
-    }
-
-    async setVisibleOverride(_value, _priority) {
-        return null;
-    }
-
-    clearVisibleOverride(_token) {
-        return false;
-    }
-
-    async containsPoint(_x, _y) {
-        return false;
-    }
-
-    async showContent(_details, displayDetails) {
-        if (displayDetails === null) { return; }
-        await this._invoke(true, 'setContent', {id: this._id, details: displayDetails});
-    }
-
-    setCustomCss(css) {
-        return this._invoke(false, 'setCustomCss', {id: this._id, css});
-    }
-
-    clearAutoPlayTimer() {
-        return this._invoke(false, 'clearAutoPlayTimer', {id: this._id});
-    }
-
-    setContentScale(_scale) {
-        // NOP
-    }
-
-    isVisibleSync() {
-        throw new Error('Not supported on PopupWindow');
-    }
-
-    updateTheme() {
-        // NOP
-    }
-
-    async setCustomOuterCss(_css, _useWebExtensionApi) {
-        // NOP
-    }
-
-    getFrameRect() {
-        return new DOMRect(0, 0, 0, 0);
-    }
-
-    async getFrameSize() {
-        return {width: 0, height: 0, valid: false};
-    }
-
-    async setFrameSize(_width, _height) {
-        return false;
-    }
-
-    // Private
-
-    async _invoke(open, action, params={}, defaultReturnValue) {
-        if (yomichan.isExtensionUnloaded) {
-            return defaultReturnValue;
-        }
-
-        const frameId = 0;
-        if (this._popupTabId !== null) {
-            try {
-                return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params});
-            } catch (e) {
-                if (yomichan.isExtensionUnloaded) {
-                    open = false;
-                }
-            }
-            this._popupTabId = null;
-        }
-
-        if (!open) {
-            return defaultReturnValue;
-        }
-
-        const {tabId} = await api.getOrCreateSearchPopup({focus: 'ifCreated'});
-        this._popupTabId = tabId;
-
-        return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params});
-    }
-}
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
deleted file mode 100644
index 75b74257..00000000
--- a/ext/fg/js/popup.js
+++ /dev/null
@@ -1,687 +0,0 @@
-/*
- * Copyright (C) 2016-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * DocumentUtil
- * FrameClient
- * api
- * dynamicLoader
- */
-
-class Popup extends EventDispatcher {
-    constructor({
-        id,
-        depth,
-        frameId,
-        childrenSupported
-    }) {
-        super();
-        this._id = id;
-        this._depth = depth;
-        this._frameId = frameId;
-        this._childrenSupported = childrenSupported;
-        this._parent = null;
-        this._child = null;
-        this._injectPromise = null;
-        this._injectPromiseComplete = false;
-        this._visible = new DynamicProperty(false);
-        this._options = null;
-        this._optionsContext = null;
-        this._contentScale = 1.0;
-        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
-
-        this._frameSizeContentScale = null;
-        this._frameClient = null;
-        this._frame = document.createElement('iframe');
-        this._frame.className = 'yomichan-popup';
-        this._frame.style.width = '0';
-        this._frame.style.height = '0';
-
-        this._container = this._frame;
-        this._shadow = null;
-
-        this._fullscreenEventListeners = new EventListenerCollection();
-    }
-
-    // Public properties
-
-    get id() {
-        return this._id;
-    }
-
-    get parent() {
-        return this._parent;
-    }
-
-    set parent(value) {
-        this._parent = value;
-    }
-
-    get child() {
-        return this._child;
-    }
-
-    set child(value) {
-        this._child = value;
-    }
-
-    get depth() {
-        return this._depth;
-    }
-
-    get frameContentWindow() {
-        return this._frame.contentWindow;
-    }
-
-    get container() {
-        return this._container;
-    }
-
-    get frameId() {
-        return this._frameId;
-    }
-
-    // Public functions
-
-    prepare() {
-        this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
-        this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
-        this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
-        this._frame.addEventListener('scroll', (e) => e.stopPropagation());
-        this._frame.addEventListener('load', this._onFrameLoad.bind(this));
-        this._visible.on('change', this._onVisibleChange.bind(this));
-        yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this));
-        this._onVisibleChange({value: this.isVisibleSync()});
-    }
-
-    async setOptionsContext(optionsContext) {
-        await this._setOptionsContext(optionsContext);
-        await this._invokeSafe('setOptionsContext', {optionsContext});
-    }
-
-    hide(changeFocus) {
-        if (!this.isVisibleSync()) {
-            return;
-        }
-
-        this._setVisible(false);
-        if (this._child !== null) {
-            this._child.hide(false);
-        }
-        if (changeFocus) {
-            this._focusParent();
-        }
-    }
-
-    async isVisible() {
-        return this.isVisibleSync();
-    }
-
-    async setVisibleOverride(value, priority) {
-        return this._visible.setOverride(value, priority);
-    }
-
-    async clearVisibleOverride(token) {
-        return this._visible.clearOverride(token);
-    }
-
-    async containsPoint(x, y) {
-        for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) {
-            const rect = popup.getFrameRect();
-            if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    async showContent(details, displayDetails) {
-        if (this._options === null) { throw new Error('Options not assigned'); }
-
-        const {optionsContext, elementRect, writingMode} = details;
-        if (optionsContext !== null) {
-            await this._setOptionsContextIfDifferent(optionsContext);
-        }
-
-        if (typeof elementRect !== 'undefined' && typeof writingMode !== 'undefined') {
-            await this._show(elementRect, writingMode);
-        }
-
-        if (displayDetails !== null) {
-            this._invokeSafe('setContent', {details: displayDetails});
-        }
-    }
-
-    setCustomCss(css) {
-        this._invokeSafe('setCustomCss', {css});
-    }
-
-    clearAutoPlayTimer() {
-        this._invokeSafe('clearAutoPlayTimer');
-    }
-
-    setContentScale(scale) {
-        this._contentScale = scale;
-        this._frame.style.fontSize = `${scale}px`;
-        this._invokeSafe('setContentScale', {scale});
-    }
-
-    isVisibleSync() {
-        return this._visible.value;
-    }
-
-    updateTheme() {
-        const {popupTheme, popupOuterTheme} = this._options.general;
-        this._frame.dataset.theme = popupTheme;
-        this._frame.dataset.outerTheme = popupOuterTheme;
-        this._frame.dataset.siteColor = this._getSiteColor();
-    }
-
-    async setCustomOuterCss(css, useWebExtensionApi) {
-        let parentNode = null;
-        const inShadow = (this._shadow !== null);
-        if (inShadow) {
-            useWebExtensionApi = false;
-            parentNode = this._shadow;
-        }
-        const node = await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode);
-        this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow});
-    }
-
-    getFrameRect() {
-        return this._frame.getBoundingClientRect();
-    }
-
-    async getFrameSize() {
-        const rect = this._frame.getBoundingClientRect();
-        return {width: rect.width, height: rect.height, valid: true};
-    }
-
-    async setFrameSize(width, height) {
-        this._setFrameSize(width, height);
-        return true;
-    }
-
-    // Private functions
-
-    _onFrameMouseOver() {
-        this.trigger('framePointerOver', {});
-    }
-
-    _onFrameMouseOut() {
-        this.trigger('framePointerOut', {});
-    }
-
-    _inject() {
-        let injectPromise = this._injectPromise;
-        if (injectPromise === null) {
-            injectPromise = this._createInjectPromise();
-            this._injectPromise = injectPromise;
-            injectPromise.then(
-                () => {
-                    if (injectPromise !== this._injectPromise) { return; }
-                    this._injectPromiseComplete = true;
-                },
-                () => { this._resetFrame(); }
-            );
-        }
-        return injectPromise;
-    }
-
-    async _createInjectPromise() {
-        if (this._options === null) {
-            throw new Error('Options not initialized');
-        }
-
-        const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general;
-
-        await this._setUpContainer(usePopupShadowDom);
-
-        const setupFrame = (frame) => {
-            frame.removeAttribute('src');
-            frame.removeAttribute('srcdoc');
-            this._observeFullscreen(true);
-            this._onFullscreenChanged();
-            const url = chrome.runtime.getURL('/popup.html');
-            if (useSecurePopupFrameUrl) {
-                frame.contentDocument.location.href = url;
-            } else {
-                frame.setAttribute('src', url);
-            }
-        };
-
-        const frameClient = new FrameClient();
-        this._frameClient = frameClient;
-        await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame);
-
-        // Configure
-        await this._invokeSafe('configure', {
-            depth: this._depth,
-            parentPopupId: this._id,
-            parentFrameId: this._frameId,
-            childrenSupported: this._childrenSupported,
-            scale: this._contentScale,
-            optionsContext: this._optionsContext
-        });
-    }
-
-    _onFrameLoad() {
-        if (!this._injectPromiseComplete) { return; }
-        this._resetFrame();
-    }
-
-    _resetFrame() {
-        const parent = this._container.parentNode;
-        if (parent !== null) {
-            parent.removeChild(this._container);
-        }
-        this._frame.removeAttribute('src');
-        this._frame.removeAttribute('srcdoc');
-
-        this._frameClient = null;
-        this._injectPromise = null;
-        this._injectPromiseComplete = false;
-    }
-
-    async _setUpContainer(usePopupShadowDom) {
-        if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') {
-            const container = document.createElement('div');
-            container.style.setProperty('all', 'initial', 'important');
-            const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true});
-            shadow.appendChild(this._frame);
-
-            this._container = container;
-            this._shadow = shadow;
-        } else {
-            const frameParentNode = this._frame.parentNode;
-            if (frameParentNode !== null) {
-                frameParentNode.removeChild(this._frame);
-            }
-
-            this._container = this._frame;
-            this._shadow = null;
-        }
-
-        await this._injectStyles();
-    }
-
-    async _injectStyles() {
-        try {
-            await this._injectPopupOuterStylesheet();
-        } catch (e) {
-            // NOP
-        }
-
-        try {
-            await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true);
-        } catch (e) {
-            // NOP
-        }
-    }
-
-    async _injectPopupOuterStylesheet() {
-        let fileType = 'file';
-        let useWebExtensionApi = true;
-        let parentNode = null;
-        if (this._shadow !== null) {
-            fileType = 'file-content';
-            useWebExtensionApi = false;
-            parentNode = this._shadow;
-        }
-        await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode);
-    }
-
-    _observeFullscreen(observe) {
-        if (!observe) {
-            this._fullscreenEventListeners.removeAllEventListeners();
-            return;
-        }
-
-        if (this._fullscreenEventListeners.size > 0) {
-            // Already observing
-            return;
-        }
-
-        DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners);
-    }
-
-    _onFullscreenChanged() {
-        const parent = this._getFrameParentElement();
-        if (parent !== null && this._container.parentNode !== parent) {
-            parent.appendChild(this._container);
-        }
-    }
-
-    async _show(elementRect, writingMode) {
-        await this._inject();
-
-        const optionsGeneral = this._options.general;
-        const {popupDisplayMode} = optionsGeneral;
-        const frame = this._frame;
-        const frameRect = frame.getBoundingClientRect();
-
-        const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport);
-        const scale = this._contentScale;
-        const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale;
-        this._frameSizeContentScale = scale;
-        const getPositionArgs = [
-            elementRect,
-            Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale),
-            Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale),
-            viewport,
-            scale,
-            optionsGeneral,
-            writingMode
-        ];
-        let [x, y, width, height, below] = (
-            writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
-            this._getPositionForHorizontalText(...getPositionArgs) :
-            this._getPositionForVerticalText(...getPositionArgs)
-        );
-
-        frame.dataset.popupDisplayMode = popupDisplayMode;
-        frame.dataset.below = `${below}`;
-
-        if (popupDisplayMode === 'full-width') {
-            x = viewport.left;
-            y = below ? viewport.bottom - height : viewport.top;
-            width = viewport.right - viewport.left;
-        }
-
-        frame.style.left = `${x}px`;
-        frame.style.top = `${y}px`;
-        this._setFrameSize(width, height);
-
-        this._setVisible(true);
-        if (this._child !== null) {
-            this._child.hide(true);
-        }
-    }
-
-    _setFrameSize(width, height) {
-        const {style} = this._frame;
-        style.width = `${width}px`;
-        style.height = `${height}px`;
-    }
-
-    _setVisible(visible) {
-        this._visible.defaultValue = visible;
-    }
-
-    _onVisibleChange({value}) {
-        this._frame.style.setProperty('visibility', value ? 'visible' : 'hidden', 'important');
-    }
-
-    _focusParent() {
-        if (this._parent !== null) {
-            // Chrome doesn't like focusing iframe without contentWindow.
-            const contentWindow = this._parent.frameContentWindow;
-            if (contentWindow !== null) {
-                contentWindow.focus();
-            }
-        } else {
-            // Firefox doesn't like focusing window without first blurring the iframe.
-            // this._frame.contentWindow.blur() doesn't work on Firefox for some reason.
-            this._frame.blur();
-            // This is needed for Chrome.
-            window.focus();
-        }
-    }
-
-    _getSiteColor() {
-        const color = [255, 255, 255];
-        const {documentElement, body} = document;
-        if (documentElement !== null) {
-            this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
-        }
-        if (body !== null) {
-            this._addColor(color, window.getComputedStyle(body).backgroundColor);
-        }
-        const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
-        return dark ? 'dark' : 'light';
-    }
-
-    async _invoke(action, params={}) {
-        const contentWindow = this._frame.contentWindow;
-        if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
-
-        const message = this._frameClient.createMessage({action, params});
-        return await api.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message);
-    }
-
-    async _invokeSafe(action, params={}, defaultReturnValue) {
-        try {
-            return await this._invoke(action, params);
-        } catch (e) {
-            if (!yomichan.isExtensionUnloaded) { throw e; }
-            return defaultReturnValue;
-        }
-    }
-
-    _invokeWindow(action, params={}) {
-        const contentWindow = this._frame.contentWindow;
-        if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
-
-        const message = this._frameClient.createMessage({action, params});
-        contentWindow.postMessage(message, this._targetOrigin);
-    }
-
-    _onExtensionUnloaded() {
-        this._invokeWindow('extensionUnloaded');
-    }
-
-    _getFrameParentElement() {
-        const defaultParent = document.body;
-        const fullscreenElement = DocumentUtil.getFullscreenElement();
-        if (
-            fullscreenElement === null ||
-            fullscreenElement.shadowRoot ||
-            fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
-        ) {
-            return defaultParent;
-        }
-
-        switch (fullscreenElement.nodeName.toUpperCase()) {
-            case 'IFRAME':
-            case 'FRAME':
-                return defaultParent;
-        }
-
-        return fullscreenElement;
-    }
-
-    _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) {
-        const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
-        const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale;
-        const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale;
-
-        const [x, w] = this._getConstrainedPosition(
-            elementRect.right - horizontalOffset,
-            elementRect.left + horizontalOffset,
-            width,
-            viewport.left,
-            viewport.right,
-            true
-        );
-        const [y, h, below] = this._getConstrainedPositionBinary(
-            elementRect.top - verticalOffset,
-            elementRect.bottom + verticalOffset,
-            height,
-            viewport.top,
-            viewport.bottom,
-            preferBelow
-        );
-        return [x, y, w, h, below];
-    }
-
-    _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) {
-        const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
-        const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale;
-        const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale;
-
-        const [x, w] = this._getConstrainedPositionBinary(
-            elementRect.left - horizontalOffset,
-            elementRect.right + horizontalOffset,
-            width,
-            viewport.left,
-            viewport.right,
-            preferRight
-        );
-        const [y, h, below] = this._getConstrainedPosition(
-            elementRect.bottom - verticalOffset,
-            elementRect.top + verticalOffset,
-            height,
-            viewport.top,
-            viewport.bottom,
-            true
-        );
-        return [x, y, w, h, below];
-    }
-
-    _isVerticalTextPopupOnRight(positionPreference, writingMode) {
-        switch (positionPreference) {
-            case 'before':
-                return !this._isWritingModeLeftToRight(writingMode);
-            case 'after':
-                return this._isWritingModeLeftToRight(writingMode);
-            case 'left':
-                return false;
-            case 'right':
-                return true;
-            default:
-                return false;
-        }
-    }
-
-    _isWritingModeLeftToRight(writingMode) {
-        switch (writingMode) {
-            case 'vertical-lr':
-            case 'sideways-lr':
-                return true;
-            default:
-                return false;
-        }
-    }
-
-    _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
-        size = Math.min(size, maxLimit - minLimit);
-
-        let position;
-        if (after) {
-            position = Math.max(minLimit, positionAfter);
-            position = position - Math.max(0, (position + size) - maxLimit);
-        } else {
-            position = Math.min(maxLimit, positionBefore) - size;
-            position = position + Math.max(0, minLimit - position);
-        }
-
-        return [position, size, after];
-    }
-
-    _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
-        const overflowBefore = minLimit - (positionBefore - size);
-        const overflowAfter = (positionAfter + size) - maxLimit;
-
-        if (overflowAfter > 0 || overflowBefore > 0) {
-            after = (overflowAfter < overflowBefore);
-        }
-
-        let position;
-        if (after) {
-            size -= Math.max(0, overflowAfter);
-            position = Math.max(minLimit, positionAfter);
-        } else {
-            size -= Math.max(0, overflowBefore);
-            position = Math.min(maxLimit, positionBefore) - size;
-        }
-
-        return [position, size, after];
-    }
-
-    _addColor(target, cssColor) {
-        if (typeof cssColor !== 'string') { return; }
-
-        const color = this._getColorInfo(cssColor);
-        if (color === null) { return; }
-
-        const a = color[3];
-        if (a <= 0.0) { return; }
-
-        const aInv = 1.0 - a;
-        for (let i = 0; i < 3; ++i) {
-            target[i] = target[i] * aInv + color[i] * a;
-        }
-    }
-
-    _getColorInfo(cssColor) {
-        const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
-        if (m === null) { return null; }
-
-        const m4 = m[4];
-        return [
-            Number.parseInt(m[1], 10),
-            Number.parseInt(m[2], 10),
-            Number.parseInt(m[3], 10),
-            m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0
-        ];
-    }
-
-    _getViewport(useVisualViewport) {
-        const visualViewport = window.visualViewport;
-        if (visualViewport !== null && typeof visualViewport === 'object') {
-            const left = visualViewport.offsetLeft;
-            const top = visualViewport.offsetTop;
-            const width = visualViewport.width;
-            const height = visualViewport.height;
-            if (useVisualViewport) {
-                return {
-                    left,
-                    top,
-                    right: left + width,
-                    bottom: top + height
-                };
-            } else {
-                const scale = visualViewport.scale;
-                return {
-                    left: 0,
-                    top: 0,
-                    right: Math.max(left + width, width * scale),
-                    bottom: Math.max(top + height, height * scale)
-                };
-            }
-        }
-
-        const body = document.body;
-        return {
-            left: 0,
-            top: 0,
-            right: (body !== null ? body.clientWidth : 0),
-            bottom: window.innerHeight
-        };
-    }
-
-    async _setOptionsContext(optionsContext) {
-        this._optionsContext = optionsContext;
-        this._options = await api.optionsGet(optionsContext);
-        this.updateTheme();
-    }
-
-    async _setOptionsContextIfDifferent(optionsContext) {
-        if (deepEqual(this._optionsContext, optionsContext)) { return; }
-        await this._setOptionsContext(optionsContext);
-    }
-}
diff --git a/ext/fg/js/text-source-element.js b/ext/fg/js/text-source-element.js
deleted file mode 100644
index 45186636..00000000
--- a/ext/fg/js/text-source-element.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * Copyright (C) 2016-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-class TextSourceElement {
-    constructor(element, fullContent=null, startOffset=0, endOffset=0) {
-        this._element = element;
-        this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element));
-        this._startOffset = startOffset;
-        this._endOffset = endOffset;
-        this._content = this._fullContent.substring(this._startOffset, this._endOffset);
-    }
-
-    get element() {
-        return this._element;
-    }
-
-    get fullContent() {
-        return this._fullContent;
-    }
-
-    get startOffset() {
-        return this._startOffset;
-    }
-
-    get endOffset() {
-        return this._endOffset;
-    }
-
-    get isConnected() {
-        return this._element.isConnected;
-    }
-
-    clone() {
-        return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset);
-    }
-
-    cleanup() {
-        // NOP
-    }
-
-    text() {
-        return this._content;
-    }
-
-    setEndOffset(length, fromEnd=false) {
-        if (fromEnd) {
-            const delta = Math.min(this._fullContent.length - this._endOffset, length);
-            this._endOffset += delta;
-            this._content = this._fullContent.substring(this._startOffset, this._endOffset);
-            return delta;
-        } else {
-            const delta = Math.min(this._fullContent.length - this._startOffset, length);
-            this._endOffset = this._startOffset + delta;
-            this._content = this._fullContent.substring(this._startOffset, this._endOffset);
-            return delta;
-        }
-    }
-
-    setStartOffset(length) {
-        const delta = Math.min(this._startOffset, length);
-        this._startOffset -= delta;
-        this._content = this._fullContent.substring(this._startOffset, this._endOffset);
-        return delta;
-    }
-
-    collapse(toStart) {
-        if (toStart) {
-            this._endOffset = this._startOffset;
-        } else {
-            this._startOffset = this._endOffset;
-        }
-        this._content = '';
-    }
-
-    getRect() {
-        return this._element.getBoundingClientRect();
-    }
-
-    getWritingMode() {
-        return 'horizontal-tb';
-    }
-
-    select() {
-        // NOP
-    }
-
-    deselect() {
-        // NOP
-    }
-
-    hasSameStart(other) {
-        return (
-            typeof other === 'object' &&
-            other !== null &&
-            other instanceof TextSourceElement &&
-            this._element === other.element &&
-            this._fullContent === other.fullContent &&
-            this._startOffset === other.startOffset
-        );
-    }
-
-    getNodesInRange() {
-        return [this._element];
-    }
-
-    static getElementContent(element) {
-        let content;
-        switch (element.nodeName.toUpperCase()) {
-            case 'BUTTON':
-                content = element.textContent;
-                break;
-            case 'IMG':
-                content = element.getAttribute('alt') || '';
-                break;
-            default:
-                content = `${element.value}`;
-                break;
-        }
-
-        // Remove zero-width non-joiner
-        content = content.replace(/\u200c/g, '');
-
-        return content;
-    }
-}
diff --git a/ext/fg/js/text-source-range.js b/ext/fg/js/text-source-range.js
deleted file mode 100644
index 377016da..00000000
--- a/ext/fg/js/text-source-range.js
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- * Copyright (C) 2016-2021  Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * DOMTextScanner
- * DocumentUtil
- */
-
-class TextSourceRange {
-    constructor(range, content, imposterContainer, imposterSourceElement) {
-        this._range = range;
-        this._rangeStartOffset = range.startOffset;
-        this._content = content;
-        this._imposterContainer = imposterContainer;
-        this._imposterSourceElement = imposterSourceElement;
-    }
-
-    get range() {
-        return this._range;
-    }
-
-    get rangeStartOffset() {
-        return this._rangeStartOffset;
-    }
-
-    get imposterSourceElement() {
-        return this._imposterSourceElement;
-    }
-
-    get isConnected() {
-        return (
-            this._range.startContainer.isConnected &&
-            this._range.endContainer.isConnected
-        );
-    }
-
-    clone() {
-        return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement);
-    }
-
-    cleanup() {
-        if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) {
-            this._imposterContainer.parentNode.removeChild(this._imposterContainer);
-        }
-    }
-
-    text() {
-        return this._content;
-    }
-
-    setEndOffset(length, layoutAwareScan, fromEnd=false) {
-        const state = (
-            fromEnd ?
-            new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) :
-            new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length)
-        );
-        this._range.setEnd(state.node, state.offset);
-        this._content = (fromEnd ? this._content + state.content : state.content);
-        return length - state.remainder;
-    }
-
-    setStartOffset(length, layoutAwareScan) {
-        const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length);
-        this._range.setStart(state.node, state.offset);
-        this._rangeStartOffset = this._range.startOffset;
-        this._content = state.content + this._content;
-        return length - state.remainder;
-    }
-
-    collapse(toStart) {
-        this._range.collapse(toStart);
-        this._content = '';
-    }
-
-    getRect() {
-        return this._range.getBoundingClientRect();
-    }
-
-    getWritingMode() {
-        return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer));
-    }
-
-    select() {
-        const selection = window.getSelection();
-        selection.removeAllRanges();
-        selection.addRange(this._range);
-    }
-
-    deselect() {
-        const selection = window.getSelection();
-        selection.removeAllRanges();
-    }
-
-    hasSameStart(other) {
-        if (!(
-            typeof other === 'object' &&
-            other !== null &&
-            other instanceof TextSourceRange
-        )) {
-            return false;
-        }
-        if (this._imposterSourceElement !== null) {
-            return (
-                this._imposterSourceElement === other.imposterSourceElement &&
-                this._rangeStartOffset === other.rangeStartOffset
-            );
-        } else {
-            try {
-                return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
-            } catch (e) {
-                if (e.name === 'WrongDocumentError') {
-                    // This can happen with shadow DOMs if the ranges are in different documents.
-                    return false;
-                }
-                throw e;
-            }
-        }
-    }
-
-    getNodesInRange() {
-        return DocumentUtil.getNodesInRange(this._range);
-    }
-
-    static getParentElement(node) {
-        while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
-            node = node.parentNode;
-        }
-        return node;
-    }
-
-    static getElementWritingMode(element) {
-        if (element !== null) {
-            const style = window.getComputedStyle(element);
-            const writingMode = style.writingMode;
-            if (typeof writingMode === 'string') {
-                return TextSourceRange.normalizeWritingMode(writingMode);
-            }
-        }
-        return 'horizontal-tb';
-    }
-
-    static normalizeWritingMode(writingMode) {
-        switch (writingMode) {
-            case 'lr':
-            case 'lr-tb':
-            case 'rl':
-                return 'horizontal-tb';
-            case 'tb':
-                return 'vertical-lr';
-            case 'tb-rl':
-                return 'vertical-rl';
-            default:
-                return writingMode;
-        }
-    }
-}
diff --git a/ext/js/app/content-script-main.js b/ext/js/app/content-script-main.js
new file mode 100644
index 00000000..5dee4c56
--- /dev/null
+++ b/ext/js/app/content-script-main.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Frontend
+ * HotkeyHandler
+ * PopupFactory
+ * api
+ */
+
+(async () => {
+    try {
+        api.forwardLogsToBackend();
+        await yomichan.backendReady();
+
+        const {tabId, frameId} = await api.frameInformationGet();
+        if (typeof frameId !== 'number') {
+            throw new Error('Failed to get frameId');
+        }
+
+        const hotkeyHandler = new HotkeyHandler();
+        hotkeyHandler.prepare();
+
+        const popupFactory = new PopupFactory(frameId);
+        popupFactory.prepare();
+
+        const frontend = new Frontend({
+            tabId,
+            frameId,
+            popupFactory,
+            depth: 0,
+            parentPopupId: null,
+            parentFrameId: null,
+            useProxyPopup: false,
+            pageType: 'web',
+            allowRootFramePopupProxy: true,
+            hotkeyHandler
+        });
+        await frontend.prepare();
+
+        yomichan.ready();
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js
new file mode 100644
index 00000000..a62b06bf
--- /dev/null
+++ b/ext/js/app/frontend.js
@@ -0,0 +1,691 @@
+/*
+ * Copyright (C) 2016-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DocumentUtil
+ * TextScanner
+ * TextSourceElement
+ * TextSourceRange
+ * api
+ */
+
+class Frontend {
+    constructor({
+        pageType,
+        popupFactory,
+        depth,
+        tabId,
+        frameId,
+        parentPopupId,
+        parentFrameId,
+        useProxyPopup,
+        canUseWindowPopup=true,
+        allowRootFramePopupProxy,
+        childrenSupported=true,
+        hotkeyHandler
+    }) {
+        this._pageType = pageType;
+        this._popupFactory = popupFactory;
+        this._depth = depth;
+        this._tabId = tabId;
+        this._frameId = frameId;
+        this._parentPopupId = parentPopupId;
+        this._parentFrameId = parentFrameId;
+        this._useProxyPopup = useProxyPopup;
+        this._canUseWindowPopup = canUseWindowPopup;
+        this._allowRootFramePopupProxy = allowRootFramePopupProxy;
+        this._childrenSupported = childrenSupported;
+        this._hotkeyHandler = hotkeyHandler;
+        this._popup = null;
+        this._disabledOverride = false;
+        this._options = null;
+        this._pageZoomFactor = 1.0;
+        this._contentScale = 1.0;
+        this._lastShowPromise = Promise.resolve();
+        this._documentUtil = new DocumentUtil();
+        this._textScanner = new TextScanner({
+            node: window,
+            ignoreElements: this._ignoreElements.bind(this),
+            ignorePoint: this._ignorePoint.bind(this),
+            getSearchContext: this._getSearchContext.bind(this),
+            documentUtil: this._documentUtil,
+            searchTerms: true,
+            searchKanji: true
+        });
+        this._popupCache = new Map();
+        this._popupEventListeners = new EventListenerCollection();
+        this._updatePopupToken = null;
+        this._clearSelectionTimer = null;
+        this._isPointerOverPopup = false;
+        this._optionsContextOverride = null;
+
+        this._runtimeMessageHandlers = new Map([
+            ['requestFrontendReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}],
+            ['setAllVisibleOverride',         {async: true,  handler: this._onApiSetAllVisibleOverride.bind(this)}],
+            ['clearAllVisibleOverride',       {async: true,  handler: this._onApiClearAllVisibleOverride.bind(this)}]
+        ]);
+
+        this._hotkeyHandler.registerActions([
+            ['scanSelectedText', this._onActionScanSelectedText.bind(this)]
+        ]);
+    }
+
+    get canClearSelection() {
+        return this._textScanner.canClearSelection;
+    }
+
+    set canClearSelection(value) {
+        this._textScanner.canClearSelection = value;
+    }
+
+    get popup() {
+        return this._popup;
+    }
+
+    async prepare() {
+        await this.updateOptions();
+        try {
+            const {zoomFactor} = await api.getZoom();
+            this._pageZoomFactor = zoomFactor;
+        } catch (e) {
+            // Ignore exceptions which may occur due to being on an unsupported page (e.g. about:blank)
+        }
+
+        this._textScanner.prepare();
+
+        window.addEventListener('resize', this._onResize.bind(this), false);
+        DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this));
+
+        const visualViewport = window.visualViewport;
+        if (visualViewport !== null && typeof visualViewport === 'object') {
+            visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this));
+            visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this));
+        }
+
+        yomichan.on('optionsUpdated', this.updateOptions.bind(this));
+        yomichan.on('zoomChanged', this._onZoomChanged.bind(this));
+        yomichan.on('closePopups', this._onClosePopups.bind(this));
+        chrome.runtime.onMessage.addListener(this._onRuntimeMessage.bind(this));
+
+        this._textScanner.on('clearSelection', this._onClearSelection.bind(this));
+        this._textScanner.on('searched', this._onSearched.bind(this));
+
+        api.crossFrame.registerHandlers([
+            ['closePopup',              {async: false, handler: this._onApiClosePopup.bind(this)}],
+            ['copySelection',           {async: false, handler: this._onApiCopySelection.bind(this)}],
+            ['getSelectionText',        {async: false, handler: this._onApiGetSelectionText.bind(this)}],
+            ['getPopupInfo',            {async: false, handler: this._onApiGetPopupInfo.bind(this)}],
+            ['getPageInfo',             {async: false, handler: this._onApiGetPageInfo.bind(this)}],
+            ['getFrameSize',            {async: true,  handler: this._onApiGetFrameSize.bind(this)}],
+            ['setFrameSize',            {async: true,  handler: this._onApiSetFrameSize.bind(this)}]
+        ]);
+
+        this._updateContentScale();
+        this._signalFrontendReady();
+    }
+
+    setDisabledOverride(disabled) {
+        this._disabledOverride = disabled;
+        this._updateTextScannerEnabled();
+    }
+
+    setOptionsContextOverride(optionsContext) {
+        this._optionsContextOverride = optionsContext;
+    }
+
+    async setTextSource(textSource) {
+        this._textScanner.setCurrentTextSource(null);
+        await this._textScanner.search(textSource);
+    }
+
+    async updateOptions() {
+        try {
+            await this._updateOptionsInternal();
+        } catch (e) {
+            if (!yomichan.isExtensionUnloaded) {
+                throw e;
+            }
+        }
+    }
+
+    showContentCompleted() {
+        return this._lastShowPromise;
+    }
+
+    // Message handlers
+
+    _onMessageRequestFrontendReadyBroadcast({frameId}) {
+        this._signalFrontendReady(frameId);
+    }
+
+    // Action handlers
+
+    _onActionScanSelectedText() {
+        this._scanSelectedText();
+    }
+
+    // API message handlers
+
+    _onApiGetUrl() {
+        return window.location.href;
+    }
+
+    _onApiClosePopup() {
+        this._clearSelection(false);
+    }
+
+    _onApiCopySelection() {
+        // This will not work on Firefox if a popup has focus, which is usually the case when this function is called.
+        document.execCommand('copy');
+    }
+
+    _onApiGetSelectionText() {
+        return document.getSelection().toString();
+    }
+
+    _onApiGetPopupInfo() {
+        return {
+            popupId: (this._popup !== null ? this._popup.id : null)
+        };
+    }
+
+    _onApiGetPageInfo() {
+        return {
+            url: window.location.href,
+            documentTitle: document.title
+        };
+    }
+
+    async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) {
+        const result = await this._popupFactory.setAllVisibleOverride(value, priority);
+        if (awaitFrame) {
+            await promiseAnimationFrame(100);
+        }
+        return result;
+    }
+
+    async _onApiClearAllVisibleOverride({token}) {
+        return await this._popupFactory.clearAllVisibleOverride(token);
+    }
+
+    async _onApiGetFrameSize() {
+        return await this._popup.getFrameSize();
+    }
+
+    async _onApiSetFrameSize({width, height}) {
+        return await this._popup.setFrameSize(width, height);
+    }
+
+    // Private
+
+    _onResize() {
+        this._updatePopupPosition();
+    }
+
+    _onRuntimeMessage({action, params}, sender, callback) {
+        const messageHandler = this._runtimeMessageHandlers.get(action);
+        if (typeof messageHandler === 'undefined') { return false; }
+        return yomichan.invokeMessageHandler(messageHandler, params, callback, sender);
+    }
+
+    _onZoomChanged({newZoomFactor}) {
+        this._pageZoomFactor = newZoomFactor;
+        this._updateContentScale();
+    }
+
+    _onClosePopups() {
+        this._clearSelection(true);
+    }
+
+    _onVisualViewportScroll() {
+        this._updatePopupPosition();
+    }
+
+    _onVisualViewportResize() {
+        this._updateContentScale();
+    }
+
+    _onClearSelection({passive}) {
+        this._stopClearSelectionDelayed();
+        if (this._popup !== null) {
+            this._popup.hide(!passive);
+            this._popup.clearAutoPlayTimer();
+            this._isPointerOverPopup = false;
+        }
+    }
+
+    _onSearched({type, definitions, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) {
+        const scanningOptions = this._options.scanning;
+
+        if (error !== null) {
+            if (yomichan.isExtensionUnloaded) {
+                if (textSource !== null && !passive) {
+                    this._showExtensionUnloaded(textSource);
+                }
+            } else {
+                yomichan.logError(error);
+            }
+        } if (type !== null) {
+            this._stopClearSelectionDelayed();
+            let focus = (eventType === 'mouseMove');
+            if (isObject(detail)) {
+                const focus2 = detail.focus;
+                if (typeof focus2 === 'boolean') { focus = focus2; }
+            }
+            this._showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext);
+        } else {
+            if (scanningOptions.autoHideResults) {
+                this._clearSelectionDelayed(scanningOptions.hideDelay, false);
+            }
+        }
+    }
+
+    _onPopupFramePointerOver() {
+        this._isPointerOverPopup = true;
+        this._stopClearSelectionDelayed();
+    }
+
+    _onPopupFramePointerOut() {
+        this._isPointerOverPopup = false;
+    }
+
+    _clearSelection(passive) {
+        this._stopClearSelectionDelayed();
+        this._textScanner.clearSelection(passive);
+    }
+
+    _clearSelectionDelayed(delay, restart, passive) {
+        if (!this._textScanner.hasSelection()) { return; }
+        if (delay > 0) {
+            if (this._clearSelectionTimer !== null && !restart) { return; } // Already running
+            this._stopClearSelectionDelayed();
+            this._clearSelectionTimer = setTimeout(() => {
+                this._clearSelectionTimer = null;
+                if (this._isPointerOverPopup) { return; }
+                this._clearSelection(passive);
+            }, delay);
+        } else {
+            this._clearSelection(passive);
+        }
+    }
+
+    _stopClearSelectionDelayed() {
+        if (this._clearSelectionTimer !== null) {
+            clearTimeout(this._clearSelectionTimer);
+            this._clearSelectionTimer = null;
+        }
+    }
+
+    async _updateOptionsInternal() {
+        const optionsContext = await this._getOptionsContext();
+        const options = await api.optionsGet(optionsContext);
+        const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options;
+        this._options = options;
+
+        this._hotkeyHandler.setHotkeys('web', options.inputs.hotkeys);
+
+        await this._updatePopup();
+
+        const preventMiddleMouse = this._getPreventMiddleMouseValueForPageType(scanningOptions.preventMiddleMouse);
+        this._textScanner.setOptions({
+            inputs: scanningOptions.inputs,
+            deepContentScan: scanningOptions.deepDomScan,
+            selectText: scanningOptions.selectText,
+            delay: scanningOptions.delay,
+            touchInputEnabled: scanningOptions.touchInputEnabled,
+            pointerEventsEnabled: scanningOptions.pointerEventsEnabled,
+            scanLength: scanningOptions.length,
+            layoutAwareScan: scanningOptions.layoutAwareScan,
+            preventMiddleMouse,
+            sentenceParsingOptions
+        });
+        this._updateTextScannerEnabled();
+
+        if (this._pageType !== 'web') {
+            const excludeSelectors = ['.scan-disable', '.scan-disable *'];
+            if (!scanningOptions.enableOnPopupExpressions) {
+                excludeSelectors.push('.source-text', '.source-text *');
+            }
+            this._textScanner.excludeSelector = excludeSelectors.join(',');
+        }
+
+        this._updateContentScale();
+
+        await this._textScanner.searchLast();
+    }
+
+    async _updatePopup() {
+        const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general;
+        const isIframe = !this._useProxyPopup && (window !== window.parent);
+
+        const currentPopup = this._popup;
+
+        let popupPromise;
+        if (usePopupWindow && this._canUseWindowPopup) {
+            popupPromise = this._popupCache.get('window');
+            if (typeof popupPromise === 'undefined') {
+                popupPromise = this._getPopupWindow();
+                this._popupCache.set('window', popupPromise);
+            }
+        } else if (
+            isIframe &&
+            showIframePopupsInRootFrame &&
+            DocumentUtil.getFullscreenElement() === null &&
+            this._allowRootFramePopupProxy
+        ) {
+            popupPromise = this._popupCache.get('iframe');
+            if (typeof popupPromise === 'undefined') {
+                popupPromise = this._getIframeProxyPopup();
+                this._popupCache.set('iframe', popupPromise);
+            }
+        } else if (this._useProxyPopup) {
+            popupPromise = this._popupCache.get('proxy');
+            if (typeof popupPromise === 'undefined') {
+                popupPromise = this._getProxyPopup();
+                this._popupCache.set('proxy', popupPromise);
+            }
+        } else {
+            popupPromise = this._popupCache.get('default');
+            if (typeof popupPromise === 'undefined') {
+                popupPromise = this._getDefaultPopup();
+                this._popupCache.set('default', popupPromise);
+            }
+        }
+
+        // The token below is used as a unique identifier to ensure that a new _updatePopup call
+        // hasn't been started during the await.
+        const token = {};
+        this._updatePopupToken = token;
+        const popup = await popupPromise;
+        const optionsContext = await this._getOptionsContext();
+        if (this._updatePopupToken !== token) { return; }
+        if (popup !== null) {
+            await popup.setOptionsContext(optionsContext);
+        }
+        if (this._updatePopupToken !== token) { return; }
+
+        if (popup !== currentPopup) {
+            this._clearSelection(true);
+        }
+
+        this._popupEventListeners.removeAllEventListeners();
+        this._popup = popup;
+        if (popup !== null) {
+            this._popupEventListeners.on(popup, 'framePointerOver', this._onPopupFramePointerOver.bind(this));
+            this._popupEventListeners.on(popup, 'framePointerOut', this._onPopupFramePointerOut.bind(this));
+        }
+        this._isPointerOverPopup = false;
+    }
+
+    async _getDefaultPopup() {
+        const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument);
+        if (isXmlDocument) {
+            return null;
+        }
+
+        return await this._popupFactory.getOrCreatePopup({
+            frameId: this._frameId,
+            depth: this._depth,
+            childrenSupported: this._childrenSupported
+        });
+    }
+
+    async _getProxyPopup() {
+        return await this._popupFactory.getOrCreatePopup({
+            frameId: this._parentFrameId,
+            depth: this._depth,
+            parentPopupId: this._parentPopupId,
+            childrenSupported: this._childrenSupported
+        });
+    }
+
+    async _getIframeProxyPopup() {
+        const targetFrameId = 0; // Root frameId
+        try {
+            await this._waitForFrontendReady(targetFrameId);
+        } catch (e) {
+            // Root frame not available
+            return await this._getDefaultPopup();
+        }
+
+        const {popupId} = await api.crossFrame.invoke(targetFrameId, 'getPopupInfo');
+        if (popupId === null) {
+            return null;
+        }
+
+        const popup = await this._popupFactory.getOrCreatePopup({
+            frameId: targetFrameId,
+            id: popupId,
+            childrenSupported: this._childrenSupported
+        });
+        popup.on('offsetNotFound', () => {
+            this._allowRootFramePopupProxy = false;
+            this._updatePopup();
+        });
+        return popup;
+    }
+
+    async _getPopupWindow() {
+        return await this._popupFactory.getOrCreatePopup({
+            depth: this._depth,
+            popupWindow: true,
+            childrenSupported: this._childrenSupported
+        });
+    }
+
+    _ignoreElements() {
+        if (this._popup !== null) {
+            const container = this._popup.container;
+            if (container !== null) {
+                return [container];
+            }
+        }
+        return [];
+    }
+
+    async _ignorePoint(x, y) {
+        try {
+            return this._popup !== null && await this._popup.containsPoint(x, y);
+        } catch (e) {
+            if (!yomichan.isExtensionUnloaded) {
+                throw e;
+            }
+            return false;
+        }
+    }
+
+    _showExtensionUnloaded(textSource) {
+        if (textSource === null) {
+            textSource = this._textScanner.getCurrentTextSource();
+            if (textSource === null) { return; }
+        }
+        this._showPopupContent(textSource, null);
+    }
+
+    _showContent(textSource, focus, definitions, type, sentence, documentTitle, optionsContext) {
+        const query = textSource.text();
+        const {url} = optionsContext;
+        const details = {
+            focus,
+            history: false,
+            params: {
+                type,
+                query,
+                wildcards: 'off'
+            },
+            state: {
+                focusEntry: 0,
+                optionsContext,
+                url,
+                sentence,
+                documentTitle
+            },
+            content: {
+                definitions,
+                contentOrigin: {
+                    tabId: this._tabId,
+                    frameId: this._frameId
+                }
+            }
+        };
+        if (textSource instanceof TextSourceElement && textSource.fullContent !== query) {
+            details.params.full = textSource.fullContent;
+            details.params['full-visible'] = 'true';
+        }
+        this._showPopupContent(textSource, optionsContext, details);
+    }
+
+    _showPopupContent(textSource, optionsContext, details=null) {
+        this._lastShowPromise = (
+            this._popup !== null ?
+            this._popup.showContent(
+                {
+                    optionsContext,
+                    elementRect: textSource.getRect(),
+                    writingMode: textSource.getWritingMode()
+                },
+                details
+            ) :
+            Promise.resolve()
+        );
+        this._lastShowPromise.catch((error) => {
+            if (yomichan.isExtensionUnloaded) { return; }
+            yomichan.logError(error);
+        });
+        return this._lastShowPromise;
+    }
+
+    _updateTextScannerEnabled() {
+        const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride);
+        this._textScanner.setEnabled(enabled);
+    }
+
+    _updateContentScale() {
+        const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general;
+        let contentScale = popupScalingFactor;
+        if (popupScaleRelativeToPageZoom) {
+            contentScale /= this._pageZoomFactor;
+        }
+        if (popupScaleRelativeToVisualViewport) {
+            const visualViewport = window.visualViewport;
+            const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0);
+            contentScale /= visualViewportScale;
+        }
+        if (contentScale === this._contentScale) { return; }
+
+        this._contentScale = contentScale;
+        if (this._popup !== null) {
+            this._popup.setContentScale(this._contentScale);
+        }
+        this._updatePopupPosition();
+    }
+
+    async _updatePopupPosition() {
+        const textSource = this._textScanner.getCurrentTextSource();
+        if (
+            textSource !== null &&
+            this._popup !== null &&
+            await this._popup.isVisible()
+        ) {
+            this._showPopupContent(textSource, null);
+        }
+    }
+
+    _signalFrontendReady(targetFrameId=null) {
+        const params = {frameId: this._frameId};
+        if (targetFrameId === null) {
+            api.broadcastTab('frontendReady', params);
+        } else {
+            api.sendMessageToFrame(targetFrameId, 'frontendReady', params);
+        }
+    }
+
+    async _waitForFrontendReady(frameId) {
+        const promise = yomichan.getTemporaryListenerResult(
+            chrome.runtime.onMessage,
+            ({action, params}, {resolve}) => {
+                if (
+                    action === 'frontendReady' &&
+                    params.frameId === frameId
+                ) {
+                    resolve();
+                }
+            },
+            10000
+        );
+        api.broadcastTab('requestFrontendReadyBroadcast', {frameId: this._frameId});
+        await promise;
+    }
+
+    _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) {
+        switch (this._pageType) {
+            case 'web': return preventMiddleMouseOptions.onWebPages;
+            case 'popup': return preventMiddleMouseOptions.onPopupPages;
+            case 'search': return preventMiddleMouseOptions.onSearchPages;
+            default: return false;
+        }
+    }
+
+    async _getOptionsContext() {
+        let optionsContext = this._optionsContextOverride;
+        if (optionsContext === null) {
+            optionsContext = (await this._getSearchContext()).optionsContext;
+        }
+        return optionsContext;
+    }
+
+    async _getSearchContext() {
+        let url = window.location.href;
+        let documentTitle = document.title;
+        if (this._useProxyPopup) {
+            try {
+                ({url, documentTitle} = await api.crossFrame.invoke(this._parentFrameId, 'getPageInfo', {}));
+            } catch (e) {
+                // NOP
+            }
+        }
+
+        let optionsContext = this._optionsContextOverride;
+        if (optionsContext === null) {
+            optionsContext = {depth: this._depth, url};
+        }
+
+        return {
+            optionsContext,
+            detail: {documentTitle}
+        };
+    }
+
+    async _scanSelectedText() {
+        const range = this._getFirstNonEmptySelectionRange();
+        if (range === null) { return false; }
+        const source = new TextSourceRange(range, range.toString(), null, null);
+        await this._textScanner.search(source, {focus: true});
+        return true;
+    }
+
+    _getFirstNonEmptySelectionRange() {
+        const selection = window.getSelection();
+        for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
+            const range = selection.getRangeAt(i);
+            if (range.toString().length > 0) {
+                return range;
+            }
+        }
+        return null;
+    }
+}
diff --git a/ext/js/app/popup-factory.js b/ext/js/app/popup-factory.js
new file mode 100644
index 00000000..7571d7ab
--- /dev/null
+++ b/ext/js/app/popup-factory.js
@@ -0,0 +1,319 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * FrameOffsetForwarder
+ * Popup
+ * PopupProxy
+ * PopupWindow
+ * api
+ */
+
+class PopupFactory {
+    constructor(frameId) {
+        this._frameId = frameId;
+        this._frameOffsetForwarder = new FrameOffsetForwarder(frameId);
+        this._popups = new Map();
+        this._allPopupVisibilityTokenMap = new Map();
+    }
+
+    // Public functions
+
+    prepare() {
+        this._frameOffsetForwarder.prepare();
+        api.crossFrame.registerHandlers([
+            ['getOrCreatePopup',     {async: true,  handler: this._onApiGetOrCreatePopup.bind(this)}],
+            ['setOptionsContext',    {async: true,  handler: this._onApiSetOptionsContext.bind(this)}],
+            ['hide',                 {async: false, handler: this._onApiHide.bind(this)}],
+            ['isVisible',            {async: true,  handler: this._onApiIsVisibleAsync.bind(this)}],
+            ['setVisibleOverride',   {async: true,  handler: this._onApiSetVisibleOverride.bind(this)}],
+            ['clearVisibleOverride', {async: true,  handler: this._onApiClearVisibleOverride.bind(this)}],
+            ['containsPoint',        {async: true,  handler: this._onApiContainsPoint.bind(this)}],
+            ['showContent',          {async: true,  handler: this._onApiShowContent.bind(this)}],
+            ['setCustomCss',         {async: false, handler: this._onApiSetCustomCss.bind(this)}],
+            ['clearAutoPlayTimer',   {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}],
+            ['setContentScale',      {async: false, handler: this._onApiSetContentScale.bind(this)}],
+            ['updateTheme',          {async: false, handler: this._onApiUpdateTheme.bind(this)}],
+            ['setCustomOuterCss',    {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}],
+            ['popup.getFrameSize',   {async: true,  handler: this._onApiGetFrameSize.bind(this)}],
+            ['popup.setFrameSize',   {async: true,  handler: this._onApiSetFrameSize.bind(this)}]
+        ]);
+    }
+
+    async getOrCreatePopup({
+        frameId=null,
+        id=null,
+        parentPopupId=null,
+        depth=null,
+        popupWindow=false,
+        childrenSupported=false
+    }) {
+        // Find by existing id
+        if (id !== null) {
+            const popup = this._popups.get(id);
+            if (typeof popup !== 'undefined') {
+                return popup;
+            }
+        }
+
+        // Find by existing parent id
+        let parent = null;
+        if (parentPopupId !== null) {
+            parent = this._popups.get(parentPopupId);
+            if (typeof parent !== 'undefined') {
+                const popup = parent.child;
+                if (popup !== null) {
+                    return popup;
+                }
+            } else {
+                parent = null;
+            }
+        }
+
+        // Depth
+        if (parent !== null) {
+            if (depth !== null) {
+                throw new Error('Depth cannot be set when parent exists');
+            }
+            depth = parent.depth + 1;
+        } else if (depth === null) {
+            depth = 0;
+        }
+
+        if (popupWindow) {
+            // New unique id
+            if (id === null) {
+                id = generateId(16);
+            }
+            const popup = new PopupWindow({
+                id,
+                depth,
+                frameId: this._frameId
+            });
+            this._popups.set(id, popup);
+            return popup;
+        } else if (frameId === this._frameId) {
+            // New unique id
+            if (id === null) {
+                id = generateId(16);
+            }
+            const popup = new Popup({
+                id,
+                depth,
+                frameId: this._frameId,
+                childrenSupported
+            });
+            if (parent !== null) {
+                if (parent.child !== null) {
+                    throw new Error('Parent popup already has a child');
+                }
+                popup.parent = parent;
+                parent.child = popup;
+            }
+            this._popups.set(id, popup);
+            popup.prepare();
+            return popup;
+        } else {
+            if (frameId === null) {
+                throw new Error('Invalid frameId');
+            }
+            const useFrameOffsetForwarder = (parentPopupId === null);
+            ({id, depth, frameId} = await api.crossFrame.invoke(frameId, 'getOrCreatePopup', {
+                id,
+                parentPopupId,
+                frameId,
+                childrenSupported
+            }));
+            const popup = new PopupProxy({
+                id,
+                depth,
+                frameId,
+                frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null
+            });
+            this._popups.set(id, popup);
+            return popup;
+        }
+    }
+
+    async setAllVisibleOverride(value, priority) {
+        const promises = [];
+        const errors = [];
+        for (const popup of this._popups.values()) {
+            const promise = popup.setVisibleOverride(value, priority)
+                .then(
+                    (token) => ({popup, token}),
+                    (error) => { errors.push(error); return null; }
+                );
+            promises.push(promise);
+        }
+
+        const results = (await Promise.all(promises)).filter(({token}) => token !== null);
+
+        if (errors.length === 0) {
+            const token = generateId(16);
+            this._allPopupVisibilityTokenMap.set(token, results);
+            return token;
+        }
+
+        // Revert on error
+        await this._revertPopupVisibilityOverrides(results);
+        throw errors[0];
+    }
+
+    async clearAllVisibleOverride(token) {
+        const results = this._allPopupVisibilityTokenMap.get(token);
+        if (typeof results === 'undefined') { return false; }
+
+        this._allPopupVisibilityTokenMap.delete(token);
+        await this._revertPopupVisibilityOverrides(results);
+        return true;
+    }
+
+    // API message handlers
+
+    async _onApiGetOrCreatePopup(details) {
+        const popup = await this.getOrCreatePopup(details);
+        return {
+            id: popup.id,
+            depth: popup.depth,
+            frameId: popup.frameId
+        };
+    }
+
+    async _onApiSetOptionsContext({id, optionsContext, source}) {
+        const popup = this._getPopup(id);
+        return await popup.setOptionsContext(optionsContext, source);
+    }
+
+    _onApiHide({id, changeFocus}) {
+        const popup = this._getPopup(id);
+        return popup.hide(changeFocus);
+    }
+
+    async _onApiIsVisibleAsync({id}) {
+        const popup = this._getPopup(id);
+        return await popup.isVisible();
+    }
+
+    async _onApiSetVisibleOverride({id, value, priority}) {
+        const popup = this._getPopup(id);
+        return await popup.setVisibleOverride(value, priority);
+    }
+
+    async _onApiClearVisibleOverride({id, token}) {
+        const popup = this._getPopup(id);
+        return await popup.clearVisibleOverride(token);
+    }
+
+    async _onApiContainsPoint({id, x, y}) {
+        const popup = this._getPopup(id);
+        [x, y] = this._convertPopupPointToRootPagePoint(popup, x, y);
+        return await popup.containsPoint(x, y);
+    }
+
+    async _onApiShowContent({id, details, displayDetails}) {
+        const popup = this._getPopup(id);
+        if (!this._popupCanShow(popup)) { return; }
+
+        const {elementRect} = details;
+        if (typeof elementRect !== 'undefined') {
+            details.elementRect = this._convertJsonRectToDOMRect(popup, elementRect);
+        }
+
+        return await popup.showContent(details, displayDetails);
+    }
+
+    _onApiSetCustomCss({id, css}) {
+        const popup = this._getPopup(id);
+        return popup.setCustomCss(css);
+    }
+
+    _onApiClearAutoPlayTimer({id}) {
+        const popup = this._getPopup(id);
+        return popup.clearAutoPlayTimer();
+    }
+
+    _onApiSetContentScale({id, scale}) {
+        const popup = this._getPopup(id);
+        return popup.setContentScale(scale);
+    }
+
+    _onApiUpdateTheme({id}) {
+        const popup = this._getPopup(id);
+        return popup.updateTheme();
+    }
+
+    _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) {
+        const popup = this._getPopup(id);
+        return popup.setCustomOuterCss(css, useWebExtensionApi);
+    }
+
+    async _onApiGetFrameSize({id}) {
+        const popup = this._getPopup(id);
+        return await popup.getFrameSize();
+    }
+
+    async _onApiSetFrameSize({id, width, height}) {
+        const popup = this._getPopup(id);
+        return await popup.setFrameSize(width, height);
+    }
+
+    // Private functions
+
+    _getPopup(id) {
+        const popup = this._popups.get(id);
+        if (typeof popup === 'undefined') {
+            throw new Error(`Invalid popup ID ${id}`);
+        }
+        return popup;
+    }
+
+    _convertJsonRectToDOMRect(popup, jsonRect) {
+        const [x, y] = this._convertPopupPointToRootPagePoint(popup, jsonRect.x, jsonRect.y);
+        return new DOMRect(x, y, jsonRect.width, jsonRect.height);
+    }
+
+    _convertPopupPointToRootPagePoint(popup, x, y) {
+        const parent = popup.parent;
+        if (parent !== null) {
+            const popupRect = parent.getFrameRect();
+            x += popupRect.x;
+            y += popupRect.y;
+        }
+        return [x, y];
+    }
+
+    _popupCanShow(popup) {
+        const parent = popup.parent;
+        return parent === null || parent.isVisibleSync();
+    }
+
+    async _revertPopupVisibilityOverrides(overrides) {
+        const promises = [];
+        for (const value of overrides) {
+            if (value === null) { continue; }
+            const {popup, token} = value;
+            const promise = popup.clearVisibleOverride(token)
+                .then(
+                    (v) => v,
+                    () => false
+                );
+            promises.push(promise);
+        }
+        return await Promise.all(promises);
+    }
+}
diff --git a/ext/js/app/popup-proxy.js b/ext/js/app/popup-proxy.js
new file mode 100644
index 00000000..b2e81824
--- /dev/null
+++ b/ext/js/app/popup-proxy.js
@@ -0,0 +1,218 @@
+/*
+ * Copyright (C) 2019-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+class PopupProxy extends EventDispatcher {
+    constructor({
+        id,
+        depth,
+        frameId,
+        frameOffsetForwarder
+    }) {
+        super();
+        this._id = id;
+        this._depth = depth;
+        this._frameId = frameId;
+        this._frameOffsetForwarder = frameOffsetForwarder;
+
+        this._frameOffset = [0, 0];
+        this._frameOffsetPromise = null;
+        this._frameOffsetUpdatedAt = null;
+        this._frameOffsetExpireTimeout = 1000;
+    }
+
+    // Public properties
+
+    get id() {
+        return this._id;
+    }
+
+    get parent() {
+        return null;
+    }
+
+    set parent(value) {
+        throw new Error('Not supported on PopupProxy');
+    }
+
+    get child() {
+        return null;
+    }
+
+    set child(value) {
+        throw new Error('Not supported on PopupProxy');
+    }
+
+    get depth() {
+        return this._depth;
+    }
+
+    get frameContentWindow() {
+        return null;
+    }
+
+    get container() {
+        return null;
+    }
+
+    get frameId() {
+        return this._frameId;
+    }
+
+    // Public functions
+
+    setOptionsContext(optionsContext, source) {
+        return this._invokeSafe('setOptionsContext', {id: this._id, optionsContext, source});
+    }
+
+    hide(changeFocus) {
+        return this._invokeSafe('hide', {id: this._id, changeFocus});
+    }
+
+    isVisible() {
+        return this._invokeSafe('isVisible', {id: this._id}, false);
+    }
+
+    setVisibleOverride(value, priority) {
+        return this._invokeSafe('setVisibleOverride', {id: this._id, value, priority}, null);
+    }
+
+    clearVisibleOverride(token) {
+        return this._invokeSafe('clearVisibleOverride', {id: this._id, token}, false);
+    }
+
+    async containsPoint(x, y) {
+        if (this._frameOffsetForwarder !== null) {
+            await this._updateFrameOffset();
+            [x, y] = this._applyFrameOffset(x, y);
+        }
+        return await this._invokeSafe('containsPoint', {id: this._id, x, y}, false);
+    }
+
+    async showContent(details, displayDetails) {
+        const {elementRect} = details;
+        if (typeof elementRect !== 'undefined') {
+            let {x, y, width, height} = elementRect;
+            if (this._frameOffsetForwarder !== null) {
+                await this._updateFrameOffset();
+                [x, y] = this._applyFrameOffset(x, y);
+            }
+            details.elementRect = {x, y, width, height};
+        }
+        return await this._invokeSafe('showContent', {id: this._id, details, displayDetails});
+    }
+
+    setCustomCss(css) {
+        return this._invokeSafe('setCustomCss', {id: this._id, css});
+    }
+
+    clearAutoPlayTimer() {
+        return this._invokeSafe('clearAutoPlayTimer', {id: this._id});
+    }
+
+    setContentScale(scale) {
+        return this._invokeSafe('setContentScale', {id: this._id, scale});
+    }
+
+    isVisibleSync() {
+        throw new Error('Not supported on PopupProxy');
+    }
+
+    updateTheme() {
+        return this._invokeSafe('updateTheme', {id: this._id});
+    }
+
+    setCustomOuterCss(css, useWebExtensionApi) {
+        return this._invokeSafe('setCustomOuterCss', {id: this._id, css, useWebExtensionApi});
+    }
+
+    getFrameRect() {
+        return new DOMRect(0, 0, 0, 0);
+    }
+
+    getFrameSize() {
+        return this._invokeSafe('popup.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false});
+    }
+
+    setFrameSize(width, height) {
+        return this._invokeSafe('popup.setFrameSize', {id: this._id, width, height});
+    }
+
+    // Private
+
+    _invoke(action, params={}) {
+        return api.crossFrame.invoke(this._frameId, action, params);
+    }
+
+    async _invokeSafe(action, params={}, defaultReturnValue) {
+        try {
+            return await this._invoke(action, params);
+        } catch (e) {
+            if (!yomichan.isExtensionUnloaded) { throw e; }
+            return defaultReturnValue;
+        }
+    }
+
+    async _updateFrameOffset() {
+        const now = Date.now();
+        const firstRun = this._frameOffsetUpdatedAt === null;
+        const expired = firstRun || this._frameOffsetUpdatedAt < now - this._frameOffsetExpireTimeout;
+        if (this._frameOffsetPromise === null && !expired) { return; }
+
+        if (this._frameOffsetPromise !== null) {
+            if (firstRun) {
+                await this._frameOffsetPromise;
+            }
+            return;
+        }
+
+        const promise = this._updateFrameOffsetInner(now);
+        if (firstRun) {
+            await promise;
+        }
+    }
+
+    async _updateFrameOffsetInner(now) {
+        this._frameOffsetPromise = this._frameOffsetForwarder.getOffset();
+        try {
+            let offset = null;
+            try {
+                offset = await this._frameOffsetPromise;
+            } catch (e) {
+                // NOP
+            }
+            this._frameOffset = offset !== null ? offset : [0, 0];
+            if (offset === null) {
+                this.trigger('offsetNotFound');
+                return;
+            }
+            this._frameOffsetUpdatedAt = now;
+        } catch (e) {
+            yomichan.logError(e);
+        } finally {
+            this._frameOffsetPromise = null;
+        }
+    }
+
+    _applyFrameOffset(x, y) {
+        const [offsetX, offsetY] = this._frameOffset;
+        return [x + offsetX, y + offsetY];
+    }
+}
diff --git a/ext/js/app/popup-window.js b/ext/js/app/popup-window.js
new file mode 100644
index 00000000..5fa0c647
--- /dev/null
+++ b/ext/js/app/popup-window.js
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+class PopupWindow extends EventDispatcher {
+    constructor({
+        id,
+        depth,
+        frameId
+    }) {
+        super();
+        this._id = id;
+        this._depth = depth;
+        this._frameId = frameId;
+        this._popupTabId = null;
+    }
+
+    // Public properties
+
+    get id() {
+        return this._id;
+    }
+
+    get parent() {
+        return null;
+    }
+
+    set parent(value) {
+        throw new Error('Not supported on PopupProxy');
+    }
+
+    get child() {
+        return null;
+    }
+
+    set child(value) {
+        throw new Error('Not supported on PopupProxy');
+    }
+
+    get depth() {
+        return this._depth;
+    }
+
+    get frameContentWindow() {
+        return null;
+    }
+
+    get container() {
+        return null;
+    }
+
+    get frameId() {
+        return this._frameId;
+    }
+
+
+    // Public functions
+
+    setOptionsContext(optionsContext, source) {
+        return this._invoke(false, 'setOptionsContext', {id: this._id, optionsContext, source});
+    }
+
+    hide(_changeFocus) {
+        // NOP
+    }
+
+    async isVisible() {
+        return (this._popupTabId !== null && await api.isTabSearchPopup(this._popupTabId));
+    }
+
+    async setVisibleOverride(_value, _priority) {
+        return null;
+    }
+
+    clearVisibleOverride(_token) {
+        return false;
+    }
+
+    async containsPoint(_x, _y) {
+        return false;
+    }
+
+    async showContent(_details, displayDetails) {
+        if (displayDetails === null) { return; }
+        await this._invoke(true, 'setContent', {id: this._id, details: displayDetails});
+    }
+
+    setCustomCss(css) {
+        return this._invoke(false, 'setCustomCss', {id: this._id, css});
+    }
+
+    clearAutoPlayTimer() {
+        return this._invoke(false, 'clearAutoPlayTimer', {id: this._id});
+    }
+
+    setContentScale(_scale) {
+        // NOP
+    }
+
+    isVisibleSync() {
+        throw new Error('Not supported on PopupWindow');
+    }
+
+    updateTheme() {
+        // NOP
+    }
+
+    async setCustomOuterCss(_css, _useWebExtensionApi) {
+        // NOP
+    }
+
+    getFrameRect() {
+        return new DOMRect(0, 0, 0, 0);
+    }
+
+    async getFrameSize() {
+        return {width: 0, height: 0, valid: false};
+    }
+
+    async setFrameSize(_width, _height) {
+        return false;
+    }
+
+    // Private
+
+    async _invoke(open, action, params={}, defaultReturnValue) {
+        if (yomichan.isExtensionUnloaded) {
+            return defaultReturnValue;
+        }
+
+        const frameId = 0;
+        if (this._popupTabId !== null) {
+            try {
+                return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params});
+            } catch (e) {
+                if (yomichan.isExtensionUnloaded) {
+                    open = false;
+                }
+            }
+            this._popupTabId = null;
+        }
+
+        if (!open) {
+            return defaultReturnValue;
+        }
+
+        const {tabId} = await api.getOrCreateSearchPopup({focus: 'ifCreated'});
+        this._popupTabId = tabId;
+
+        return await api.crossFrame.invokeTab(this._popupTabId, frameId, 'popupMessage', {action, params});
+    }
+}
diff --git a/ext/js/app/popup.js b/ext/js/app/popup.js
new file mode 100644
index 00000000..75b74257
--- /dev/null
+++ b/ext/js/app/popup.js
@@ -0,0 +1,687 @@
+/*
+ * Copyright (C) 2016-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DocumentUtil
+ * FrameClient
+ * api
+ * dynamicLoader
+ */
+
+class Popup extends EventDispatcher {
+    constructor({
+        id,
+        depth,
+        frameId,
+        childrenSupported
+    }) {
+        super();
+        this._id = id;
+        this._depth = depth;
+        this._frameId = frameId;
+        this._childrenSupported = childrenSupported;
+        this._parent = null;
+        this._child = null;
+        this._injectPromise = null;
+        this._injectPromiseComplete = false;
+        this._visible = new DynamicProperty(false);
+        this._options = null;
+        this._optionsContext = null;
+        this._contentScale = 1.0;
+        this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
+
+        this._frameSizeContentScale = null;
+        this._frameClient = null;
+        this._frame = document.createElement('iframe');
+        this._frame.className = 'yomichan-popup';
+        this._frame.style.width = '0';
+        this._frame.style.height = '0';
+
+        this._container = this._frame;
+        this._shadow = null;
+
+        this._fullscreenEventListeners = new EventListenerCollection();
+    }
+
+    // Public properties
+
+    get id() {
+        return this._id;
+    }
+
+    get parent() {
+        return this._parent;
+    }
+
+    set parent(value) {
+        this._parent = value;
+    }
+
+    get child() {
+        return this._child;
+    }
+
+    set child(value) {
+        this._child = value;
+    }
+
+    get depth() {
+        return this._depth;
+    }
+
+    get frameContentWindow() {
+        return this._frame.contentWindow;
+    }
+
+    get container() {
+        return this._container;
+    }
+
+    get frameId() {
+        return this._frameId;
+    }
+
+    // Public functions
+
+    prepare() {
+        this._frame.addEventListener('mouseover', this._onFrameMouseOver.bind(this));
+        this._frame.addEventListener('mouseout', this._onFrameMouseOut.bind(this));
+        this._frame.addEventListener('mousedown', (e) => e.stopPropagation());
+        this._frame.addEventListener('scroll', (e) => e.stopPropagation());
+        this._frame.addEventListener('load', this._onFrameLoad.bind(this));
+        this._visible.on('change', this._onVisibleChange.bind(this));
+        yomichan.on('extensionUnloaded', this._onExtensionUnloaded.bind(this));
+        this._onVisibleChange({value: this.isVisibleSync()});
+    }
+
+    async setOptionsContext(optionsContext) {
+        await this._setOptionsContext(optionsContext);
+        await this._invokeSafe('setOptionsContext', {optionsContext});
+    }
+
+    hide(changeFocus) {
+        if (!this.isVisibleSync()) {
+            return;
+        }
+
+        this._setVisible(false);
+        if (this._child !== null) {
+            this._child.hide(false);
+        }
+        if (changeFocus) {
+            this._focusParent();
+        }
+    }
+
+    async isVisible() {
+        return this.isVisibleSync();
+    }
+
+    async setVisibleOverride(value, priority) {
+        return this._visible.setOverride(value, priority);
+    }
+
+    async clearVisibleOverride(token) {
+        return this._visible.clearOverride(token);
+    }
+
+    async containsPoint(x, y) {
+        for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) {
+            const rect = popup.getFrameRect();
+            if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    async showContent(details, displayDetails) {
+        if (this._options === null) { throw new Error('Options not assigned'); }
+
+        const {optionsContext, elementRect, writingMode} = details;
+        if (optionsContext !== null) {
+            await this._setOptionsContextIfDifferent(optionsContext);
+        }
+
+        if (typeof elementRect !== 'undefined' && typeof writingMode !== 'undefined') {
+            await this._show(elementRect, writingMode);
+        }
+
+        if (displayDetails !== null) {
+            this._invokeSafe('setContent', {details: displayDetails});
+        }
+    }
+
+    setCustomCss(css) {
+        this._invokeSafe('setCustomCss', {css});
+    }
+
+    clearAutoPlayTimer() {
+        this._invokeSafe('clearAutoPlayTimer');
+    }
+
+    setContentScale(scale) {
+        this._contentScale = scale;
+        this._frame.style.fontSize = `${scale}px`;
+        this._invokeSafe('setContentScale', {scale});
+    }
+
+    isVisibleSync() {
+        return this._visible.value;
+    }
+
+    updateTheme() {
+        const {popupTheme, popupOuterTheme} = this._options.general;
+        this._frame.dataset.theme = popupTheme;
+        this._frame.dataset.outerTheme = popupOuterTheme;
+        this._frame.dataset.siteColor = this._getSiteColor();
+    }
+
+    async setCustomOuterCss(css, useWebExtensionApi) {
+        let parentNode = null;
+        const inShadow = (this._shadow !== null);
+        if (inShadow) {
+            useWebExtensionApi = false;
+            parentNode = this._shadow;
+        }
+        const node = await dynamicLoader.loadStyle('yomichan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode);
+        this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow});
+    }
+
+    getFrameRect() {
+        return this._frame.getBoundingClientRect();
+    }
+
+    async getFrameSize() {
+        const rect = this._frame.getBoundingClientRect();
+        return {width: rect.width, height: rect.height, valid: true};
+    }
+
+    async setFrameSize(width, height) {
+        this._setFrameSize(width, height);
+        return true;
+    }
+
+    // Private functions
+
+    _onFrameMouseOver() {
+        this.trigger('framePointerOver', {});
+    }
+
+    _onFrameMouseOut() {
+        this.trigger('framePointerOut', {});
+    }
+
+    _inject() {
+        let injectPromise = this._injectPromise;
+        if (injectPromise === null) {
+            injectPromise = this._createInjectPromise();
+            this._injectPromise = injectPromise;
+            injectPromise.then(
+                () => {
+                    if (injectPromise !== this._injectPromise) { return; }
+                    this._injectPromiseComplete = true;
+                },
+                () => { this._resetFrame(); }
+            );
+        }
+        return injectPromise;
+    }
+
+    async _createInjectPromise() {
+        if (this._options === null) {
+            throw new Error('Options not initialized');
+        }
+
+        const {useSecurePopupFrameUrl, usePopupShadowDom} = this._options.general;
+
+        await this._setUpContainer(usePopupShadowDom);
+
+        const setupFrame = (frame) => {
+            frame.removeAttribute('src');
+            frame.removeAttribute('srcdoc');
+            this._observeFullscreen(true);
+            this._onFullscreenChanged();
+            const url = chrome.runtime.getURL('/popup.html');
+            if (useSecurePopupFrameUrl) {
+                frame.contentDocument.location.href = url;
+            } else {
+                frame.setAttribute('src', url);
+            }
+        };
+
+        const frameClient = new FrameClient();
+        this._frameClient = frameClient;
+        await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame);
+
+        // Configure
+        await this._invokeSafe('configure', {
+            depth: this._depth,
+            parentPopupId: this._id,
+            parentFrameId: this._frameId,
+            childrenSupported: this._childrenSupported,
+            scale: this._contentScale,
+            optionsContext: this._optionsContext
+        });
+    }
+
+    _onFrameLoad() {
+        if (!this._injectPromiseComplete) { return; }
+        this._resetFrame();
+    }
+
+    _resetFrame() {
+        const parent = this._container.parentNode;
+        if (parent !== null) {
+            parent.removeChild(this._container);
+        }
+        this._frame.removeAttribute('src');
+        this._frame.removeAttribute('srcdoc');
+
+        this._frameClient = null;
+        this._injectPromise = null;
+        this._injectPromiseComplete = false;
+    }
+
+    async _setUpContainer(usePopupShadowDom) {
+        if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') {
+            const container = document.createElement('div');
+            container.style.setProperty('all', 'initial', 'important');
+            const shadow = container.attachShadow({mode: 'closed', delegatesFocus: true});
+            shadow.appendChild(this._frame);
+
+            this._container = container;
+            this._shadow = shadow;
+        } else {
+            const frameParentNode = this._frame.parentNode;
+            if (frameParentNode !== null) {
+                frameParentNode.removeChild(this._frame);
+            }
+
+            this._container = this._frame;
+            this._shadow = null;
+        }
+
+        await this._injectStyles();
+    }
+
+    async _injectStyles() {
+        try {
+            await this._injectPopupOuterStylesheet();
+        } catch (e) {
+            // NOP
+        }
+
+        try {
+            await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true);
+        } catch (e) {
+            // NOP
+        }
+    }
+
+    async _injectPopupOuterStylesheet() {
+        let fileType = 'file';
+        let useWebExtensionApi = true;
+        let parentNode = null;
+        if (this._shadow !== null) {
+            fileType = 'file-content';
+            useWebExtensionApi = false;
+            parentNode = this._shadow;
+        }
+        await dynamicLoader.loadStyle('yomichan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode);
+    }
+
+    _observeFullscreen(observe) {
+        if (!observe) {
+            this._fullscreenEventListeners.removeAllEventListeners();
+            return;
+        }
+
+        if (this._fullscreenEventListeners.size > 0) {
+            // Already observing
+            return;
+        }
+
+        DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners);
+    }
+
+    _onFullscreenChanged() {
+        const parent = this._getFrameParentElement();
+        if (parent !== null && this._container.parentNode !== parent) {
+            parent.appendChild(this._container);
+        }
+    }
+
+    async _show(elementRect, writingMode) {
+        await this._inject();
+
+        const optionsGeneral = this._options.general;
+        const {popupDisplayMode} = optionsGeneral;
+        const frame = this._frame;
+        const frameRect = frame.getBoundingClientRect();
+
+        const viewport = this._getViewport(optionsGeneral.popupScaleRelativeToVisualViewport);
+        const scale = this._contentScale;
+        const scaleRatio = this._frameSizeContentScale === null ? 1.0 : scale / this._frameSizeContentScale;
+        this._frameSizeContentScale = scale;
+        const getPositionArgs = [
+            elementRect,
+            Math.max(frameRect.width * scaleRatio, optionsGeneral.popupWidth * scale),
+            Math.max(frameRect.height * scaleRatio, optionsGeneral.popupHeight * scale),
+            viewport,
+            scale,
+            optionsGeneral,
+            writingMode
+        ];
+        let [x, y, width, height, below] = (
+            writingMode === 'horizontal-tb' || optionsGeneral.popupVerticalTextPosition === 'default' ?
+            this._getPositionForHorizontalText(...getPositionArgs) :
+            this._getPositionForVerticalText(...getPositionArgs)
+        );
+
+        frame.dataset.popupDisplayMode = popupDisplayMode;
+        frame.dataset.below = `${below}`;
+
+        if (popupDisplayMode === 'full-width') {
+            x = viewport.left;
+            y = below ? viewport.bottom - height : viewport.top;
+            width = viewport.right - viewport.left;
+        }
+
+        frame.style.left = `${x}px`;
+        frame.style.top = `${y}px`;
+        this._setFrameSize(width, height);
+
+        this._setVisible(true);
+        if (this._child !== null) {
+            this._child.hide(true);
+        }
+    }
+
+    _setFrameSize(width, height) {
+        const {style} = this._frame;
+        style.width = `${width}px`;
+        style.height = `${height}px`;
+    }
+
+    _setVisible(visible) {
+        this._visible.defaultValue = visible;
+    }
+
+    _onVisibleChange({value}) {
+        this._frame.style.setProperty('visibility', value ? 'visible' : 'hidden', 'important');
+    }
+
+    _focusParent() {
+        if (this._parent !== null) {
+            // Chrome doesn't like focusing iframe without contentWindow.
+            const contentWindow = this._parent.frameContentWindow;
+            if (contentWindow !== null) {
+                contentWindow.focus();
+            }
+        } else {
+            // Firefox doesn't like focusing window without first blurring the iframe.
+            // this._frame.contentWindow.blur() doesn't work on Firefox for some reason.
+            this._frame.blur();
+            // This is needed for Chrome.
+            window.focus();
+        }
+    }
+
+    _getSiteColor() {
+        const color = [255, 255, 255];
+        const {documentElement, body} = document;
+        if (documentElement !== null) {
+            this._addColor(color, window.getComputedStyle(documentElement).backgroundColor);
+        }
+        if (body !== null) {
+            this._addColor(color, window.getComputedStyle(body).backgroundColor);
+        }
+        const dark = (color[0] < 128 && color[1] < 128 && color[2] < 128);
+        return dark ? 'dark' : 'light';
+    }
+
+    async _invoke(action, params={}) {
+        const contentWindow = this._frame.contentWindow;
+        if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
+
+        const message = this._frameClient.createMessage({action, params});
+        return await api.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message);
+    }
+
+    async _invokeSafe(action, params={}, defaultReturnValue) {
+        try {
+            return await this._invoke(action, params);
+        } catch (e) {
+            if (!yomichan.isExtensionUnloaded) { throw e; }
+            return defaultReturnValue;
+        }
+    }
+
+    _invokeWindow(action, params={}) {
+        const contentWindow = this._frame.contentWindow;
+        if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
+
+        const message = this._frameClient.createMessage({action, params});
+        contentWindow.postMessage(message, this._targetOrigin);
+    }
+
+    _onExtensionUnloaded() {
+        this._invokeWindow('extensionUnloaded');
+    }
+
+    _getFrameParentElement() {
+        const defaultParent = document.body;
+        const fullscreenElement = DocumentUtil.getFullscreenElement();
+        if (
+            fullscreenElement === null ||
+            fullscreenElement.shadowRoot ||
+            fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+        ) {
+            return defaultParent;
+        }
+
+        switch (fullscreenElement.nodeName.toUpperCase()) {
+            case 'IFRAME':
+            case 'FRAME':
+                return defaultParent;
+        }
+
+        return fullscreenElement;
+    }
+
+    _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) {
+        const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
+        const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale;
+        const verticalOffset = optionsGeneral.popupVerticalOffset * offsetScale;
+
+        const [x, w] = this._getConstrainedPosition(
+            elementRect.right - horizontalOffset,
+            elementRect.left + horizontalOffset,
+            width,
+            viewport.left,
+            viewport.right,
+            true
+        );
+        const [y, h, below] = this._getConstrainedPositionBinary(
+            elementRect.top - verticalOffset,
+            elementRect.bottom + verticalOffset,
+            height,
+            viewport.top,
+            viewport.bottom,
+            preferBelow
+        );
+        return [x, y, w, h, below];
+    }
+
+    _getPositionForVerticalText(elementRect, width, height, viewport, offsetScale, optionsGeneral, writingMode) {
+        const preferRight = this._isVerticalTextPopupOnRight(optionsGeneral.popupVerticalTextPosition, writingMode);
+        const horizontalOffset = optionsGeneral.popupHorizontalOffset2 * offsetScale;
+        const verticalOffset = optionsGeneral.popupVerticalOffset2 * offsetScale;
+
+        const [x, w] = this._getConstrainedPositionBinary(
+            elementRect.left - horizontalOffset,
+            elementRect.right + horizontalOffset,
+            width,
+            viewport.left,
+            viewport.right,
+            preferRight
+        );
+        const [y, h, below] = this._getConstrainedPosition(
+            elementRect.bottom - verticalOffset,
+            elementRect.top + verticalOffset,
+            height,
+            viewport.top,
+            viewport.bottom,
+            true
+        );
+        return [x, y, w, h, below];
+    }
+
+    _isVerticalTextPopupOnRight(positionPreference, writingMode) {
+        switch (positionPreference) {
+            case 'before':
+                return !this._isWritingModeLeftToRight(writingMode);
+            case 'after':
+                return this._isWritingModeLeftToRight(writingMode);
+            case 'left':
+                return false;
+            case 'right':
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    _isWritingModeLeftToRight(writingMode) {
+        switch (writingMode) {
+            case 'vertical-lr':
+            case 'sideways-lr':
+                return true;
+            default:
+                return false;
+        }
+    }
+
+    _getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
+        size = Math.min(size, maxLimit - minLimit);
+
+        let position;
+        if (after) {
+            position = Math.max(minLimit, positionAfter);
+            position = position - Math.max(0, (position + size) - maxLimit);
+        } else {
+            position = Math.min(maxLimit, positionBefore) - size;
+            position = position + Math.max(0, minLimit - position);
+        }
+
+        return [position, size, after];
+    }
+
+    _getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
+        const overflowBefore = minLimit - (positionBefore - size);
+        const overflowAfter = (positionAfter + size) - maxLimit;
+
+        if (overflowAfter > 0 || overflowBefore > 0) {
+            after = (overflowAfter < overflowBefore);
+        }
+
+        let position;
+        if (after) {
+            size -= Math.max(0, overflowAfter);
+            position = Math.max(minLimit, positionAfter);
+        } else {
+            size -= Math.max(0, overflowBefore);
+            position = Math.min(maxLimit, positionBefore) - size;
+        }
+
+        return [position, size, after];
+    }
+
+    _addColor(target, cssColor) {
+        if (typeof cssColor !== 'string') { return; }
+
+        const color = this._getColorInfo(cssColor);
+        if (color === null) { return; }
+
+        const a = color[3];
+        if (a <= 0.0) { return; }
+
+        const aInv = 1.0 - a;
+        for (let i = 0; i < 3; ++i) {
+            target[i] = target[i] * aInv + color[i] * a;
+        }
+    }
+
+    _getColorInfo(cssColor) {
+        const m = /^\s*rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)\s*$/.exec(cssColor);
+        if (m === null) { return null; }
+
+        const m4 = m[4];
+        return [
+            Number.parseInt(m[1], 10),
+            Number.parseInt(m[2], 10),
+            Number.parseInt(m[3], 10),
+            m4 ? Math.max(0.0, Math.min(1.0, Number.parseFloat(m4))) : 1.0
+        ];
+    }
+
+    _getViewport(useVisualViewport) {
+        const visualViewport = window.visualViewport;
+        if (visualViewport !== null && typeof visualViewport === 'object') {
+            const left = visualViewport.offsetLeft;
+            const top = visualViewport.offsetTop;
+            const width = visualViewport.width;
+            const height = visualViewport.height;
+            if (useVisualViewport) {
+                return {
+                    left,
+                    top,
+                    right: left + width,
+                    bottom: top + height
+                };
+            } else {
+                const scale = visualViewport.scale;
+                return {
+                    left: 0,
+                    top: 0,
+                    right: Math.max(left + width, width * scale),
+                    bottom: Math.max(top + height, height * scale)
+                };
+            }
+        }
+
+        const body = document.body;
+        return {
+            left: 0,
+            top: 0,
+            right: (body !== null ? body.clientWidth : 0),
+            bottom: window.innerHeight
+        };
+    }
+
+    async _setOptionsContext(optionsContext) {
+        this._optionsContext = optionsContext;
+        this._options = await api.optionsGet(optionsContext);
+        this.updateTheme();
+    }
+
+    async _setOptionsContextIfDifferent(optionsContext) {
+        if (deepEqual(this._optionsContext, optionsContext)) { return; }
+        await this._setOptionsContext(optionsContext);
+    }
+}
diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js
new file mode 100644
index 00000000..b1ed7114
--- /dev/null
+++ b/ext/js/comm/frame-ancestry-handler.js
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ */
+
+/**
+ * This class is used to return the ancestor frame IDs for the current frame.
+ * This is a workaround to using the `webNavigation.getAllFrames` API, which
+ * would require an additional permission that is otherwise unnecessary.
+ * It is also used to track the correlation between child frame elements and their IDs.
+ */
+class FrameAncestryHandler {
+    /**
+     * Creates a new instance.
+     * @param frameId The frame ID of the current frame the instance is instantiated in.
+     */
+    constructor(frameId) {
+        this._frameId = frameId;
+        this._isPrepared = false;
+        this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo';
+        this._responseMessageIdBase = `${this._requestMessageId}.response.`;
+        this._getFrameAncestryInfoPromise = null;
+        this._childFrameMap = new Map();
+    }
+
+    /**
+     * Gets the frame ID that the instance is instantiated in.
+     */
+    get frameId() {
+        return this._frameId;
+    }
+
+    /**
+     * Initializes event event listening.
+     */
+    prepare() {
+        if (this._isPrepared) { return; }
+        window.addEventListener('message', this._onWindowMessage.bind(this), false);
+        this._isPrepared = true;
+    }
+
+    /**
+     * Returns whether or not this frame is the root frame in the tab.
+     * @returns `true` if it is the root, otherwise `false`.
+     */
+    isRootFrame() {
+        return (window === window.parent);
+    }
+
+    /**
+     * Gets the frame ancestry information for the current frame. If the frame is the
+     * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned,
+     * starting from the nearest ancestor.
+     * @param timeout The maximum time to wait to receive a response to frame information requests.
+     * @returns An array of frame IDs corresponding to the ancestors of the current frame.
+     */
+    async getFrameAncestryInfo() {
+        if (this._getFrameAncestryInfoPromise === null) {
+            this._getFrameAncestryInfoPromise = this._getFrameAncestryInfo(5000);
+        }
+        return await this._getFrameAncestryInfoPromise;
+    }
+
+    /**
+     * Gets the frame element of a child frame given a frame ID.
+     * For this function to work, the `getFrameAncestryInfo` function needs to have
+     * been invoked previously.
+     * @param frameId The frame ID of the child frame to get.
+     * @returns The element corresponding to the frame with ID `frameId`, otherwise `null`.
+     */
+    getChildFrameElement(frameId) {
+        const frameInfo = this._childFrameMap.get(frameId);
+        if (typeof frameInfo === 'undefined') { return null; }
+
+        let {frameElement} = frameInfo;
+        if (typeof frameElement === 'undefined') {
+            frameElement = this._findFrameElementWithContentWindow(frameInfo.window);
+            frameInfo.frameElement = frameElement;
+        }
+
+        return frameElement;
+    }
+
+    // Private
+
+    _getFrameAncestryInfo(timeout=5000) {
+        return new Promise((resolve, reject) => {
+            const targetWindow = window.parent;
+            if (window === targetWindow) {
+                resolve([]);
+                return;
+            }
+
+            const uniqueId = generateId(16);
+            let nonce = generateId(16);
+            const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
+            const results = [];
+            let timer = null;
+
+            const cleanup = () => {
+                if (timer !== null) {
+                    clearTimeout(timer);
+                    timer = null;
+                }
+                api.crossFrame.unregisterHandler(responseMessageId);
+            };
+            const onMessage = (params) => {
+                if (params.nonce !== nonce) { return null; }
+
+                // Add result
+                const {frameId, more} = params;
+                results.push(frameId);
+                nonce = generateId(16);
+
+                if (!more) {
+                    // Cleanup
+                    cleanup();
+
+                    // Finish
+                    resolve(results);
+                }
+                return {nonce};
+            };
+            const onTimeout = () => {
+                timer = null;
+                cleanup();
+                reject(new Error(`Request for parent frame ID timed out after ${timeout}ms`));
+            };
+            const resetTimeout = () => {
+                if (timer !== null) { clearTimeout(timer); }
+                timer = setTimeout(onTimeout, timeout);
+            };
+
+            // Start
+            api.crossFrame.registerHandlers([[responseMessageId, {async: false, handler: onMessage}]]);
+            resetTimeout();
+            const frameId = this._frameId;
+            this._requestFrameInfo(targetWindow, frameId, frameId, uniqueId, nonce);
+        });
+    }
+
+    _onWindowMessage(event) {
+        const {source} = event;
+        if (source === window || source.parent !== window) { return; }
+
+        const {data} = event;
+        if (
+            typeof data === 'object' &&
+            data !== null &&
+            data.action === this._requestMessageId
+        ) {
+            this._onRequestFrameInfo(data.params, source);
+        }
+    }
+
+    async _onRequestFrameInfo(params, source) {
+        try {
+            let {originFrameId, childFrameId, uniqueId, nonce} = params;
+            if (
+                !this._isNonNegativeInteger(originFrameId) ||
+                typeof uniqueId !== 'string' ||
+                typeof nonce !== 'string'
+            ) {
+                return;
+            }
+
+            const frameId = this._frameId;
+            const {parent} = window;
+            const more = (window !== parent);
+            const responseParams = {frameId, nonce, more};
+            const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`;
+
+            try {
+                const response = await api.crossFrame.invoke(originFrameId, responseMessageId, responseParams);
+                if (response === null) { return; }
+                nonce = response.nonce;
+            } catch (e) {
+                return;
+            }
+
+            if (!this._childFrameMap.has(childFrameId)) {
+                this._childFrameMap.set(childFrameId, {window: source, frameElement: void 0});
+            }
+
+            if (more) {
+                this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce);
+            }
+        } catch (e) {
+            // NOP
+        }
+    }
+
+    _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) {
+        targetWindow.postMessage({
+            action: this._requestMessageId,
+            params: {originFrameId, childFrameId, uniqueId, nonce}
+        }, '*');
+    }
+
+    _isNonNegativeInteger(value) {
+        return (
+            typeof value === 'number' &&
+            Number.isFinite(value) &&
+            value >= 0 &&
+            Math.floor(value) === value
+        );
+    }
+
+    _findFrameElementWithContentWindow(contentWindow) {
+        // Check frameElement, for non-null same-origin frames
+        try {
+            const {frameElement} = contentWindow;
+            if (frameElement !== null) { return frameElement; }
+        } catch (e) {
+            // NOP
+        }
+
+        // Check frames
+        const frameTypes = ['iframe', 'frame', 'embed'];
+        for (const frameType of frameTypes) {
+            for (const frame of document.getElementsByTagName(frameType)) {
+                if (frame.contentWindow === contentWindow) {
+                    return frame;
+                }
+            }
+        }
+
+        // Check for shadow roots
+        const rootElements = [document.documentElement];
+        while (rootElements.length > 0) {
+            const rootElement = rootElements.shift();
+            const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT);
+            while (walker.nextNode()) {
+                const element = walker.currentNode;
+
+                if (element.contentWindow === contentWindow) {
+                    return element;
+                }
+
+                const shadowRoot = (
+                    element.shadowRoot ||
+                    element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+                );
+                if (shadowRoot) {
+                    rootElements.push(shadowRoot);
+                }
+            }
+        }
+
+        // Not found
+        return null;
+    }
+}
diff --git a/ext/js/comm/frame-offset-forwarder.js b/ext/js/comm/frame-offset-forwarder.js
new file mode 100644
index 00000000..0a0b4a18
--- /dev/null
+++ b/ext/js/comm/frame-offset-forwarder.js
@@ -0,0 +1,70 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * FrameAncestryHandler
+ * api
+ */
+
+class FrameOffsetForwarder {
+    constructor(frameId) {
+        this._frameId = frameId;
+        this._frameAncestryHandler = new FrameAncestryHandler(frameId);
+    }
+
+    prepare() {
+        this._frameAncestryHandler.prepare();
+        api.crossFrame.registerHandlers([
+            ['FrameOffsetForwarder.getChildFrameRect', {async: false, handler: this._onMessageGetChildFrameRect.bind(this)}]
+        ]);
+    }
+
+    async getOffset() {
+        if (this._frameAncestryHandler.isRootFrame()) {
+            return [0, 0];
+        }
+
+        const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo();
+
+        let childFrameId = this._frameId;
+        const promises = [];
+        for (const frameId of ancestorFrameIds) {
+            promises.push(api.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId}));
+            childFrameId = frameId;
+        }
+
+        const results = await Promise.all(promises);
+
+        let xOffset = 0;
+        let yOffset = 0;
+        for (const {x, y} of results) {
+            xOffset += x;
+            yOffset += y;
+        }
+        return [xOffset, yOffset];
+    }
+
+    // Private
+
+    _onMessageGetChildFrameRect({frameId}) {
+        const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId);
+        if (frameElement === null) { return null; }
+
+        const {x, y, width, height} = frameElement.getBoundingClientRect();
+        return {x, y, width, height};
+    }
+}
diff --git a/ext/js/display/display.js b/ext/js/display/display.js
index ffadd055..c522fe14 100644
--- a/ext/js/display/display.js
+++ b/ext/js/display/display.js
@@ -1573,13 +1573,13 @@ class Display extends EventDispatcher {
         await dynamicLoader.loadScripts([
             '/js/language/text-scanner.js',
             '/js/comm/frame-client.js',
-            '/fg/js/popup.js',
-            '/fg/js/popup-proxy.js',
-            '/fg/js/popup-window.js',
-            '/fg/js/popup-factory.js',
-            '/fg/js/frame-ancestry-handler.js',
-            '/fg/js/frame-offset-forwarder.js',
-            '/fg/js/frontend.js'
+            '/js/app/popup.js',
+            '/js/app/popup-proxy.js',
+            '/js/app/popup-window.js',
+            '/js/app/popup-factory.js',
+            '/js/comm/frame-ancestry-handler.js',
+            '/js/comm/frame-offset-forwarder.js',
+            '/js/app/frontend.js'
         ]);
 
         const popupFactory = new PopupFactory(this._frameId);
diff --git a/ext/js/display/popup-main.js b/ext/js/display/popup-main.js
new file mode 100644
index 00000000..7c048b62
--- /dev/null
+++ b/ext/js/display/popup-main.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Display
+ * DisplayProfileSelection
+ * DocumentFocusController
+ * HotkeyHandler
+ * JapaneseUtil
+ * api
+ */
+
+(async () => {
+    try {
+        const documentFocusController = new DocumentFocusController();
+        documentFocusController.prepare();
+
+        api.forwardLogsToBackend();
+        await yomichan.backendReady();
+
+        const {tabId, frameId} = await api.frameInformationGet();
+
+        const japaneseUtil = new JapaneseUtil(null);
+
+        const hotkeyHandler = new HotkeyHandler();
+        hotkeyHandler.prepare();
+
+        const display = new Display(tabId, frameId, 'popup', japaneseUtil, documentFocusController, hotkeyHandler);
+        await display.prepare();
+
+        const displayProfileSelection = new DisplayProfileSelection(display);
+        displayProfileSelection.prepare();
+
+        display.initializeState();
+
+        document.documentElement.dataset.loaded = 'true';
+
+        yomichan.ready();
+    } catch (e) {
+        yomichan.logError(e);
+    }
+})();
diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js
new file mode 100644
index 00000000..71e74fc3
--- /dev/null
+++ b/ext/js/dom/dom-text-scanner.js
@@ -0,0 +1,551 @@
+/*
+ * Copyright (C) 2020-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/**
+ * A class used to scan text in a document.
+ */
+class DOMTextScanner {
+    /**
+     * Creates a new instance of a DOMTextScanner.
+     * @param node The DOM Node to start at.
+     * @param offset The character offset in to start at when node is a text node.
+     *   Use 0 for non-text nodes.
+     */
+    constructor(node, offset, forcePreserveWhitespace=false, generateLayoutContent=true) {
+        const ruby = DOMTextScanner.getParentRubyElement(node);
+        const resetOffset = (ruby !== null);
+        if (resetOffset) { node = ruby; }
+
+        this._node = node;
+        this._offset = offset;
+        this._content = '';
+        this._remainder = 0;
+        this._resetOffset = resetOffset;
+        this._newlines = 0;
+        this._lineHasWhitespace = false;
+        this._lineHasContent = false;
+        this._forcePreserveWhitespace = forcePreserveWhitespace;
+        this._generateLayoutContent = generateLayoutContent;
+    }
+
+    /**
+     * Gets the current node being scanned.
+     * @returns A DOM Node.
+     */
+    get node() {
+        return this._node;
+    }
+
+    /**
+     * Gets the current offset corresponding to the node being scanned.
+     * This value is only applicable for text nodes.
+     * @returns An integer.
+     */
+    get offset() {
+        return this._offset;
+    }
+
+    /**
+     * Gets the remaining number of characters that weren't scanned in the last seek() call.
+     * This value is usually 0 unless the end of the document was reached.
+     * @returns An integer.
+     */
+    get remainder() {
+        return this._remainder;
+    }
+
+    /**
+     * Gets the accumulated content string resulting from calls to seek().
+     * @returns A string.
+     */
+    get content() {
+        return this._content;
+    }
+
+    /**
+     * Seeks a given length in the document and accumulates the text content.
+     * @param length A positive or negative integer corresponding to how many characters
+     *   should be added to content. Content is only added to the accumulation string,
+     *   never removed, so mixing seek calls with differently signed length values
+     *   may give unexpected results.
+     * @returns this
+     */
+    seek(length) {
+        const forward = (length >= 0);
+        this._remainder = (forward ? length : -length);
+        if (length === 0) { return this; }
+
+        const TEXT_NODE = Node.TEXT_NODE;
+        const ELEMENT_NODE = Node.ELEMENT_NODE;
+
+        const generateLayoutContent = this._generateLayoutContent;
+        let node = this._node;
+        let lastNode = node;
+        let resetOffset = this._resetOffset;
+        let newlines = 0;
+        while (node !== null) {
+            let enterable = false;
+            const nodeType = node.nodeType;
+
+            if (nodeType === TEXT_NODE) {
+                lastNode = node;
+                if (!(
+                    forward ?
+                    this._seekTextNodeForward(node, resetOffset) :
+                    this._seekTextNodeBackward(node, resetOffset)
+                )) {
+                    // Length reached
+                    break;
+                }
+            } else if (nodeType === ELEMENT_NODE) {
+                lastNode = node;
+                this._offset = 0;
+                [enterable, newlines] = DOMTextScanner.getElementSeekInfo(node);
+                if (newlines > this._newlines && generateLayoutContent) {
+                    this._newlines = newlines;
+                }
+            }
+
+            const exitedNodes = [];
+            node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes);
+
+            for (const exitedNode of exitedNodes) {
+                if (exitedNode.nodeType !== ELEMENT_NODE) { continue; }
+                newlines = DOMTextScanner.getElementSeekInfo(exitedNode)[1];
+                if (newlines > this._newlines && generateLayoutContent) {
+                    this._newlines = newlines;
+                }
+            }
+
+            resetOffset = true;
+        }
+
+        this._node = lastNode;
+        this._resetOffset = resetOffset;
+
+        return this;
+    }
+
+    // Private
+
+    /**
+     * Seeks forward in a text node.
+     * @param textNode The text node to use.
+     * @param resetOffset Whether or not the text offset should be reset.
+     * @returns true if scanning should continue, or false if the scan length has been reached.
+     */
+    _seekTextNodeForward(textNode, resetOffset) {
+        const nodeValue = textNode.nodeValue;
+        const nodeValueLength = nodeValue.length;
+        const [preserveNewlines, preserveWhitespace] = (
+            this._forcePreserveWhitespace ?
+            [true, true] :
+            DOMTextScanner.getWhitespaceSettings(textNode)
+        );
+
+        let lineHasWhitespace = this._lineHasWhitespace;
+        let lineHasContent = this._lineHasContent;
+        let content = this._content;
+        let offset = resetOffset ? 0 : this._offset;
+        let remainder = this._remainder;
+        let newlines = this._newlines;
+
+        while (offset < nodeValueLength) {
+            const char = nodeValue[offset];
+            const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+            ++offset;
+
+            if (charAttributes === 0) {
+                // Character should be ignored
+                continue;
+            } else if (charAttributes === 1) {
+                // Character is collapsable whitespace
+                lineHasWhitespace = true;
+            } else {
+                // Character should be added to the content
+                if (newlines > 0) {
+                    if (content.length > 0) {
+                        const useNewlineCount = Math.min(remainder, newlines);
+                        content += '\n'.repeat(useNewlineCount);
+                        remainder -= useNewlineCount;
+                        newlines -= useNewlineCount;
+                    } else {
+                        newlines = 0;
+                    }
+                    lineHasContent = false;
+                    lineHasWhitespace = false;
+                    if (remainder <= 0) {
+                        --offset; // Revert character offset
+                        break;
+                    }
+                }
+
+                lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+                if (lineHasWhitespace) {
+                    if (lineHasContent) {
+                        content += ' ';
+                        lineHasWhitespace = false;
+                        if (--remainder <= 0) {
+                            --offset; // Revert character offset
+                            break;
+                        }
+                    } else {
+                        lineHasWhitespace = false;
+                    }
+                }
+
+                content += char;
+
+                if (--remainder <= 0) { break; }
+            }
+        }
+
+        this._lineHasWhitespace = lineHasWhitespace;
+        this._lineHasContent = lineHasContent;
+        this._content = content;
+        this._offset = offset;
+        this._remainder = remainder;
+        this._newlines = newlines;
+
+        return (remainder > 0);
+    }
+
+    /**
+     * Seeks backward in a text node.
+     * This function is nearly the same as _seekTextNodeForward, with the following differences:
+     * - Iteration condition is reversed to check if offset is greater than 0.
+     * - offset is reset to nodeValueLength instead of 0.
+     * - offset is decremented instead of incremented.
+     * - offset is decremented before getting the character.
+     * - offset is reverted by incrementing instead of decrementing.
+     * - content string is prepended instead of appended.
+     * @param textNode The text node to use.
+     * @param resetOffset Whether or not the text offset should be reset.
+     * @returns true if scanning should continue, or false if the scan length has been reached.
+     */
+    _seekTextNodeBackward(textNode, resetOffset) {
+        const nodeValue = textNode.nodeValue;
+        const nodeValueLength = nodeValue.length;
+        const [preserveNewlines, preserveWhitespace] = (
+            this._forcePreserveWhitespace ?
+            [true, true] :
+            DOMTextScanner.getWhitespaceSettings(textNode)
+        );
+
+        let lineHasWhitespace = this._lineHasWhitespace;
+        let lineHasContent = this._lineHasContent;
+        let content = this._content;
+        let offset = resetOffset ? nodeValueLength : this._offset;
+        let remainder = this._remainder;
+        let newlines = this._newlines;
+
+        while (offset > 0) {
+            --offset;
+            const char = nodeValue[offset];
+            const charAttributes = DOMTextScanner.getCharacterAttributes(char, preserveNewlines, preserveWhitespace);
+
+            if (charAttributes === 0) {
+                // Character should be ignored
+                continue;
+            } else if (charAttributes === 1) {
+                // Character is collapsable whitespace
+                lineHasWhitespace = true;
+            } else {
+                // Character should be added to the content
+                if (newlines > 0) {
+                    if (content.length > 0) {
+                        const useNewlineCount = Math.min(remainder, newlines);
+                        content = '\n'.repeat(useNewlineCount) + content;
+                        remainder -= useNewlineCount;
+                        newlines -= useNewlineCount;
+                    } else {
+                        newlines = 0;
+                    }
+                    lineHasContent = false;
+                    lineHasWhitespace = false;
+                    if (remainder <= 0) {
+                        ++offset; // Revert character offset
+                        break;
+                    }
+                }
+
+                lineHasContent = (charAttributes === 2); // 3 = character is a newline
+
+                if (lineHasWhitespace) {
+                    if (lineHasContent) {
+                        content = ' ' + content;
+                        lineHasWhitespace = false;
+                        if (--remainder <= 0) {
+                            ++offset; // Revert character offset
+                            break;
+                        }
+                    } else {
+                        lineHasWhitespace = false;
+                    }
+                }
+
+                content = char + content;
+
+                if (--remainder <= 0) { break; }
+            }
+        }
+
+        this._lineHasWhitespace = lineHasWhitespace;
+        this._lineHasContent = lineHasContent;
+        this._content = content;
+        this._offset = offset;
+        this._remainder = remainder;
+        this._newlines = newlines;
+
+        return (remainder > 0);
+    }
+
+    // Static helpers
+
+    /**
+     * Gets the next node in the document for a specified scanning direction.
+     * @param node The current DOM Node.
+     * @param forward Whether to scan forward in the document or backward.
+     * @param visitChildren Whether the children of the current node should be visited.
+     * @param exitedNodes An array which stores nodes which were exited.
+     * @returns The next node in the document, or null if there is no next node.
+     */
+    static getNextNode(node, forward, visitChildren, exitedNodes) {
+        let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null;
+        if (next === null) {
+            while (true) {
+                exitedNodes.push(node);
+
+                next = (forward ? node.nextSibling : node.previousSibling);
+                if (next !== null) { break; }
+
+                next = node.parentNode;
+                if (next === null) { break; }
+
+                node = next;
+            }
+        }
+        return next;
+    }
+
+    /**
+     * Gets the parent element of a given Node.
+     * @param node The node to check.
+     * @returns The parent element if one exists, otherwise null.
+     */
+    static getParentElement(node) {
+        while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+            node = node.parentNode;
+        }
+        return node;
+    }
+
+    /**
+     * Gets the parent <ruby> element of a given node, if one exists. For efficiency purposes,
+     * this only checks the immediate parent elements and does not check all ancestors, so
+     * there are cases where the node may be in a ruby element but it is not returned.
+     * @param node The node to check.
+     * @returns A <ruby> node if the input node is contained in one, otherwise null.
+     */
+    static getParentRubyElement(node) {
+        node = DOMTextScanner.getParentElement(node);
+        if (node !== null && node.nodeName.toUpperCase() === 'RT') {
+            node = node.parentNode;
+            if (node !== null && node.nodeName.toUpperCase() === 'RUBY') {
+                return node;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * @returns [enterable: boolean, newlines: integer]
+     *   The enterable value indicates whether the content of this node should be entered.
+     *   The newlines value corresponds to the number of newline characters that should be added.
+     *     1 newline corresponds to a simple new line in the layout.
+     *     2 newlines corresponds to a significant visual distinction since the previous content.
+     */
+    static getElementSeekInfo(element) {
+        let enterable = true;
+        switch (element.nodeName.toUpperCase()) {
+            case 'HEAD':
+            case 'RT':
+            case 'SCRIPT':
+            case 'STYLE':
+                return [false, 0];
+            case 'BR':
+                return [false, 1];
+            case 'TEXTAREA':
+            case 'INPUT':
+            case 'BUTTON':
+                enterable = false;
+                break;
+        }
+
+        const style = window.getComputedStyle(element);
+        const display = style.display;
+
+        const visible = (display !== 'none' && DOMTextScanner.isStyleVisible(style));
+        let newlines = 0;
+
+        if (!visible) {
+            enterable = false;
+        } else {
+            switch (style.position) {
+                case 'absolute':
+                case 'fixed':
+                case 'sticky':
+                    newlines = 2;
+                    break;
+            }
+            if (newlines === 0 && DOMTextScanner.doesCSSDisplayChangeLayout(display)) {
+                newlines = 1;
+            }
+        }
+
+        return [enterable, newlines];
+    }
+
+    /**
+     * Gets information about how whitespace characters are treated.
+     * @param textNode The Text node to check.
+     * @returns [preserveNewlines: boolean, preserveWhitespace: boolean]
+     *   The value of preserveNewlines indicates whether or not newline characters are treated as line breaks.
+     *   The value of preserveWhitespace indicates whether or not sequences of whitespace characters are collapsed.
+     */
+    static getWhitespaceSettings(textNode) {
+        const element = DOMTextScanner.getParentElement(textNode);
+        if (element !== null) {
+            const style = window.getComputedStyle(element);
+            switch (style.whiteSpace) {
+                case 'pre':
+                case 'pre-wrap':
+                case 'break-spaces':
+                    return [true, true];
+                case 'pre-line':
+                    return [true, false];
+            }
+        }
+        return [false, false];
+    }
+
+    /**
+     * Gets attributes for the specified character.
+     * @param character A string containing a single character.
+     * @returns An integer representing the attributes of the character.
+     *   0: Character should be ignored.
+     *   1: Character is collapsable whitespace.
+     *   2: Character should be added to the content.
+     *   3: Character should be added to the content and is a newline.
+     */
+    static getCharacterAttributes(character, preserveNewlines, preserveWhitespace) {
+        switch (character.charCodeAt(0)) {
+            case 0x09: // Tab ('\t')
+            case 0x0c: // Form feed ('\f')
+            case 0x0d: // Carriage return ('\r')
+            case 0x20: // Space (' ')
+                return preserveWhitespace ? 2 : 1;
+            case 0x0a: // Line feed ('\n')
+                return preserveNewlines ? 3 : 1;
+            case 0x200c: // Zero-width non-joiner ('\u200c')
+                return 0;
+            default: // Other
+                return 2;
+        }
+    }
+
+    /**
+     * Checks whether a given style is visible or not.
+     * This function does not check style.display === 'none'.
+     * @param style An object implementing the CSSStyleDeclaration interface.
+     * @returns true if the style should result in an element being visible, otherwise false.
+     */
+    static isStyleVisible(style) {
+        return !(
+            style.visibility === 'hidden' ||
+            parseFloat(style.opacity) <= 0 ||
+            parseFloat(style.fontSize) <= 0 ||
+            (
+                !DOMTextScanner.isStyleSelectable(style) &&
+                (
+                    DOMTextScanner.isCSSColorTransparent(style.color) ||
+                    DOMTextScanner.isCSSColorTransparent(style.webkitTextFillColor)
+                )
+            )
+        );
+    }
+
+    /**
+     * Checks whether a given style is selectable or not.
+     * @param style An object implementing the CSSStyleDeclaration interface.
+     * @returns true if the style is selectable, otherwise false.
+     */
+    static isStyleSelectable(style) {
+        return !(
+            style.userSelect === 'none' ||
+            style.webkitUserSelect === 'none' ||
+            style.MozUserSelect === 'none' ||
+            style.msUserSelect === 'none'
+        );
+    }
+
+    /**
+     * Checks whether a CSS color is transparent or not.
+     * @param cssColor A CSS color string, expected to be encoded in rgb(a) form.
+     * @returns true if the color is transparent, otherwise false.
+     */
+    static isCSSColorTransparent(cssColor) {
+        return (
+            typeof cssColor === 'string' &&
+            cssColor.startsWith('rgba(') &&
+            /,\s*0.?0*\)$/.test(cssColor)
+        );
+    }
+
+    /**
+     * Checks whether a CSS display value will cause a layout change for text.
+     * @param cssDisplay A CSS string corresponding to the value of the display property.
+     * @returns true if the layout is changed by this value, otherwise false.
+     */
+    static doesCSSDisplayChangeLayout(cssDisplay) {
+        let pos = cssDisplay.indexOf(' ');
+        if (pos >= 0) {
+            // Truncate to <display-outside> part
+            cssDisplay = cssDisplay.substring(0, pos);
+        }
+
+        pos = cssDisplay.indexOf('-');
+        if (pos >= 0) {
+            // Truncate to first part of kebab-case value
+            cssDisplay = cssDisplay.substring(0, pos);
+        }
+
+        switch (cssDisplay) {
+            case 'block':
+            case 'flex':
+            case 'grid':
+            case 'list': // list-item
+            case 'table': // table, table-*
+                return true;
+            case 'ruby': // rubt-*
+                return (pos >= 0);
+            default:
+                return false;
+        }
+    }
+}
diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js
new file mode 100644
index 00000000..45186636
--- /dev/null
+++ b/ext/js/dom/text-source-element.js
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2016-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class TextSourceElement {
+    constructor(element, fullContent=null, startOffset=0, endOffset=0) {
+        this._element = element;
+        this._fullContent = (typeof fullContent === 'string' ? fullContent : TextSourceElement.getElementContent(element));
+        this._startOffset = startOffset;
+        this._endOffset = endOffset;
+        this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+    }
+
+    get element() {
+        return this._element;
+    }
+
+    get fullContent() {
+        return this._fullContent;
+    }
+
+    get startOffset() {
+        return this._startOffset;
+    }
+
+    get endOffset() {
+        return this._endOffset;
+    }
+
+    get isConnected() {
+        return this._element.isConnected;
+    }
+
+    clone() {
+        return new TextSourceElement(this._element, this._fullContent, this._startOffset, this._endOffset);
+    }
+
+    cleanup() {
+        // NOP
+    }
+
+    text() {
+        return this._content;
+    }
+
+    setEndOffset(length, fromEnd=false) {
+        if (fromEnd) {
+            const delta = Math.min(this._fullContent.length - this._endOffset, length);
+            this._endOffset += delta;
+            this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+            return delta;
+        } else {
+            const delta = Math.min(this._fullContent.length - this._startOffset, length);
+            this._endOffset = this._startOffset + delta;
+            this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+            return delta;
+        }
+    }
+
+    setStartOffset(length) {
+        const delta = Math.min(this._startOffset, length);
+        this._startOffset -= delta;
+        this._content = this._fullContent.substring(this._startOffset, this._endOffset);
+        return delta;
+    }
+
+    collapse(toStart) {
+        if (toStart) {
+            this._endOffset = this._startOffset;
+        } else {
+            this._startOffset = this._endOffset;
+        }
+        this._content = '';
+    }
+
+    getRect() {
+        return this._element.getBoundingClientRect();
+    }
+
+    getWritingMode() {
+        return 'horizontal-tb';
+    }
+
+    select() {
+        // NOP
+    }
+
+    deselect() {
+        // NOP
+    }
+
+    hasSameStart(other) {
+        return (
+            typeof other === 'object' &&
+            other !== null &&
+            other instanceof TextSourceElement &&
+            this._element === other.element &&
+            this._fullContent === other.fullContent &&
+            this._startOffset === other.startOffset
+        );
+    }
+
+    getNodesInRange() {
+        return [this._element];
+    }
+
+    static getElementContent(element) {
+        let content;
+        switch (element.nodeName.toUpperCase()) {
+            case 'BUTTON':
+                content = element.textContent;
+                break;
+            case 'IMG':
+                content = element.getAttribute('alt') || '';
+                break;
+            default:
+                content = `${element.value}`;
+                break;
+        }
+
+        // Remove zero-width non-joiner
+        content = content.replace(/\u200c/g, '');
+
+        return content;
+    }
+}
diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js
new file mode 100644
index 00000000..377016da
--- /dev/null
+++ b/ext/js/dom/text-source-range.js
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2016-2021  Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DOMTextScanner
+ * DocumentUtil
+ */
+
+class TextSourceRange {
+    constructor(range, content, imposterContainer, imposterSourceElement) {
+        this._range = range;
+        this._rangeStartOffset = range.startOffset;
+        this._content = content;
+        this._imposterContainer = imposterContainer;
+        this._imposterSourceElement = imposterSourceElement;
+    }
+
+    get range() {
+        return this._range;
+    }
+
+    get rangeStartOffset() {
+        return this._rangeStartOffset;
+    }
+
+    get imposterSourceElement() {
+        return this._imposterSourceElement;
+    }
+
+    get isConnected() {
+        return (
+            this._range.startContainer.isConnected &&
+            this._range.endContainer.isConnected
+        );
+    }
+
+    clone() {
+        return new TextSourceRange(this._range.cloneRange(), this._content, this._imposterContainer, this._imposterSourceElement);
+    }
+
+    cleanup() {
+        if (this._imposterContainer !== null && this._imposterContainer.parentNode !== null) {
+            this._imposterContainer.parentNode.removeChild(this._imposterContainer);
+        }
+    }
+
+    text() {
+        return this._content;
+    }
+
+    setEndOffset(length, layoutAwareScan, fromEnd=false) {
+        const state = (
+            fromEnd ?
+            new DOMTextScanner(this._range.endContainer, this._range.endOffset, !layoutAwareScan, layoutAwareScan).seek(length) :
+            new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(length)
+        );
+        this._range.setEnd(state.node, state.offset);
+        this._content = (fromEnd ? this._content + state.content : state.content);
+        return length - state.remainder;
+    }
+
+    setStartOffset(length, layoutAwareScan) {
+        const state = new DOMTextScanner(this._range.startContainer, this._range.startOffset, !layoutAwareScan, layoutAwareScan).seek(-length);
+        this._range.setStart(state.node, state.offset);
+        this._rangeStartOffset = this._range.startOffset;
+        this._content = state.content + this._content;
+        return length - state.remainder;
+    }
+
+    collapse(toStart) {
+        this._range.collapse(toStart);
+        this._content = '';
+    }
+
+    getRect() {
+        return this._range.getBoundingClientRect();
+    }
+
+    getWritingMode() {
+        return TextSourceRange.getElementWritingMode(TextSourceRange.getParentElement(this._range.startContainer));
+    }
+
+    select() {
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(this._range);
+    }
+
+    deselect() {
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+    }
+
+    hasSameStart(other) {
+        if (!(
+            typeof other === 'object' &&
+            other !== null &&
+            other instanceof TextSourceRange
+        )) {
+            return false;
+        }
+        if (this._imposterSourceElement !== null) {
+            return (
+                this._imposterSourceElement === other.imposterSourceElement &&
+                this._rangeStartOffset === other.rangeStartOffset
+            );
+        } else {
+            try {
+                return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0;
+            } catch (e) {
+                if (e.name === 'WrongDocumentError') {
+                    // This can happen with shadow DOMs if the ranges are in different documents.
+                    return false;
+                }
+                throw e;
+            }
+        }
+    }
+
+    getNodesInRange() {
+        return DocumentUtil.getNodesInRange(this._range);
+    }
+
+    static getParentElement(node) {
+        while (node !== null && node.nodeType !== Node.ELEMENT_NODE) {
+            node = node.parentNode;
+        }
+        return node;
+    }
+
+    static getElementWritingMode(element) {
+        if (element !== null) {
+            const style = window.getComputedStyle(element);
+            const writingMode = style.writingMode;
+            if (typeof writingMode === 'string') {
+                return TextSourceRange.normalizeWritingMode(writingMode);
+            }
+        }
+        return 'horizontal-tb';
+    }
+
+    static normalizeWritingMode(writingMode) {
+        switch (writingMode) {
+            case 'lr':
+            case 'lr-tb':
+            case 'rl':
+                return 'horizontal-tb';
+            case 'tb':
+                return 'vertical-lr';
+            case 'tb-rl':
+                return 'vertical-rl';
+            default:
+                return writingMode;
+        }
+    }
+}
diff --git a/ext/manifest.json b/ext/manifest.json
index 4ac53273..85aecc9c 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -47,17 +47,17 @@
                 "js/language/text-scanner.js",
                 "js/dom/document-util.js",
                 "js/input/hotkey-handler.js",
-                "fg/js/dom-text-scanner.js",
-                "fg/js/popup.js",
-                "fg/js/text-source-range.js",
-                "fg/js/text-source-element.js",
-                "fg/js/popup-factory.js",
-                "fg/js/frame-ancestry-handler.js",
-                "fg/js/frame-offset-forwarder.js",
-                "fg/js/popup-proxy.js",
-                "fg/js/popup-window.js",
-                "fg/js/frontend.js",
-                "fg/js/content-script-main.js"
+                "js/dom/dom-text-scanner.js",
+                "js/app/popup.js",
+                "js/dom/text-source-range.js",
+                "js/dom/text-source-element.js",
+                "js/app/popup-factory.js",
+                "js/comm/frame-ancestry-handler.js",
+                "js/comm/frame-offset-forwarder.js",
+                "js/app/popup-proxy.js",
+                "js/app/popup-window.js",
+                "js/app/frontend.js",
+                "js/app/content-script-main.js"
             ],
             "match_about_blank": true,
             "all_frames": true
diff --git a/ext/popup-preview.html b/ext/popup-preview.html
index 18aec551..6f6ff8b1 100644
--- a/ext/popup-preview.html
+++ b/ext/popup-preview.html
@@ -46,14 +46,14 @@
 
 <script src="/js/dom/document-util.js"></script>
 <script src="/js/input/hotkey-handler.js"></script>
-<script src="/fg/js/dom-text-scanner.js"></script>
-<script src="/fg/js/popup.js"></script>
-<script src="/fg/js/text-source-range.js"></script>
-<script src="/fg/js/text-source-element.js"></script>
-<script src="/fg/js/popup-factory.js"></script>
-<script src="/fg/js/frontend.js"></script>
-<script src="/fg/js/frame-ancestry-handler.js"></script>
-<script src="/fg/js/frame-offset-forwarder.js"></script>
+<script src="/js/dom/dom-text-scanner.js"></script>
+<script src="/js/app/popup.js"></script>
+<script src="/js/dom/text-source-range.js"></script>
+<script src="/js/dom/text-source-element.js"></script>
+<script src="/js/app/popup-factory.js"></script>
+<script src="/js/app/frontend.js"></script>
+<script src="/js/comm/frame-ancestry-handler.js"></script>
+<script src="/js/comm/frame-offset-forwarder.js"></script>
 <script src="/bg/js/settings/popup-preview-frame.js"></script>
 
 <script src="/bg/js/settings/popup-preview-frame-main.js"></script>
diff --git a/ext/popup.html b/ext/popup.html
index 274340ca..e31237dd 100644
--- a/ext/popup.html
+++ b/ext/popup.html
@@ -96,9 +96,9 @@
 <script src="/js/language/japanese-util.js"></script>
 
 <script src="/js/dom/document-util.js"></script>
-<script src="/fg/js/dom-text-scanner.js"></script>
-<script src="/fg/js/text-source-range.js"></script>
-<script src="/fg/js/text-source-element.js"></script>
+<script src="/js/dom/dom-text-scanner.js"></script>
+<script src="/js/dom/text-source-range.js"></script>
+<script src="/js/dom/text-source-element.js"></script>
 <script src="/js/media/audio-system.js"></script>
 <script src="/js/language/dictionary-data-util.js"></script>
 <script src="/js/display/display.js"></script>
@@ -126,7 +126,7 @@
 
 <script src="/bg/js/query-parser.js"></script>
 
-<script src="/fg/js/float-main.js"></script>
+<script src="/js/display/popup-main.js"></script>
 
 </body>
 </html>
diff --git a/ext/search.html b/ext/search.html
index 384493c8..1efef61e 100644
--- a/ext/search.html
+++ b/ext/search.html
@@ -83,9 +83,9 @@
 
 <script src="/js/dom/document-focus-controller.js"></script>
 <script src="/js/dom/document-util.js"></script>
-<script src="/fg/js/dom-text-scanner.js"></script>
-<script src="/fg/js/text-source-range.js"></script>
-<script src="/fg/js/text-source-element.js"></script>
+<script src="/js/dom/dom-text-scanner.js"></script>
+<script src="/js/dom/text-source-range.js"></script>
+<script src="/js/dom/text-source-element.js"></script>
 <script src="/js/media/audio-system.js"></script>
 <script src="/js/language/dictionary-data-util.js"></script>
 <script src="/js/display/display.js"></script>
diff --git a/test/test-document-util.js b/test/test-document-util.js
index 5c6a3894..52fabf97 100644
--- a/test/test-document-util.js
+++ b/test/test-document-util.js
@@ -94,9 +94,9 @@ async function testDocument1() {
 
     const vm = new VM({document, window, Range, Node});
     vm.execute([
-        'fg/js/dom-text-scanner.js',
-        'fg/js/text-source-range.js',
-        'fg/js/text-source-element.js',
+        'js/dom/dom-text-scanner.js',
+        'js/dom/text-source-range.js',
+        'js/dom/text-source-element.js',
         'js/dom/document-util.js'
     ]);
     const [DOMTextScanner, TextSourceRange, TextSourceElement, DocumentUtil] = vm.get([
diff --git a/test/test-dom-text-scanner.js b/test/test-dom-text-scanner.js
index 6fac695a..31eca1e7 100644
--- a/test/test-dom-text-scanner.js
+++ b/test/test-dom-text-scanner.js
@@ -166,7 +166,7 @@ async function testDocument1() {
         window.getComputedStyle = createAbsoluteGetComputedStyle(window);
 
         const vm = new VM({document, window, Range, Node});
-        vm.execute('fg/js/dom-text-scanner.js');
+        vm.execute('js/dom/dom-text-scanner.js');
         const DOMTextScanner = vm.get('DOMTextScanner');
 
         await testDomTextScanner(dom, {DOMTextScanner});
-- 
cgit v1.2.3