はじめに
CodeMirror6をバンドル無しで使って遊んでみる。次のサイトを参考にする。
CodeMirrorはコードエディタを作るためのjavascriptで、どうも5までと6で仕様が大幅に違うらしい。p5.EditorとOpenProcessingは5系を使っている(クラス名にやたらCodeMirror-と付いてるのが5系)。CodePenも5系を使っている。NEORTはまた別のエディタを使ってるらしい...
上記のリンクで、バンドル(詳しくない...)をしなくても使える方法があるということを紹介していたので、それで遊んでみる。ローカルではうまくいかないので、自分のサイトやVSCodeなどのローカルサーバー、OpenProcessingなどのサービスを使う必要がある。今回はOpenProcessingのサイトをお借りして紹介する。
コード全文
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width">
<title>CodeMirror</title>
<link rel="stylesheet" type="text/css" href="style.css" />
<script type="importmap">
{
"imports": {
"codemirror/": "https://deno.land/x/codemirror_esm@v6.0.1/esm/"
}
}
</script>
<script async type="module" src="demo.js"></script>
</head>
<body>
<div id="links">
<a href="https://toach.biz/blog/codemirror6-basics/">参考:codeMirrorをバンドル無しで使う</a>
<a href="https://discuss.codemirror.net/t/esm-compatible-codemirror-build-directly-importable-in-browser/5933">参考:バンドル無しで使うことについて書いてあるらしい</a>
<a href="https://github.com/lionel-rowe/codemirror-esm/tree/esm/esm">リポジトリ一覧はここ</a>
<a href="https://codemirror.net/examples/readonly/">ReadOnlyとEditableは別の概念。focusしたいだけならEditableだけいじる。</a>
<a href="https://codemirror.net/examples/config/">extensionsを後から変更するのはここかもしれない</a>
<p>stateのCompartmentっていうのを使うと動的にextensionsを変更できるようです。</p>
<p>メモ:Tabキーでインデントを扱うにはkeymapを使う。viewに入ってる。</p>
<p>実験的にgetTextやめてみたらflexとかちゃんと補完された。多分だけどあれだ、asyncの中でasyncの関数を使うのまずいんだろう。<br>だから何らかの形でユーザーインタラクションでも使ってロードすればいいのかもしれないが、わからんな。もう少し実験しようか。まあいいや。とりあえずQiitaだ。</p>
</div>
<main>
<div id="editor0">
<div class="buttons"><button class="undo">UNDO</button><button class="redo">REDO</button><button class="copy">COPY</button><button class="lineWrap" aria-pressed="false">LINEWRAP</button><button class="editable" aria-pressed="false">EDIT</button></div>
<div class="code-space"></div>
</div>
<div id="editor1">
<div class="buttons"><button class="undo">UNDO</button><button class="redo">REDO</button><button class="copy">COPY</button></div>
<div class="code-space"></div>
</div>
<div id="editor2">
<div class="buttons"><button class="undo">UNDO</button><button class="redo">REDO</button><button class="copy">COPY</button></div>
<div class="code-space"></div>
</div>
</main>
</body>
</html>
// 参考:https://toach.biz/blog/codemirror6-basics/
// 参考:https://discuss.codemirror.net/t/esm-compatible-codemirror-build-directly-importable-in-browser/5933
// 参考:indentWithTab: https://qiita.com/BB30330807/items/418d20385195b5a7c515
// viewからkeymapを取得しないと駄目みたいですね。それで、ofで配列を渡す。
import { basicSetup, EditorView } from "codemirror/codemirror/dist/index.js"
import { html } from "codemirror/lang-html/dist/index.js"
import { javascript, javascriptLanguage, scopeCompletionSource} from "codemirror/lang-javascript/dist/index.js"
import { css } from "codemirror/lang-css/dist/index.js"
import {keymap} from "codemirror/view/dist/index.js"
import {indentWithTab, undo, redo} from "codemirror/commands/dist/index.js"
import {EditorState, Compartment} from "codemirror/state/dist/index.js";
//let view0, view1, view2;
// 操作についてはここにまとめてもいいし、lilなどで管理するのもあり。
const config = {};
// 処理を切り分けるには、まずCompartmentを生成し、
const lineWrapOption = new Compartment();
const editOption = new Compartment();
// stateを使う場合、docをこっちに含めないとクリアされてしまう。stateのデフォルトのdocが""になってて上書きされるんだろう。
const state0 = EditorState.create({
doc: `<html>
<head>
<title>タイトル</title>
</head>
<body>
<main>
<a href="https://www.fisce.net"></a>
</main>
</body>
</html>`,
extensions: [
keymap.of([indentWithTab]), basicSetup, html(),
javascript(), javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) }),
lineWrapOption.of(EditorView.lineWrapping), // ofでくくる。
editOption.of(EditorView.editable.of(false))
]
});
// scriptタグ内でjavascript補完ができるようにするにはextensionsに追加すればよい。
const view0 = new EditorView({
state:state0,
parent: document.querySelector('#editor0').querySelector(".code-space")
});
// 削除するには[]を指定する
view0.dispatch({
effects:lineWrapOption.reconfigure([])
});
// 復活させるにはそのものずばりを書くだけ
/*
view0.dispatch({
effects:lineWrapOption.reconfigure(EditorView.lineWrapping)
});
*/
// 削除したいなら[]で。この辺の切り替えをボタンで出来るようにすればいい。
/*
view0.dispatch({
effects:lineWrapOption.reconfigure([])
});
*/
// なので、basicSetupの一部をいじるにはこれをばらして部分的にCompartmentで切り分けるしかない。
config.lineWrap0 = (enable) => {
if(enable){
view0.dispatch({
effects:lineWrapOption.reconfigure(EditorView.lineWrapping)
});
}else{
view0.dispatch({
effects:lineWrapOption.reconfigure([])
});
}
}
config.edit0 = (enable) => {
view0.dispatch({
effects:editOption.reconfigure(EditorView.editable.of(enable))
});
}
// 関数の補完方法はこれで。
// javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) })
// これです
const view1 = new EditorView({
doc: `console.log("Hello, world!");`,
extensions: [
keymap.of([indentWithTab]), basicSetup,
javascript(), javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) }),
EditorView.lineWrapping // 行を折り返す場合
],
parent: document.querySelector('#editor1').querySelector(".code-space")
});
const view2 = new EditorView({
doc: `*{font-size:62.5%;}`,
extensions: [
keymap.of([indentWithTab]), basicSetup, css(),
EditorView.lineWrapping
],
parent: document.querySelector('#editor2').querySelector(".code-space")
});
document.querySelector("#editor0").querySelector(".undo").addEventListener("click", ()=>{ undo(view0); });
document.querySelector("#editor0").querySelector(".redo").addEventListener("click", ()=>{ redo(view0); });
document.querySelector("#editor0").querySelector(".copy").addEventListener("click", ()=>{
navigator.clipboard.writeText(
view0.state.doc.toString() // stateがある場合はこれでいいみたいです
//document.querySelector("#editor0").querySelector(".cm-content").innerText
).then(() => {
console.log("copied!");
});
});
document.querySelector("#editor0").querySelector(".lineWrap").addEventListener("click", (e)=>{
const buttonState = (e.target.getAttribute("aria-pressed") === "true");
// 押すことにより「!buttonState」になるので、以降の処理はそれに基づいて実行する。
e.target.setAttribute("aria-pressed", !buttonState);
config.lineWrap0(!buttonState);
});
document.querySelector("#editor0").querySelector(".editable").addEventListener("click", (e)=>{
const buttonState = (e.target.getAttribute("aria-pressed") === "true");
// 押すことにより「!buttonState」になるので、以降の処理はそれに基づいて実行する。
e.target.setAttribute("aria-pressed", !buttonState);
config.edit0(!buttonState);
});
document.querySelector("#editor1").querySelector(".undo").addEventListener("click", ()=>{ undo(view1); });
document.querySelector("#editor1").querySelector(".redo").addEventListener("click", ()=>{ redo(view1); });
document.querySelector("#editor1").querySelector(".copy").addEventListener("click", ()=>{
navigator.clipboard.writeText(
document.querySelector("#editor1").querySelector(".cm-content").innerText
).then(() => {
console.log("copied!");
});
});
document.querySelector("#editor2").querySelector(".undo").addEventListener("click", ()=>{ undo(view2); });
document.querySelector("#editor2").querySelector(".redo").addEventListener("click", ()=>{ redo(view2); });
document.querySelector("#editor2").querySelector(".copy").addEventListener("click", ()=>{
navigator.clipboard.writeText(
document.querySelector("#editor2").querySelector(".cm-content").innerText
).then(() => {
console.log("copied!");
});
});
html {
font-size:62.5%;
}
* {
box-sizing: border-box;
}
body {
margin:0;
font-size:1.8rem;
height:100dvh;
}
main{
display:flex;
flex-direction:column;
gap:4rem;
width:100%;
}
.cm-content{
font-family:"Consolas";
}
#links > a, #links > p{
display:block;
margin:2rem;
}
button{
margin:1rem;
padding:1rem;
}
button:active{
background-color:aquamarine;
}
button[aria-pressed="false"]{
background-color:rgb(160, 160, 160);
}
button[aria-pressed="true"]{
background-color:rgb(255, 255, 255);
}
バンドル無しで使う
次のコードを書くといいらしいです。
<script type="importmap">
{
"imports": {
"codemirror/": "https://deno.land/x/codemirror_esm@v6.0.1/esm/"
}
}
</script>
これはimportする際のアドレスを書き換えてくれるらしい?まあとにかくこれを置いておくといろいろimportできるようです。それで、scriptをasyncで用意しておきます。
<script async type="module" src="demo.js"></script>
type="module"は必須です。これで準備ができました。
作り方
まず、コードを置きたい場所を用意する。このコードの場合は何の変哲もないdiv要素を置いてある。そこがエディタに化ける仕組みらしい。
<div class="code-space"></div>
いろいろimportする。最低限必要なのはbasicSetupとEditorView.
import { basicSetup, EditorView } from "codemirror/codemirror/dist/index.js"
basicSetupには必要な機能が大体揃ってる。内容は:
const basicSetup: Extension = (() => [
// エディタに行番号を追加する。
lineNumbers(),
// アクティブな行のガター(行番号横の余白)をハイライト可能にする。
highlightActiveLineGutter(),
// 通常見ることのできない、特殊な空白、改行文字等をハイライト可能にする。
highlightSpecialChars(),
// キーコマンドに応じてアンドゥ・リドゥを可能にする。
history(),
// コードブロックを折りたたみ可能にする。
foldGutter(),
// テキスト選択時に、エディタと同じ幅の矩形の選択範囲を描く。
drawSelection(),
// エディタ上を何かがドラッグされているとき、ドロップ候補の箇所にカーソルを表示する。
dropCursor(),
// 有効化すると、エディタで複数の範囲を選択できるようにするファセット。
// ただし、デフォルトではエディタはネイティブのDOM選択に依存しており、
// 複数の選択を扱うことができない。
// そのためこのファセットは、drawSelection()のような拡張を併用する必要がある。
EditorState.allowMultipleSelections.of(true),
// `Language`オブジェクトの`languageData.indentOnInput`フィールドに
// 正規表現が格納されている場合にのみ作用する。
// 新しく入力されたテキストの行頭からカーソルまでの入力が、
// その正規表現に合致した際に、自動インデントを行う。
// なお、不要な再インデントを避けるために、
// 同フィールドの正規表現は`^`で始め、$で終わらせることが推奨される。
indentOnInput(),
// highlighterをラップし、
// それを使ってエディタにシンタクスハイライトを適用する。
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// ブラケット(`[]`、`{}`、`()`、`""`、`''`)の
// 隣にカーソルがあるときに、反対側もハイライトする。
bracketMatching(),
// ブラケットを入力したら自動で閉じる。
closeBrackets(),
// オートコンプリートを有効にする。
autocompletion(),
// Altキー(or optionキー)+選択で、
// テキストの矩形選択を有効にする。
rectangularSelection(),
// Altをデフォルトとする修飾キーが押されているときに、
// 十字キーを表示する。
crosshairCursor(),
// アクティブな行をハイライトする。
highlightActiveLine(),
// 選択したテキストと一致するテキストをハイライトする。
highlightSelectionMatches(),
// キーマップを受け取り、基本的なキーバインディングを有効にする。
// basicSetupでは以下のキーマップが渡されている。
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...lintKeymap,
]),
])();
foldGuttersやlineNumbersなどは場合によっては必要ないかもしれないが、容易に切り売りはできない仕組みになっている(後述)。大体これだけあればそれっぽいエディタになる。重要な仕組みはEditorViewで、これがエディタそのものを扱うためのインタフェースになる。
EditorViewを作る
stateを使う方法と、使わない方法がある。まずは使う方法を説明する。
stateを使わない方法
const view1 = new EditorView({
doc: `console.log("Hello, world!");`,
extensions: [
keymap.of([indentWithTab]), basicSetup,
javascript(), javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) }),
EditorView.lineWrapping // 行を折り返す場合
],
parent: document.querySelector('#editor1').querySelector(".code-space")
});
まずdocには入れたいテキストを入れる。デフォルトは空文字。extensions,ここに使いたい機能を配列の形で指定する。basicSetupはそのままおいておけば上記の機能はすべて適用される。他にも欲しい場合は追加する。あとはparentにエディタ化したい要素を指定する(div要素...それ以外は試したこと無いけど。まあdiv要素ですね)。これでそこがそのままエディタになる。おわり。
stateを使う方法
stateを使う場合は、先にimportする。
import {EditorState, Compartment} from "codemirror/state/dist/index.js";
Compartmentについては後述する。無くてもいいが、あると便利。
// stateを使う場合、docをこっちに含めないとクリアされてしまう。stateのデフォルトのdocが""になってて上書きされるんだろう。
const state0 = EditorState.create({
doc: `<html>
<head>
<title>タイトル</title>
</head>
<body>
<main>
<a href="https://www.fisce.net"></a>
</main>
</body>
</html>`,
extensions: [
keymap.of([indentWithTab]), basicSetup, html(),
javascript(), javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) })
]
});
// scriptタグ内でjavascript補完ができるようにするにはextensionsに追加すればよい。
const view0 = new EditorView({
state:state0,
parent: document.querySelector('#editor0').querySelector(".code-space")
});
stateを使う場合、先にEditorStateというクラスを用意する。docはその場合こっちで用意する。extensionsもである。そうしたうえで、EditorViewを作る際、docやextensionsは用意せず、stateに先ほど用意したEditorStateを指定する。あとはparentを指定して終わり。
extensions
次に、extensionsをいろいろ。
javascript(), html(), css()
これらはそれぞれ、対応するモジュールをimportして使えるようにする。
import { html } from "codemirror/lang-html/dist/index.js"
import { javascript, javascriptLanguage, scopeCompletionSource} from "codemirror/lang-javascript/dist/index.js"
import { css } from "codemirror/lang-css/dist/index.js"
それぞれlang-言語名というモジュールに入ってる。一覧はこちら:
extensionsにこれらを含めると、ハイライトが機能する。加えてautoCompleteも機能するので自動的に候補を出してくれる。なお、javascriptに関しては関数の内容などは
javascriptLanguage.data.of({ autocomplete: scopeCompletionSource(globalThis) })
を用意しないといけないので注意。html内のscriptタグでそれが欲しい場合も必要になる。
indentWithTab
タブキーでインデントする。シフト+タブキーだとインデントが左にズレる。これがうれしいのだが、なぜかp5.Editorでは機能しなくなってしまった。原因は不明。右にしかずらせないので不便である。
これはindentWithTabという機能で、使うにはkeymapとindentWithTabを別々にimportする。
import {keymap} from "codemirror/view/dist/index.js"
import {indentWithTab, undo, redo} from "codemirror/commands/dist/index.js"
そうしたうえで次のように設定する。
keymap.of([indentWithTab])
つまり、配列の形でキー関連のオブジェクトを並べることで指定する。
行折り返し
行が長い場合に折り返す。スマホなどでいちいち右にスクロールするのがめんどくさい場合には重宝する。
EditorView.lineWrapping
これを置くだけ。
編集可能性
フォーカスはできるが編集はできないようにする機能。
EditorView.editable.of(false)
ここのfalse/trueに応じて編集不能にしたりできる。ところで後からいじる場合はどうするのだろうか?
あとからextensionsを変更する方法
これについては次のサイトで説明されている。
5系と違ってちょっと難しい仕組みになっているらしい。これにはCompartmentというのを使う。まず、これをあらかじめ作っておく。
// 処理を切り分けるには、まずCompartmentを生成し、
const lineWrapOption = new Compartment();
const editOption = new Compartment();
次いで、使いたい機能をこれのofでラップして指定する。
lineWrapOption.of(EditorView.lineWrapping), // ofでくくる。
editOption.of(EditorView.editable.of(false))
こうするとまずofの中身がextensionsとして機能する。そのうえで...
const config = {};
/* ... */
config.lineWrap0 = (enable) => {
if(enable){
view0.dispatch({
effects:lineWrapOption.reconfigure(EditorView.lineWrapping)
});
}else{
view0.dispatch({
effects:lineWrapOption.reconfigure([])
});
}
}
config.edit0 = (enable) => {
view0.dispatch({
effects:editOption.reconfigure(EditorView.editable.of(enable))
});
}
こんな感じで、dispatchという関数をeffectsというオブジェクトを渡す形で実行する。その中で先ほどのCompartmentでreconfigureという関数を実行する。その内部はextensionsの配列に入れるときの中身をそのまま書けばいいし、取り消す場合は[]を指定すればよい。これでextensionsの一部を書き換えることができる。
なおここではやってないが、basicSetupの機能の一部を編集可能にしたい場合は、これをばらしたうえで、いじりたい機能だけをCompartmentで切り分ければできると思う。
undo, redoをボタンで実行する
historyという機能である。キーではCtrl+ZとCtrl+Yが割り当てられる。basicSetupの一部なので普通に使えるが、スマホではもちろん使えない。もし使いたい場合は、関数自体をimportしてボタンに割り当てることで、スマホでも使えるようにできる。
import {indentWithTab, undo, redo} from "codemirror/commands/dist/index.js"
実行は簡単で、EditorViewオブジェクトを渡すだけ。もちろんhistory()は必須。
undo(view0);
/* ... */
redo(view0);
テキストの取得(クリップボードへのコピー)
stateを使わない場合、普通にinnerTextで取得できるが、stateを使う場合は、EditorView経由で取得できる。
view0.state.doc.toString()
あとスタイルの変更はちょっと追いついてないので...割愛。
おわりに
OpenProcessingのCodeMirror, タブがおかしいんですよね。たとえばそのままQiitaに持ってくるともろにズレる。なのでここで書いたコードはすべてお気に入りのAtomとVSCodeで書いています。あと日本語入力だと漢字変換のボックスが文字を隠すから使いづらい...自分でエディタを作れたら楽しいと思いました。
拝読感謝。