Help us understand the problem. What is going on with this article?

Qiitaの投稿画面の同時スクロールを改善するユーザースクリプト

概要

  • Before
    image.png

  • After
    image.png

Qiitaの投稿画面のエディタ(左)・プレビュー(右)の同時スクロールを改善し、各見出しを基準にスクロール位置を合わせるようにするユーザースクリプトを書きました。
GreaseyForkで公開中です。

きっかけ

「だからなに」で学ぶ、Javaの初歩【初心者向け】

多少長い文章でも、おっぱいのことなら読んでくれるだろう。
そんな狂気の発想から先日こんな気合入った長ーい記事を書いたのですが、その際に大変困ったことがありました。

image.png

Qiitaの投稿は左のエディタにMarkdownで書くようになっており、そのMarkdownをHTMLに書きだした結果が右のプレビューでリアルタイムに確認できるようになっています。

このエディタとプレビューには同時スクロールが実装されていて、ウィンドウの縦幅に収まりきらないくらい長い記事を書いた時でもある程度ならエディタとプレビューで同じところが見れるようにもなっているのですが…

問題は「ある程度」以上に長い記事を書いた時です。

image.png

既存の同時スクロールは単にスクロールの割合を合わせるようになっていますから、エディタとプレビューではサイズが異なる見出しや画像などが原因となって、エディタで見ている文章の位置とプレビューで見ている文章の位置がだんだんずれていきます。

image.png

長い記事を書けば書くほどずれていくので、そのうちエディタとプレビューで全然違う場所が表示されるようになるのです。
こうなればエディタに書いてる内容のプレビューを確認するのにいちいち手動でスクロールしなければなりません。面倒です。

エディタとプレビューの同時スクロールを改善し、同じところを見れるようにできないでしょうか。しましょう。

手法

image.png

エディタのスクロールイベントに介入し、エディタの一番上で見ている行と、プレビューの一番上で見ている要素が一致するようにスクロール位置を合わせることにします。

image.png

そのためにはエディタのどの行がプレビューのどの要素に対応するかの関係を明らかにし、またそれぞれの座標(スクロールの一番上からの距離)も求めなければなりません。

image.png

しかし現実的に考えてエディタの1行1行をMarkdownとしてきっちり解析するとかやってられませんから、今回は見出し行だけを対象に対応関係と座標を求めることにします。

image.png

見出し行と見出し行の間の区間を見ているときには、単純に線形補間でスクロールの割合を合わせることにします。
この方法では間の区間でスクロールに多少のズレが生じることになりますが、よっぽど長い章など書いてなければ実用上は問題ない範囲のズレで収まるだろうと考えました。

また今回はエディタ・プレビューの一番上で見ている行を一致させようとしているので、当然ながら一番下で見ている行は一致しません。

image.png

ブラウザのスクロールは最終行が一番下に来るところまでしか行けませんから、これではエディタ側で一番下までスクロールしてもプレビュー側では一番下を見れないという問題が起きてしまいます。

image.png

そこでエディタ・プレビューのスクロール領域を拡張し、このように最終行が一番上に来るところまで行けるようにしましょう。こうすればプレビューでも最終行まで確実に見れます。

そういうわけで、実装の流れはおおまかには次のようになります。

  1. 既存のスクロール機能を削除する
  2. エディタ、プレビューそれぞれで見出し行の対応関係と座標を求める
  3. 見出しの座標に合わせてプレビューを同時スクロールする
  4. エディタ・プレビューのスクロール領域を拡張する

実装

1. 既存のスクロール機能を削除する

既存の同時スクロール機能を削除する

image.png

image.png

既存の同時スクロール機能は、Reactを通じてdocumentに登録されているscrollのEventListenerに実装されているようです。
これを削除します。

ただ一つ問題があって…

JavaScriptでEventListenerの削除を行うメソッドはremoveEventListener()。このメソッドは引数に「削除したいEventListener」をとります。

しかし肝心のそのEventListenerを取得する方法がありません。引数を渡せなければ削除のしようもないのでこの方法は使えません。

早くも普通の方法が採れないことが判明して先が思いやられますが、ここはEventListenerが削除できないのならEventListenerが設定されること自体を阻止すれば良いじゃないという強引な発想で乗り切ることにします。

つまり、

  1. ユーザースクリプトの@run-atメタデータにdocument-startと書いて「このスクリプトは何よりも早く実行してね」と指定した上で、
  2. EventListenerの追加を行うメソッドaddEventListener()を上書きしてscrollのEventListenerの設定を阻止してしまいます。
// ==UserScript==
// @name         Qiita同時スクロール
// @version      0.1
// @description  Qiitaの投稿画面のエディター(左)・プレビュー(右)の同時スクロールを改善し、各見出しを基準にスクロール位置を合わせるようにする。
// @author       fukuchan
// @match        *://*.qiita.com/drafts/new
// @match        *://*.qiita.com/drafts/*/edit*
// @run-at       document-start
// ==/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);
};

「「だからなに」で学ぶ、Javaの初歩【初心者向け」を編集 - Qiita - Mozilla Firefox 2020-03-02 10-49-44.gif

これで既存の同時スクロール機能を削除?することができました。

既存のスクロール位置リセット機能を削除する

「「だからなに」で学ぶ、Javaの初歩【初心者向け」を編集 - Qiita - Mozilla Firefox 2020-03-02 10-53-34.gif

既存の同時スクロール機能を削除すると、エディタ側に何か入力するたびにプレビューのスクロール位置が一番上にリセットされてしまうようです。

image.png

image.png

このスクロール位置リセット機能は、Reactを通じてtextarea.editorMarkdown_textarea(エディタの要素)に登録されているinputのEventListenerに実装されているようです。

この機能も先ほどと同じように消してしまいたいわけですが、単にそうしてしまうとエディタに何か入力したときにプレビューが更新される処理まで止まってしまいます。

それも困るので今度は、スクロール位置がリセットされる傍からスクロール位置を元に戻すという方法で乗り切りましょう。

  1. プレビューのスクロール位置を即座に再計算し、計算後には自身を削除するようなscrollEventListenerを作る
  2. エディタに何か入力される度にプレビューに↑を登録するinputEventListenerを作る
// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    const handleInput = () => {
        const viewer = document.querySelector(".editorPreview_article");
        if (viewer) {
            // 入力のたびにプレビューのスクロール位置が0にされるのを無理やり修正
            const disableScroll = () => {
                // スクロール位置を再計算し、一度再計算したらイベントリスナを削除する
                handleScroll();
                viewer.removeEventListener("scroll", disableScroll);
            };
            viewer.addEventListener("scroll", disableScroll);
        }
    };

    // 見出しに合わせてスクロールする
    const handleScroll = () => {
    };

    editor.addEventListener("input", handleInput);
    editor.addEventListener("scroll", handleScroll);
});

ごり押しですが、これで既存のスクロール位置リセット機能も削除??することができました。

2. エディタ、プレビューそれぞれで見出し行の対応関係と座標を求める

見出し行の対応関係と座標を求める処理を実装します。

この処理はプレビューが更新されたときに行いたいので、まずはMutationObserverでプレビューの更新を監視することにしました。

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = () => {
    };

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

都合よくこんなAPIがあって助かりましたが一般的にはどういう用途に使われるものなんでしょうか…

まあとにかくこれでプレビューが更新されたときに何らかの処理を走らせる実装はできました。次は実際に見出し行の対応関係・それぞれの座標を求める処理を書いていきます。

見出し行の対応関係を求める

エディタでもプレビューでも見出し行の数と出てくる順番は必ず同じになりますから、今回は単純にエディタ・プレビューそれぞれで見出し行を取得して、対応関係はそのインデックスから求めることにします。

エディタで見出し行を取得する

image.png

エディタに入っているMarkdownのテキストから正規表現で見出し行を取得し、後の座標計算のためにテキストを「見出し行から次の見出し行の前の行」という単位で分割します。

これを行うための正規表現は(?=^(?:> ?)?#+)です。

この正規表現は# 見出しのようにして書く普通の見出しに加えて、> # 見出しのようにして書く引用文中の見出し文にも対応でき、また肯定的先読みによってsplit()メソッドを使った分割の際に見出し文自体も分割後の要素に含めることができます。

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

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = () => {
        const paragraphs = editor.value.split(/(?=^(?:> ?)?#+)/gm);
    };

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

プレビューで見出し行を取得する

プレビューのHTMLからCSSセレクタで見出し行を取得します。こっちは分割とかそういうのは行いません。

見出し行を取得するCSSセレクタはh1,h2,h3,h4,h5,h6です。

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = () => {
        const headers = target.querySelectorAll("h1,h2,h3,h4,h5,h6");
    };

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

見出し行の座標を求める

同時スクロールの基準にする各見出しの座標を、エディタ・プレビューそれぞれで求めます。

エディタで見出し行の座標を求める

エディタに入っているのはただのMarkdown記法のテキストです。
JavaScriptにテキスト中のある行の座標を求めてくれる機能なんて当然ないので、またしても何らかの無茶をして計算する必要があります。

Canvasを使って計算する(断念)

本筋と関係ないので折り畳み

結局断念したのですが、最初に試したのはCanvasを使った方法でした。

ただ折り返しのことまで考えた行数の計算というのは極めて難解です。

最初はテキストを\n(改行)ごとに分割し、CanvasRenderingContext2D.measureText()メソッドを使って各行のテキストの横幅を計算、この横幅をエディタの横幅で割ることで折り返しも含めた行数を求めようと考えました。

しかしまず問題となったのが、\t(タブ)の長さというのは場合によって変わるという問題です。

  • エディタにおけるタブの長さは半角スペース8つ分だが、Canvas要素におけるタブの長さは半角スペース1つ分
  • 文章の途中にタブがある場合、タブの長さは短くなることがある

そこでタブは半角スペース8つ分の横幅として計算し、またその長さは必要に応じて短くする処理を実装しました。

しかしここでさらに問題となったのが、折り返しのタイミングの問題です。

  • テキストがエディタの横幅を溢れる前に折り返されるので、1行に入るテキストの横幅は実際にはエディタの横幅より狭い。
    • なので単なる割り算で行数を求めるのは無理
  • 折り返しのタイミングは必ずしも横幅を溢れる直前ではない。特に英語の場合、CSSの設定によっては単語区切りで折り返されることがある。
    • でも必ず単語区切りで折り返されるわけでもない。

これもまあ根性で、単なる割り算で計算するのは諦めて1文字1文字の横幅を計算することで折り返し位置をなんとか求めようと頑張りまして、単語区切りについて以外は実装することができました。

  1. エディタのテキストを\nで分割
  2. 分割された各行について、1文字ずつ文字の横幅を計算する。横幅の合計がエディタの横幅を越えたら折り返しと判定することで折り返しの行数を求める。
  3. 合計して折り返しも含めた行数を求める。
  4. 見出し行の前の行までの行数にlineHeightをかけることで、見出し行の座標が求められる。
// ロード時に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

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

    // テキストの横幅計算用のCanvas
    const ctx = (() => {
        const fontStyle = style.fontStyle ? style.fontStyle : "normal";
        const fontSize = style.fontSize ? style.fontSize : "14px";
        const fontFamily = style.fontFamily ? style.fontFamily : "Consolas, Liberation Mono, Menlo, Courier, monospace";
        const canvas = document.createElement("canvas");
        const ctx = canvas.getContext("2d");
        ctx.font = fontStyle + " " + fontSize + " " + fontFamily;
        return ctx;
    })();
    const tabWidth = ctx.measureText(" ".repeat(style.tabSize ? parseInt(style.tabSize) : 8));

    // 見出しの座標を計算する
    const mutationEvent = async () => {
        // プレビューを取得
        const viewer = document.querySelector(".editorPreview_article");
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }

        // エディターにおける各見出し位置を求める
        const getXCoordinates = new Promise(resolve => {
            const xCoordinates = [];
            const editorWidth = editor.clientWidth - paddingLeft - paddingRight;
            let sum = 0;
            editor.value.split("\n").forEach(line => {
                if (line.match(/([^\\]|^)#+/)) {
                    // 見出し行なら行数から座標を計算して追加する
                    xCoordinates.push(sum * lineHeight);
                }

                // 折り返しを含めた行数を計算
                let lineWidth = 0, linesCount = 1;
                const chars = Array.from(line);
                chars.forEach(char => {
                    // 文字幅を計算
                    const charWidth = (() => {
                        if (char === "\t") {
                            // タブの場合
                            const surplus = lineWidth % tabWidth;
                            return surplus === 0 ? tabWidth : tabWidth - surplus;
                        }
                        return ctx.measureText(char).width;
                    })();

                    // 改行の場合行幅をリセット
                    if (editorWidth <= lineWidth + charWidth) {
                        lineWidth = 0;
                        linesCount++;
                    }
                    // 行幅に文字幅を加算
                    lineWidth += charWidth;
                });
                sum += linesCount;
            });
            xCoordinates.unshift(0);
            xCoordinates.push(editor.scrollHeight);
            resolve(xCoordinates);
        });

        editor.dataset.coordinates = JSON.stringify(await getXCoordinates);
    };
    // プレビューの変更・レイアウトの変更・ウィンドウのリサイズを監視
    new MutationObserver(mutationEvent).observe(document.querySelector(".editorPreview"), {
        childList: true,
        subtree: true
    });
    window.addEventListener("resize", mutationEvent);
});

しかしこの方法は無茶というか…今まで上に書いてきた内容も大概無茶だったとはいえ、この実装はさすがに綱渡りすぎると私もこの辺で思い始めました。
そこで残念ながらここで断念して、他の方法を考えることにしたのです。

image.png

image.png

↑この方法で計算した場合の同時スクロール

結構いい線行ってはいたんですけどね。

textarea要素を使って計算する

次に考えたのは計算用のtextarea要素を使った方法でした。

この方法ではエディタとほとんど同じスタイルを適用した計算用のtextarea要素を作り、そのtextareaに「1行目から座標を求めたい見出し行の1つ前の行まで」のテキストを実際に入力します。

image.png

こうすることで、textareaのscrollHeightは「1行目から座標を求めたい見出し行の1つ前の行まで」のテキストの高さ=見出し行の座標となるのです。

  1. 計算用のtextarea要素を作る。
  2. ↑にエディタとほとんど同じスタイルを適用する(但し計算用なので見えないように)。
  3. 前に正規表現で分割したテキストを順次結合しながら計算用textareaに入力する。
  4. textareaのscrollHeightから各見出しの座標を求める。
// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    // エディターのスタイルを取得
    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;
    document.querySelector(".editorMarkdown_textareaWrapper").append(textarea);

    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = async () => {
        const viewer = document.querySelector(".editorPreview_article");
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }

        // エディターにおける各見出し位置を求める
        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);
        });

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

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

計算用テキストエリアの高さをパディング幅+行の高さで計算していますが、これは「できるだけ小さい高さで、scrollHeightの計算がおかしくならないギリギリのところ」を狙った結果です。計算方法に根拠はありません。

そんな調子ですからこの方法も正直どうかとは思いましたが、Canvasを使った方法よりかはシンプルに実装できました。

プレビューで見出し行の座標を求める

エディタ側はテキストなので大変でしたが、プレビュー側はHTMLなので見出し行の座標を求めるのも簡単です。

JavaScriptにはHTML要素の座標を取得する機能があります。具体的にはoffsetTopで親要素の一番上からのピクセル数が取得できるので、これを使って各見出しの座標を求めることができます。

但しChromeの場合は画像の遅延読み込みが有効になっており、画面上で見える領域にない画像は読み込まれません。
読み込まれてない画像は高さが0なので見出しの座標がずれる原因となります。遅延読み込みは無効にし、画像の読み込みを待機してから座標の計算を行うようにしましょう。

image.png

またQiitaの記事ではdetails要素を使った文章の折り畳みができますが、これはプレビューで見ている間は常に開いていたほうが良いように思います。
見出しの座標も開いている状態を基準に計算したいので、details要素は全て開いてしまうことにしましょう。

  1. 画像の遅延読み込みを無効にする
  2. details要素をすべて開く
  3. 各見出し要素のoffsetTopから各見出しの座標を求める
// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    // エディターのスタイルを取得
    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;
    document.querySelector(".editorMarkdown_textareaWrapper").append(textarea);

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

        // プレビューにおける各見出し位置を求める
        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に設定
        viewer.dataset.coordinates = JSON.stringify(await getYCoordinates);
    };

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

3. 見出しの座標に合わせてプレビューを同時スクロールする

各見出しの座標を求めるのは大変でしたが、座標さえわかってしまえばスクロール位置は簡単に線形補間で計算できます。

y = y_0 + (y_1 - y_0) \frac{x - x_0}{x_1 - x_0}
x … 現在のエディタのスクロール座標
y … 現在のプレビューのスクロール座標
x_0 … 現在のエディタのスクロール座標から1つ上の見出しの座標
x_1 … 現在のエディタのスクロール座標から1つ下の見出しの座標
y_0 … x_0の見出しに対応するプレビューの見出しの座標
y_1 … x_1の見出しに対応するプレビューの見出しの座標

エディタのscrollイベントに、↑のような計算を行ってプレビューのスクロール座標を求め代入するEventListenerを登録します。

スクロール位置はscrollTopの値を直接いじることで取得したり設定したりすることができます。

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

    // 見出しに合わせてスクロールする
    const handleScroll = () => {
        const viewer = document.querySelector(".editorPreview_article");
        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];
    };

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

「「だからなに」で学ぶ、Javaの初歩【初心者向け」を編集 - Qiita - Mozilla Firefox 2020-03-02 11-10-37.gif

これで同時スクロールが動きました!なかなか様になってきた感じです。

4. エディタ・プレビューのスクロール領域を拡張する

image.png

最後の仕上げです。エディタ・プレビューのスクロール領域を拡張し、上の画像のような位置までスクロールできるようにします。

エディタのスクロール領域を拡張する

当然ながらtextareaに「スクロール領域を拡張する」なんて機能は無いので、また曲芸の時間です。

今度はスクロール操作に合わせてエディタのpadding-bottomを増やすことでスクロール領域が増えたように見せかけるという発想で行きました。

エディタ上でマウスホイールを回すとwheelイベントが発生しますから、それに合わせて「エディタのスクロール位置は既に末尾にあるのにまだスクロールしようとしている」かどうかを判定し、そのようならスクロールしようとしている分だけエディタのpadding-bottomを増やします。
こうすることであたかもスクロール領域が増えたように見せかけることができます。

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // エディターを取得
    const editor = document.querySelector(".editorMarkdown_textarea");

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

    // スクロールの下限をなくすように見せかける
    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";
            }
        }
    };

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

「「だからなに」で学ぶ、Javaの初歩【初心者向け」を編集 - Qiita - Mozilla Firefox 2020-03-02 11-14-16.gif

通常のスクロールのアニメーションが働かないので動きがぎこちないですが、これでエディタのスクロール領域を拡張???することができました。

なお今回はwheelイベントのことしか見てませんから、[↓]キーや[PageDown]キーを用いたスクロールや、スクロールバーを直接操作してのスクロールには対応できません。

プレビューのスクロール領域を拡張する

こっちは簡単です。単にプレビューの中身の長さを広げてしまいましょう。

div.it-MdContent(プレビューの子要素)にmargin-bottomを設定してプレビューの中身の長さを下に広げます。
margin-bottomの値はプレビューの高さと同じにします。

// ロード後に実行
window.addEventListener("DOMContentLoaded", () => {
    // プレビューの変更時に見出しの座標を計算する
    const handleMutation = async () => {
        const viewer = document.querySelector(".editorPreview_article");
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }
        const target = viewer.children[0];

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

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

最終的に出来上がったコード

GitHubGreaseyForkで公開中です。

// ==UserScript==
// @name         Qiita同時スクロール
// @version      0.1
// @description  Qiitaの投稿画面のエディター(左)・プレビュー(右)の同時スクロールを改善し、各見出しを基準にスクロール位置を合わせるようにする。
// @author       fukuchan
// @match        *://*.qiita.com/drafts/new
// @match        *://*.qiita.com/drafts/*/edit*
// @run-at       document-start
// ==/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(".editorMarkdown_textarea");

    // エディターのスタイルを取得
    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;
    document.querySelector(".editorMarkdown_textareaWrapper").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(".editorPreview_article");
        if (viewer) {
            // 入力のたびにプレビューのスクロール位置が0にされるのを無理やり修正
            const disableScroll = () => {
                // スクロール位置を再計算し、一度再計算したらイベントリスナを削除する
                handleScroll();
                viewer.removeEventListener("scroll", disableScroll);
            };
            viewer.addEventListener("scroll", disableScroll);
        }
    };

    // 見出しに合わせてスクロールする
    const handleScroll = () => {
        const viewer = document.querySelector(".editorPreview_article");
        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(".editorPreview_article");
        if (!viewer) {
            // プレビュー非表示モードなら何もしない
            return;
        }
        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(document.querySelector(".editorPreview"), {
        childList: true,
        subtree: true
    });
    window.addEventListener("resize", handleMutation);
});

あとがき

件の長い記事を書いてたときの思いつきで作ったのですが、まあ数々のアクロバティックな実装の繰り返しとなってしまって果たしてQiitaの同時スクロールごときのためにここまでやる必要はあるのだろうかという感じになってしまいました。

でもまあ無いよりはあったほうが良い機能だと思うんですがどうでしょう()

しかしできればこんな綱渡りのユーザースクリプトに頼らずとも、Qiita公式でこういう同時スクロール機能を実装してほしいなとも思う次第です。

参考

m_fukuchan
17歳JKの大学院生。 Twitter:@m_fukuchan
http://fukuo.jugem.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした