うしブログ

うしブログ

趣味で運営する、GeoGebraの専門ブログ。

(作業メモ)StartPoint要検証(2行の場合;テキスト変更時未定義問題)

(要修復)ToggleButton・RollPolygonWithoutSlipping・貯金時計・直感力トレーニング

グローバルJavaScript入力欄をAceエディタ化 [geoEditor-1-1]

動作を確認した環境

GeoGebra:web版GeoGebra クラシック6 Version: 6.0.659.0-w (10 August 2021)

ブラウザ:chrome バージョン: 92.0.4515.131(Official Build) (x86_64)

OS:MacOs 10.14.1(18B2107)

※その他の環境での動作確認はしていません。環境によっては、正常に動作しない可能性がありますので、あらかじめご了承ください。

できること

web版GeoGebraの、グローバルJavaScript記述欄を、綺麗なエディターにする。

色分け、自動補完、候補ポップアップのほか、ダブルクリックで自動整形が可能。

(導入前)

f:id:usiblog:20210822202540p:plain

(導入後)

f:id:usiblog:20210822202608p:plain

※デフォルトで実装して欲しいですね。今後のアップデートに期待します。

導入方法

グローバルJavaScript記述欄に、本記事末尾のスクリプトをコピペして、アプレットを保存する。

f:id:usiblog:20210822201741g:plain

すでにグローバルJavaScript記述欄にスクリプトを記述している場合は、それを、本記事末尾のスクリプトの最後「// userScript_below」の2行下の「function ggbOnInit() { }」に上書きしてコピペしてください。その際、コメント「// userScript_below」の下は、必ず1行あけてください。

f:id:usiblog:20210823112610p:plain

ブラウザを更新することで、アプレットを再読み込みすれば、グローバルJavaScript記述欄がJSエディタ化する。

記述後は、上部の「//クリックして確定//」というボタンを押せば、記述内容を確定できる。その後、アプレットを再読み込みすれば、記述したスクリプトが使えるようになる。

f:id:usiblog:20210822201847g:plain

デフォルトでは、エラー行番号とエディタ上の行番号が一致しない。一致させたい場合は、下記スクリプト中「// firstLineNumber: 289」をコメントインされたい。なお、下記スクリプトに変更を加えた場合は、「289」の部分を、スクリプトの全行数に揃えて頂きたい。

無効化の方法

①エディタの任意の箇所に「// disable_usiusi」とコメントする(カギカッコ不要) 。

f:id:usiblog:20210823111427p:plain

②エディタ上部の確定ボタンを押し、アプレットを保存する。

アプレットを再読込して、グローバルJS入力欄が元に戻っていることを確認する。

④グローバルJS入力欄の1行目から、コメント「// userScript_below」までを削除し、アプレットを保存する。

ステップ④において、グローバルJS入力欄のコメント「// userScript_below」より下は、これまでエディタに記述したスクリプトです。残す場合は削除しないよう、ご注意ください。

再導入したい場合

上記ステップ③まで実行した段階で、エディタを再導入したい場合は、グローバルJS入力欄のコメント「// disable_usiusi」を削除して、アプレットを再読み込みしてください。

ステップ④まで実行した段階で、エディタを再導入したい場合は、本記事上述「導入方法」に従って再導入してください。

スクリプト

// クリックして確定  //




// -------------------------ウインドウクリック時に1回だけ実行-----------------------------------------------------------------------
window.addEventListener("pointerdown", usiusi_usiOnInit);  // ウインドウクリック時
function usiusi_usiOnInit() {
    if (location.href.indexOf("https://www.geogebra.org/classic/") >= 0) {  // web版GeoGebra Classicにのみ対応
        usiusi_insertJS();  // aceエディタ動作に必要なJavaScriptをインポート
        usiusi_mutationFunc();  // (GeoGebraが提供する既存の)グローバルJS入力欄がdefineされるのを監視し、defineされたらaceエディタを仕込む
    }
    window.removeEventListener("pointerdown", usiusi_usiOnInit);
}
// ---------------------------------------------------------------------------------------------------------------------------


// ------------------------必要なJSを読み込み-----------------------------------------------------------
function usiusi_insertJS() {
    const scriptUrl1 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js";
    const scriptUrl2 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ext-language_tools.min.js";
    const scriptUrl3 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/theme-sqlserver.min.js";
    // const scriptUrl3 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/theme-monokai.min.js";
    const scriptUrl4 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/mode-javascript.min.js";
    const scriptUrl5 = "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ext-beautify.min.js";

    var el1 = document.createElement('script');
    el1.src = scriptUrl1;
    document.head.appendChild(el1);

    var el2 = document.createElement('script');
    el2.src = scriptUrl2;
    document.head.appendChild(el2);

    var el3 = document.createElement('script');
    el3.src = scriptUrl3;
    document.head.appendChild(el3);

    var el4 = document.createElement('script');
    el4.src = scriptUrl4;
    document.head.appendChild(el4);

    var el5 = document.createElement('script');
    el5.src = scriptUrl5;
    document.body.appendChild(el5);
}
// -------------------------------------------------------------------------------------------------


// ------------------------グローバルJSテキストエリアの真上にdivを挿入----------------------
function usiusi_insertDivToGlobalJsDiv() {
    var globalJsArea = document.getElementsByClassName("scriptArea")[2];
    var globalJsDiv = globalJsArea.parentElement;
    // var rect = globalJsArea.getBoundingClientRect()  // 絶対位置が知りたいときは使え
    var newDiv = document.createElement("div");
    // newDiv.style.backgroundColor = "red";
    globalJsDiv.appendChild(newDiv);
    newDiv.id = "editor";
    newDiv.className = "noGGB";
    // globalJsDiv.appendChild(globalJsArea);  // 順番入れ替え
    newDiv.style.width = "100%";
    newDiv.style.height = "1000px";
    newDiv.style.resize = "none";
    newDiv.style.overflow = "scroll";
}
// -----------------------------------------------------------------------------------


// -------body直下にdivを挿入、絶対位置でグローバルJSテキストエリアの真上に置く-----
// function insertDivToBody() {
//     var newDiv = document.createElement("div");
//     // newDiv.style.backgroundColor = "red";
//     document.body.appendChild(newDiv);
//     newDiv.id = "editor";
//     newDiv.style.position = "absolute";
//     newDiv.style.width = "200px";
//     newDiv.style.height = "200px";
//     newDiv.style.top = "0px";
//     newDiv.style.left = "0px";
//     newDiv.style.resize = "both";
//     newDiv.style.overflow = "scroll";
//     newDiv.addEventListener("mouseover", usiusi_makeEditor);
// }
// -----------------------------------------------------------------------


// ------------------------挿入したdivをaceエディタにする---------------------
function usiusi_makeEditor() {
    var editor = ace.edit("editor");
    // editor.setTheme("ace/theme/monokai");
    editor.setTheme("ace/theme/sqlserver");
    editor.getSession().setMode("ace/mode/javascript");
    editor.setFontSize(16);
    editor.setOptions({
        // firstLineNumber: 289,  // エラー行番号とのズレを解消したいときはコメントイン(数値は、本コードの最終行番号に揃えること)
        enableBasicAutocompletion: true,
        enableSnippets: true,
        enableLiveAutocompletion: true,
        showFoldWidgets: false
    });
}
// -----------------------------------------------------------------------


// ------------------------GeoGebra側指定のCSS適用を回避-------------------------------------------
// これをしないと、エディタの入力欄が真っ白になり、入力内容が見えなかった。
// エディタ関連要素に、クラス「noGGB」を設定し、GeoGebraFrameクラスの全要素対象のCSS「.GeoGebraFrame *{...}」に、否定疑似クラス「:not(.noGGB)」を追加する。
// なお、spanに適用されるCSSは、GeoGebra側指定のままである。当該CSSが指定するフォントサイズと、エディタのフォントサイズ(関数「usiusi_makeEditor()」内「editor.setFontSize(16);」)が合わないと、カーソルがずれる。
function usiusi_addClass() {
    var linkedCss = document.querySelector("#simple-bundle");  // linkタグ[#simple-bundle]を探す

    if (linkedCss) {  // 例外追加したいCSSが、linkタグ[#simple-bundle]の外部リンクとして導入されている場合
        var cssUrl = linkedCss.href;  // 外部リンクのURLを取得
        // var cssUrl = "https://www.geogebra.org/apps/latest/css/bundles/simple-bundle.css";

        linkedCss.remove();  // 外部リンクCSS削除

        var newCss = document.createElement('style');  // 差し替え用のCSS
        newCss.className = "ggw_resource";

        // URLからCSSのテキストデータを取得し、セレクタ「.GeoGebraFrame *{...}」を、否定疑似クラス付きセレクタ「.GeoGebraFrame *:not(.noGGB){...}」に変更し、差し替え用のCSSの中身として利用する。
        fetch(cssUrl) // (1) リクエスト送信
            .then(response => response.text()) // (2) レスポンスデータを取得
            .then(data => { // (3)レスポンスデータを処理
                newCss.innerText = data.replace("*", "*:not(.noGGB)");
            });

        document.head.appendChild(newCss);  //headに差し替え用CSSをインポート

        var bundleCss = document.querySelector("#bundle");  // もともとのlinkedCssに優先して適用されていたCSS。先ほどnewCssをインポートしたことで、bundleCssの適用順は、newCssに劣後している。そこで、bundleCssを一旦削除→再インポートすることで、newCssに優先して適用させる。
        bundleCss.remove();
        document.head.appendChild(bundleCss);  // bundleCss優先適用
        console.log("[#simple-bundle] exists. so remove it and inported CSS with :not(.noGGB).");

    } else {  // 例外追加したいCSSが、外部リンクではなくstyleタグに直接記述してある場合
        document.querySelector("head > style.ggw_resource").textContent = document.querySelector("head > style.ggw_resource").textContent.replace("*", "*:not(.noGGB)");  // セレクタ「.GeoGebraFrame *{...}」を、否定疑似クラス付きセレクタ「.GeoGebraFrame *:not(.noGGB){...}」に変更する
        console.log("[#simple-bundle] undefined. so edited [head > style.ggw_resource].");
    }
    
    var targets = document.getElementById("editor").querySelectorAll("div, span");
    [...targets].forEach(v =>
        // console.log(v.className)
        v.className = "noGGB " + v.className
    );

    document.getElementById("editor").addEventListener("mousemove", function () {
        // クラス設定
        var targets = document.getElementById("editor").querySelectorAll("div, span");
        [...targets].forEach(v => {
            if (v.className.indexOf("noGGB") < 0) {
                v.className = "noGGB " + v.className;
            }
        });
    });

    // 行を加除した際のDOM変更に対応。その都度クラス「NoGGB」を設定する。
    // 監視対象ノード
    const mutationTarget = document.getElementById("editor");
    // インスタンス
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            // console.log(n);
            // 警告アイコンがちゃんと表示されるように、レイアウト調整
            var target = document.getElementById("editor");
            target.getElementsByClassName("ace_scroller")[0].style.left = "50px";
            target.getElementsByClassName("ace_gutter")[0].style.width = "50px";
            target.getElementsByClassName("ace_gutter-layer")[0].style.width = "50px";
            // クラス設定
            var targets = document.getElementById("editor").querySelectorAll("div, span");
            [...targets].forEach(v => {
                if (v.className.indexOf("noGGB") < 0) {
                    v.className = "noGGB " + v.className;
                }
            });
        });
    });
    // オブザーバ
    const config = {
        childList: true,
        subtree: true,
        attributes: true
    };
    observer.observe(mutationTarget, config);

}
// ---------------------------------------------------------------------------------------------


// -----------------------もともとのグローバルJSテキストエリアと、新たに作ったエディタとの間の、データやりとりを設定------------------------------
function usiusi_attach() {
    var globalJsArea = document.getElementsByClassName("scriptArea")[2];  // もともとのテキストエリア
    var editor = ace.edit("editor");  // エディタ

    // アプレット読み込み時:ユーザーが入力したスクリプトをエディタに表示
    var userStartingMark = "// userScript" + "_below";  // 本コード末尾の、ユーザー入力スクリプトの開始文句
    var userInputScript = globalJsArea.value.substring(globalJsArea.value.indexOf(userStartingMark) + userStartingMark.length + 2);  // ユーザー入力スクリプトの開始文句以下に記載された、ユーザー入力スクリプト
    editor.setValue(userInputScript);  // アプレット読み込み時のエディタ内容をセット。ユーザー入力スクリプトのみを表示するようにしている。

    // エディタ入力時:エディタへの入力内容(ユーザー入力スクリプト)を、もともとのテキストエリアに反映
    var endingMark = "// usiusi" + "_end";  // 本コード末尾の、うし記述スクリプトの終わり文句
    var usiusiScript = globalJsArea.value.substring(0, globalJsArea.value.indexOf(endingMark) + endingMark.length); // 本コード1行目~終わり文句(すなわち、うし記述スクリプト部分)
    var editorTextArea = document.getElementsByClassName("ace_text-input")[0];  // キーイベント登録対象要素
    editorTextArea.addEventListener("keyup", function (event) {
        var editorText = editor.getValue();  // 現在の、エディタの入力データ(ユーザー入力スクリプト)
        globalJsArea.value = usiusiScript + "\n\n" + userStartingMark + "\n\n" + editorText;
    });

    //エディタダブルクリック時:コードを整形(拡張機能「beautify」を使用)
    document.getElementById("editor").addEventListener("dblclick", function (event) {
        var beautify = ace.require("ace/ext/beautify");
        beautify.beautify(editor.session);  // コードを整形
        var editorText = editor.getValue();  // 現在の、エディタの入力データ(ユーザー入力スクリプト)
        globalJsArea.value = usiusiScript + "\n\n" + userStartingMark + "\n\n" + editorText;
    });
}
// -------------------------------------------------------------------------------------------------------------------------------


// -----------------------グローバルJSテキストエリアを、実行ボタンに仕立てる------------------------------
function usiusi_makeGlobalJsAreaExecuteButton() {
    var globalJsArea = document.getElementsByClassName("scriptArea")[2];
    globalJsArea.readOnly = true;
    globalJsArea.style.resize = "none";
    globalJsArea.style.width = "100%";
    globalJsArea.style.overflow = "hidden";
    globalJsArea.style.height = "25px";
    globalJsArea.style.backgroundColor = "#afeeee";  // ペールターコイズ
}
// ---------------------------------------------------------------------------------------------


// ------------------------位置を同期-------------------------------------------------------------
// function usiusi_syncPosition() {
//     var globalJsArea = document.getElementsByClassName("scriptArea")[2];
//     if (!globalJsArea) { return; }
//     var rect = globalJsArea.getBoundingClientRect();
//     var newDiv = document.getElementById("editor");
//     newDiv.style.width = rect.width + "px";
//     newDiv.style.height = rect.height + "px";
//     newDiv.style.top = rect.top + "px";
//     newDiv.style.left = rect.left + "px";
// }
// ---------------------------------------------------------------------------------------------


// ----------------------グローバルJS入力欄がdefineされた際に、エディタを仕込む(あるいはエディタを無効化する)-----------------------------
function usiusi_mutationFunc() {
    // 監視対象ノード
    const target = document.body;

    // インスタンス
    const observer = new MutationObserver((mutations) => {
        var globalJsArea = document.getElementsByClassName("scriptArea")[2];  // (既存の)グローバルJS入力欄
        // console.log("searching...");
        if (globalJsArea) {  // グローバルJS入力欄がdefineされたら実行
            observer.disconnect();  // 監視を終了する

            var userStartingMark = "// userScript" + "_below";  // 本コード末尾の、ユーザー入力欄の開始文句
            var userInputScript = globalJsArea.value.substring(globalJsArea.value.indexOf(userStartingMark) + userStartingMark.length + 2);  // ユーザー入力スクリプトの開始文句以下に記載された、ユーザー入力スクリプト
            var disableCommand = "// disable" + "_usiusi";  // エディタ無効化文句(エディタにこれをコメントすると、次回読み込み時以降はエディタが仕込まれない)

            if (userInputScript.indexOf(disableCommand) >= 0) {
                console.log("エディタが正常に無効化されました。\n引き続き、以下の手順を実行してください。\n[手順]グローバルJS入力欄の1行目から、コメント「" + userStartingMark + "」までを削除し、アプレットを保存する。[手順おわり]\nなお、コメント「" + userStartingMark + "」より下は、これまでエディタに記述されていたスクリプトです。残す場合は削除しないよう、ご注意ください。\nエディタを再度有効化したいときは、上記の[手順]を行わずに、以下のステップに従って操作してください。\n①グローバルJS入力欄のコメント「" + disableCommand + "」を削除する 。\n②アプレットを保存する。\n③アプレットを再読込して、エディタが復活していることを確認する。");
            } else {
                usiusi_insertDivToGlobalJsDiv();
                usiusi_makeEditor();
                usiusi_addClass();
                usiusi_attach();
                usiusi_makeGlobalJsAreaExecuteButton();
                console.log("グローバルJavaScript入力欄に、エディタを実装しました。\nエディタを無効化したいときは、以下の手順に従って操作してください。\n①エディタの任意の箇所に「" + disableCommand + "」とコメントする(カギカッコ不要) 。\n②エディタ上部の確定ボタンを押し、アプレットを保存する。\n③アプレットを再読込して、グローバルJS入力欄が元に戻っていることを確認する。\n④グローバルJS入力欄の1行目から、コメント「" + userStartingMark + "」までを削除し、アプレットを保存する。\nなお、ステップ④において、グローバルJS入力欄のコメント「" + userStartingMark + "」より下は、これまでエディタに記述したスクリプトです。残す場合は削除しないよう、ご注意ください。");
            }
        }
    });

    // オブザーバ
    const config = {
        childList: true,
        subtree: true
    };

    observer.observe(target, config);
}
// --------------------------------------------------------------------------------------------------------------------------

// usiusi_end

// userScript_below

function ggbOnInit() { }