Monaco Editor をiframe内に配置すると編集できなくなる問題。
えーと、VSCode使ってますか?
このVSCodeのエディタ部分をブラウザ上で使えたらいいですよね。
そんな時便利なMonaco Editor
があります。
Reactなら以下のようにします。
npm install @monaco-editor/react
実は昔作って放置していたVSCode用の拡張機能をWordPressのブロックエディタに移植しようとしてハマりました。
Monaco EditorをWordPressのブロックエディタ内に配置したところ編集できません。
レイアウトも崩れます。
以前MUIのデータグリッドをブロックに配置した時も崩れました。
何故なのか。
ブロックエディタはWordPress 6.2からiframe内に配置されるようになりました。
なのでスタイルシートをiframe内に継承できなかったりwindowからDOMを掘っていく過程で誤作動が起きます。
MUIは前者、Monaco Editorは両方です。
今回4日かけて原因を特定しました。
Monaco Editorは内部的にtextarea
が使われており、
DOMのイベント「focus、blur」を受け取り、内部で論理的にフォーカス状態を保持しています。
問題は「現在、論理的にフォーカスされているか」を取得する際に、
window.document.activeElement === textarea
という判定を行います。
以下のようなHTMLがあったとします。
<html>
<head>
</head>
<body onload="init()">
<h1>IFrame のテスト</h1>
<textarea id="outerText"></textarea>
<iframe id="innerFrame" width="300" height="200">
<textarea id="innerText"></textarea>
</iframe>
</body>
</html>
もしouterText
にフォーカスが当たれば判定は成功しますが、
問題はinnerText
にフォーカスが当たってた場合です。
iframe内の要素にフォーカスが当たっていた場合、document.activeElementで取得できるのはiframe本体です。
textarea.focus()でいったん内部のフォーカス状態はtrueの状態になりますが、
その後の調整で(今回の判定が使われ)、falseの状態に戻されます。
フォーカス状態がfalseなので様々な処理がなされません。
回避する
そこで回避法です。
内部ではiframeを意識してません。
でも実はシャドウDOMを想定しているようです。
シャドウDOMの場合は、textareaから先祖を上ってシャドウルートを見つけ出し、
shadowRoot.activeElement === textarea
という判定を行います。
つまりMonacoEditorをShadowDom内に配置すれば何とか回避できそうです。
以下にShadowDomの実験を記録を残しておきます。
- activeElementはiframeだけでなくShadowDomに対しても同じような問題があり、
- iframeとShadowDomが混在するとさらにややこしくなります。
function init()
{
const inner = `<textarea id="innerText"></textarea>`;
const frame = document.getElementById("innerFrame").contentDocument;
frame.body.innerHTML = `<html><head></head><body><h2>Hello</h2><div id="shadowHost"></div></body></html>`;
const host = frame.getElementById("shadowHost");
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `${inner}`;
setInterval(() => showMessage(), 5000);
}
function showMessage()
{
console.log("--- log ---")
//console.log(document.getElementById('innerFrame').contentDocument.activeElement)
//console.log(window.document.activeElement);
const host = document.getElementById('innerFrame').contentDocument.getElementById('shadowHost');
console.log(host.shadowRoot)
const innerDom = host.shadowRoot.getElementById('innerText');
console.log(innerDom)
const shadowRoot = getShadowRoot(innerDom)
console.log(shadowRoot)
console.log(shadowRoot.activeElement)
console.log("=== やりたいこと ===")
console.log(innerDom === shadowRoot.activeElement)
}
// 以下はmonaco editorにてtextareaからシャドウDOMを取得する際に定義してある関数
function getShadowRoot(domNode) {
while (domNode.parentNode) {
if (domNode === domNode.ownerDocument.body) {
// reached the body
return null;
}
domNode = domNode.parentNode;
}
return isShadowRoot(domNode) ? domNode : null;
}
function isShadowRoot(node){
return (
node && !!node.host && !!node.mode
);
}
innerTextにフォーカスを当てて実行結果を見てください。
Reactで使うなら
Reactならreact-shadow
がある
Reactで実装
これを踏まえ、Reactで実装してみます。
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { __ } from '@wordpress/i18n';
import { Editor } from '@monaco-editor/react';
import root from 'react-shadow'
export default function Edit()
{
return (
<div>
{ __( 'Block Editor Develop!', 'kurage' ) }
<root.div className="monaco-shadow-dom">
<link
rel="stylesheet"
type="text/css"
data-name="vs/editor/editor.main"
href="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css"
/>
<Editor
width="100%"
height="300px"
defaultLanguage="markdown"
theme="vs-dark"
onChange={e => console.log(e)}
/>
</root.div>
</div>
);
}
今回はCSSをシャドウDOMに直接追加してますがスマートではありません。
それをどうするかはまた後で考えるとします。
monaco 内部の重要コード
textareaのfocus/blueイベントで内部で論理的にフォーカス状態を変更しています。
もしtextareaにフォーカスを当てた場合どうなるか追っていきます。
フォーカスが当たった時に実行される
そのメソッドの中身
オレンジの矢印の_setHasFocus()
で論理的なフォーカス状態がセットされる(イベントを発火する)。
(1)フォーカスイベントを受け取ると実行されます。
(2)により問答無用でいったんtrueに設定される。
(3)その後(4)を実行し調整される。
(4)hasFocus()の結果で上書き設定される。
ちなみにfire()されたとき、論理的にフォーカス状態がセットされる場所。
現在の論理的なフォーカス状態を取得する
hasFocus()
の内部が超重要。
textareaの先祖にシャドウROOTがあればそのactiveElementとtextareaが比較される
無ければwindow.document.activeElementとtextareaが比較される。
iframe内にあるtextareaにはwindow.document.activeElementの対象にならない(常にiframe)ので、
シャドウDOMを使用する。