グローバル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記述欄を、綺麗なエディターにする。
色分け、自動補完、候補ポップアップのほか、ダブルクリックで自動整形が可能。
※デフォルトで実装して欲しいですね。今後のアップデートに期待します。
導入方法
グローバルJavaScript記述欄に、本記事末尾のスクリプトをコピペして、アプレットを保存する。
すでにグローバルJavaScript記述欄にスクリプトを記述している場合は、それを、本記事末尾のスクリプトの最後「// userScript_below」の2行下の「function ggbOnInit() { }」に上書きしてコピペしてください。その際、コメント「// userScript_below」の下は、必ず1行あけてください。
ブラウザを更新することで、アプレットを再読み込みすれば、グローバルJavaScript記述欄がJSエディタ化する。
記述後は、上部の「//クリックして確定//」というボタンを押せば、記述内容を確定できる。その後、アプレットを再読み込みすれば、記述したスクリプトが使えるようになる。
デフォルトでは、エラー行番号とエディタ上の行番号が一致しない。一致させたい場合は、下記スクリプト中「// firstLineNumber: 289」をコメントインされたい。なお、下記スクリプトに変更を加えた場合は、「289」の部分を、スクリプトの全行数に揃えて頂きたい。
無効化の方法
①エディタの任意の箇所に「// disable_usiusi」とコメントする(カギカッコ不要) 。
②エディタ上部の確定ボタンを押し、アプレットを保存する。
③アプレットを再読込して、グローバル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() { }