Qiita同時スクロール

Qiitaの投稿画面のエディター(左)・プレビュー(右)の同時スクロールを改善し、各見出しを基準にスクロール位置を合わせるようにする。

// ==UserScript==
// @name         Qiita同時スクロール
// @description  Qiitaの投稿画面のエディター(左)・プレビュー(右)の同時スクロールを改善し、各見出しを基準にスクロール位置を合わせるようにする。
// @author       fukuchan
// @match        *://*.qiita.com/drafts/new
// @match        *://*.qiita.com/drafts/*/edit*
// @run-at       document-start
// @version 0.0.1.20201007134028
// @namespace https://greasyfork.org/users/432749
// ==/UserScript==

// あらかじめaddEventListenerを上書きして既存のscrollイベントが設定されるのを阻止する
Document.prototype._addEventListener = Document.prototype.addEventListener;
Document.prototype.addEventListener = function (type, listener, useCapture = false) {
    if (type === "scroll") {
        return;
    }
    this._addEventListener(type, listener, useCapture);
};

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector("textarea[placeholder='プログラミング知識をMarkdown記法で書いて共有しよう']");

    // エディターのスタイルを取得
    const style = getComputedStyle(editor);
    const lineHeight = style.lineHeight ? parseFloat(style.lineHeight) : 21;
    const padding = style.padding ? parseFloat(style.padding) : 10;

    // 見出し座標計算用のテキストエリアを作る
    const textarea = document.createElement("textarea");
    Array.from(style).forEach(key => textarea.style.setProperty(key, style.getPropertyValue(key), style.getPropertyPriority(key)));
    textarea.style.pointerEvents = "none";
    textarea.style.visibility = "hidden";
    textarea.style.position = "absolute";
    textarea.style.top = "0";
    textarea.style.left = "0";
    textarea.style.width = "100%";
    textarea.style.height = (padding * 2 + lineHeight) + "px";
    textarea.readOnly = true;
    editor.parentElement.style.position = "relative";
    editor.parentElement.append(textarea);

    // スクロールの下限をなくすように見せかける
    const handleWheel = e => {
        if (editor.scrollTop === editor.scrollHeight - editor.clientHeight) {
            // スクロール末尾でなおスクロールしようとしている場合
            const y = editor.style.paddingBottom ? parseFloat(editor.style.paddingBottom) : padding;
            const deltaY = e.deltaMode === WheelEvent.DOM_DELTA_PAGE ? editor.clientHeight :
                e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * lineHeight :
                    e.deltaY;
            const sum = y + deltaY;
            if (deltaY < 0 || sum < editor.clientHeight) {
                // padding-bottomを増やしてスクロールしているように見せかける
                editor.style.paddingBottom = sum + "px";
                editor.scrollTop = editor.scrollHeight - editor.clientHeight;
            }
        } else if (parseFloat(editor.style.paddingBottom) !== padding) {
            editor.style.paddingBottom = padding + "px";
        }
    };

    // エディタにpadding-bottomを設定しているのをごまかす
    const handleInput = () => {
        if (editor.style.paddingBottom) {
            const paddingBottom = parseFloat(editor.style.paddingBottom);
            // padding-bottomが設定されている場合
            if (paddingBottom > padding) {
                // 入力時にpadding-bottomを調整して、パディングの中に文字列が隠れるのを防止する
                const deltaY = editor.scrollTop - editor.scrollHeight + editor.clientHeight;
                const y = paddingBottom + deltaY > padding ? paddingBottom + deltaY : padding;
                editor.style.paddingBottom = y + "px";
            }
        }
        const viewer = document.querySelector(".it-MdContent").parentElement;
        if (viewer) {
            // 入力のたびにプレビューのスクロール位置が0にされるのを無理やり修正
            const disableScroll = () => {
                // スクロール位置を再計算し、一度再計算したらイベントリスナを削除する
                handleScroll();
                viewer.removeEventListener("scroll", disableScroll);
            };
            viewer.addEventListener("scroll", disableScroll);
        }
    };

    // 見出しに合わせてスクロールする
    const handleScroll = () => {
        const viewer = document.querySelector(".it-MdContent").parentElement;
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }

        // 座標が未設定ならなにもしない
        if (!editor.dataset.coordinates || !viewer.dataset.coordinates) {
            return;
        }

        // datasetから各見出しの座標を取得
        const x = JSON.parse(editor.dataset.coordinates);
        const y = JSON.parse(viewer.dataset.coordinates);

        // 線形補完でプレビューのスクロール位置を計算
        const i = x.reduce((a, b, j) => b <= editor.scrollTop ? j : a, 0);
        viewer.scrollTop = i === x.length - 1 ? y[y.length - 1] : (y[i + 1] - y[i]) / (x[i + 1] - x[i]) * (editor.scrollTop - x[i]) + y[i];
    };

    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = async () => {
        const viewer = document.querySelector(".it-MdContent").parentElement;
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }
        viewer.style.position = "relative";
        const target = viewer.children[0];

        // エディターにおける各見出し位置を求める
        const getXCoordinates = new Promise(resolve => {
            // 見出しで文章を分割
            const re = /(?=^(?:> ?)?#+)/gm;
            const paragraphs = editor.value.split(re);
            const xCoordinates = paragraphs.map((paragraph, i) => {
                // 計算用テキストエリアに入力、テキストエリアの高さから見出し位置を求める
                textarea.value = paragraphs.slice(0, i + 1).join("");
                return textarea.scrollHeight - padding * 2;
            });

            // スクロール先頭の座標を追加
            if (paragraphs[0].match(re)) {
                xCoordinates.unshift(0);
            }
            xCoordinates.unshift(0);

            resolve(xCoordinates);
        });

        // プレビューにおける各見出し位置を求める
        const getYCoordinates = new Promise(resolve => {
            // detailsを全て開き、画像は全て先行読み込みに設定
            target.querySelectorAll("details").forEach(node => (node.open = true));
            target.querySelectorAll("img").forEach(node => (node.loading = "eager"));

            // 画像の読み込みを待機
            const images = Array.from(target.querySelectorAll("img"));
            const intervalID = setInterval(() => {
                // naturalHeightが0より大きくなれば読み込み完了と推測
                if (images.every(image => image.naturalHeight > 0)) {
                    // ループを終了
                    clearInterval(intervalID);

                    // 見出し位置を求める
                    const headers = target.querySelectorAll("h1,h2,h3,h4,h5,h6");
                    const yCoordinates = Array.from(headers).map(header => header.offsetTop);

                    // スクロールの先頭と末尾の座標を追加
                    yCoordinates.unshift(0);
                    yCoordinates.push(viewer.scrollHeight);

                    resolve(yCoordinates);
                }
            }, 10);
        });

        // 座標をdata-coordinatesに設定
        editor.dataset.coordinates = JSON.stringify(await getXCoordinates);
        viewer.dataset.coordinates = JSON.stringify(await getYCoordinates);

        // プレビューの下に空白を追加
        target.style.marginBottom = viewer.clientHeight + "px";

        // スクロール位置を修正
        handleScroll();
    };

    // エディタに各イベントリスナを設定する
    editor.addEventListener("scroll", handleScroll);
    editor.addEventListener("wheel", handleWheel);
    editor.addEventListener("input", handleInput);

    // プレビューの変更・レイアウトの変更・ウィンドウのリサイズを監視
    new MutationObserver(handleMutation).observe(editor.parentElement.parentElement.nextElementSibling, {
        childList: true,
        subtree: true
    });
    window.addEventListener("resize", handleMutation);
});