<button>Toggle window</button>

<window- screen-x="200" screen-y="200" width="600" height="200">
	<div style="background: skyblue; width: 100%; height: 100%;">I render in the window.</div>
</window->
/////////////////////////////////////////////////////////
///// This is what user code looks like.
/////////////////////////////////////////////////////////
function userCode() {
    const win = document.querySelector("window-");
    const btn = document.querySelector("button");

    // Toggle the window open attribute on each click.
    btn.addEventListener("click", () => {
        if (win.hasAttribute("open")) win.removeAttribute("open");
        else win.setAttribute("open", "");
    });

    const div = document.querySelector("div");

    // Click on the div regardless if it is rendering in the window or not...
    div.addEventListener("click", (e) => {
        if (e.target === div) {
            const composedPath = e.composedPath();
            let path = "";
            let i = composedPath.length;

            while (i--) {
                path =
                    path +
                    composedPath[i].constructor.name +
                    (i === 0 ? "" : " --> ");
            }

            console.log("You clicked the div. Here is the Event's composedPath (in the main DOM!): \n" + path);
        }
    });
}

/////////////////////////////////////////////////////////
///// The following is the implementation of the <window> prototype.
/////////////////////////////////////////////////////////

// TODO
//  - [ ] Use the innerWidth and innerHeight of the opened window for the rendering size, then resize the window's outer size accordingly.
//  - [ ] Rendering ShadowDOM: patch the `ShadowRoot.attachShadow` method so we can traverse the "flat tree", and update html2canvas to traverse this tree instead of the "light tree".
//  - [ ] Prevent any race condition, if any, due to the async handling of the html2canvas rendering. What happens if a user changes the attributes too fast.
//  - [ ] At the moment we only emulate click events. Track all events in the popup window along with their coordinates, and emulate them in the main window.

prototype();
userCode();

import html2canvas from "https://unpkg.com/html2canvas@1.0.0-rc.5/dist/html2canvas.esm.js";

function prototype() {
    // (I'm using this for syntax highlight/linting/formatting convenience in modern editors.)
    const html = identityTemplateTag;

    // Imagine the following is a new browser built-in element. It does some
    // hacks that a native implementation would not do.

    class HTMLWindowElement extends HTMLElement {
        // Note, property names with double underscores are "private"

        constructor() {
            super();

            this.__onClick = this.__onClick.bind(this);
            this.__onUserCloseWindow = this.__onUserCloseWindow.bind(this);
            this.__closeWindow = this.__closeWindow.bind(this);

            this.style.display = "block";
        }

        connectedCallback() {
            // This early return is a hack due to html2canvas running cloneNode on the entire document.
            if (this.ownerDocument !== document) return;

            this.__render();
        }

        disconnectedCallback() {
            // This early return is a hack due to html2canvas running cloneNode on the entire document.
            if (this.ownerDocument !== document) return;

            this.__closeWindow();
        }

        // prettier-ignore
        static observedAttributes = ["screen-x", "screen-y", "width", "height", "open"];
        attributeChangedCallback(attr, _, __) {
            // This early return is a hack due to html2canvas running cloneNode on the entire document.
            if (this.ownerDocument !== document) return;

            if (!this.isConnected) return;

            if (attr === "open") {
                const open = this.hasAttribute("open") || false;
                if (open) {
                    this.__openWindow();
                    this.__render();
                } else this.__closeWindow();
            } else this.__render();
        }

        static __winHtml = html`<!DOCTYPE html>
            <html>
                <head>
                    <title>Declarative window</title>
                    <style>
                        html,
                        body {
                            width: 100%;
                            height: 100%;
                            padding: 0;
                            margin: 0;
                            box-sizing: border-box;
                        }
                    </style>
                </head>
                <body>
                    <!-- a <canvas> is injected here -->
                </body>
            </html>`;

        static __winUrl = URL.createObjectURL(
            new Blob([this.__winHtml], { type: "text/html" })
        );

        __win = null;
        __canvas = null;
        __canvasPromise = null;

        __openWindow() {
            const screenX = this.getAttribute("screen-x") || "0";
            const screenY = this.getAttribute("screen-y") || "0";
            const width = this.getAttribute("width") || "800";
            const height = this.getAttribute("height") || "600";

            this.__win = window.open(
                this.constructor.__winUrl,
                "win",
                `width=${width},height=${height},screenX=${screenX},screenY=${screenY}`
            );

            // TODO Handle all types of events, and emulate them back in the main window.
            this.__win.addEventListener("click", this.__onClick);

            this.__win.addEventListener(
                "beforeunload",
                this.__onUserCloseWindow,
                { once: true }
            );

            window.addEventListener("beforeunload", this.__closeWindow, {
                once: true,
            });
        }

        /** @param {MouseEvent} e */
        __onClick(e) {
            // It's no fun Event properties are non-enumerable, so we have to manually copy them all.
            const emulated = new MouseEvent("click", {
                altKey: e.altKey,
                button: e.button,
                buttons: e.buttons,
                clientX: e.clientX,
                clientY: e.clientY,
                ctrlKey: e.ctrlKey,
                metaKey: e.metaKey,
                movementX: e.movementX,
                movementY: e.movementY,
                offsetX: e.offsetX,
                offsetY: e.offsetY,
                pageX: e.pageX,
                pageY: e.pageY,
                relatedTarget: e.relatedTarget,
                screenX: e.screenX,
                screenY: e.screenY,
                shiftKey: e.shiftKey,
                x: e.x,
                y: e.y,
            });

            // TODO get the target based on the coordinates of the event within the window
            const target = this.firstElementChild;

            target.dispatchEvent(emulated);
        }

        __onUserCloseWindow() {
            this.removeAttribute("open");
        }

        __closeWindow() {
            if (this.__win) {
                // probably not necessary
                this.__win.removeEventListener("click", this.__onClick);
                this.__win.removeEventListener(
                    "beforeunload",
                    this.__onUserCloseWindow
                );

                this.__win.close();
            }
            this.__win = null;
            this.__canvas = null;
            this.__canvasPromise = null;
            window.removeEventListener("beforeunload", this.__closeWindow);

            this.__showMainDOM();
        }

        __hideMainDOM() {
            this.style.position = "absolute";
            this.style.pointerEvents = "none";
            this.style.filter = "opacity(0)";
        }

        __showMainDOM() {
            this.style.position = "static";
            this.style.pointerEvents = "all";
            this.style.filter = "none";
        }

        // FIXME There may be a race condition if the user toggles the DOM attributes too fast.
        async __render() {
            const width = this.getAttribute("width") || "800";
            const height = this.getAttribute("height") || "600";

            // TODO use the innerWidth and innerHeight of the opened window for the render size.
            this.style.width = width + "px";
            this.style.height = height + "px";

            const open = this.hasAttribute("open") || false;
            if (!open) return;

            const screenX = this.getAttribute("screen-x") || "0";
            const screenY = this.getAttribute("screen-y") || "0";

            this.__win.resizeTo(width, height);
            this.__win.moveTo(screenX, screenY);

            if (this.__canvas) {
                await this.__drawCanvas();
            } else {
                if (!this.__canvasPromise) {
                    this.__canvasPromise = html2canvas(this);
                    this.__canvas = await this.__canvasPromise;
                    this.__win.document.body.append(this.__canvas);
                } else {
                    this.__canvas = await this.__canvasPromise;
                    await this.__drawCanvas();
                }
            }

            this.__hideMainDOM();
        }

        async __drawCanvas() {
            await html2canvas(this, {
                canvas: this.__canvas,
            });
        }
    }

    customElements.define("window-", HTMLWindowElement);
}

function identityTemplateTag(stringsParts, ...values) {
    let str = "";
    for (let i = 0; i < values.length; i++)
        str += stringsParts[i] + String(values[i]);
    return str + stringsParts[stringsParts.length - 1];
}
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.