Monaco Editor (VSCodeのエディタ) をiframe内に配置する時の誤動作回避。


Monaco Editor をiframe内に配置すると編集できなくなる問題。

えーと、VSCode使ってますか？
このVSCodeのエディタ部分をブラウザ上で使えたらいいですよね。
そんな時便利な`Monaco Editor`があります。

Reactなら以下のようにします。

  npm install @monaco-editor/react


実は昔作って放置していたVSCode用の拡張機能をWordPressのブロックエディタに移植しようとしてハマりました。

https://marketplace.visualstudio.com/items?itemName=clover.md-table-editor



Monaco EditorをWordPressのブロックエディタ内に配置したところ編集できません。
レイアウトも崩れます。
以前MUIのデータグリッドをブロックに配置した時も崩れました。


https://kurage-worker.com/2024/wordpress-6-2%E3%81%8B%E3%82%89%E3%83%96%E3%83%AD%E3%83%83%E3%82%AF%E3%82%A8%E3%83%87%E3%82%A3%E3%82%BF%E3%81%AE%E4%B8%AD%E8%BA%AB%E3%81%8C%E5%A3%8A%E3%82%8C%E3%82%8B%E4%BB%B6

何故なのか。

ブロックエディタはWordPress 6.2からiframe内に配置されるようになりました。
なのでスタイルシートをiframe内に継承できなかったりwindowからDOMを掘っていく過程で誤作動が起きます。
MUIは前者、Monaco Editorは両方です。


今回４日かけて原因を特定しました。


Monaco Editorは内部的に`textarea`が使われており、
ＤＯＭのイベント「focus、blur」を受け取り、内部で論理的にフォーカス状態を保持しています。

問題は「現在、論理的にフォーカスされているか」を取得する際に、


    window.document.activeElement === textarea

という判定を行います。

以下のようなHTMLがあったとします。

```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本体です。

https://fits.hatenablog.com/entry/20130603/1370269552



textarea.focus()でいったん内部のフォーカス状態はtrueの状態になりますが、
その後の調整で(今回の判定が使われ)、falseの状態に戻されます。
フォーカス状態がfalseなので様々な処理がなされません。


## 回避する

そこで回避法です。
内部ではiframeを意識してません。
でも実はシャドウＤＯＭを想定しているようです。

シャドウＤＯＭの場合は、textareaから先祖を上ってシャドウルートを見つけ出し、

    shadowRoot.activeElement === textarea

という判定を行います。

つまりMonacoEditorをShadowDom内に配置すれば何とか回避できそうです。


以下にShadowDomの実験を記録を残しておきます。

* activeElementはiframeだけでなくShadowDomに対しても同じような問題があり、
* iframeとShadowDomが混在するとさらにややこしくなります。


```js
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で実装してみます。

```tsx
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>
	);
}
```

![monaco-v.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/fa6ba887-4bda-4d41-90e5-10c1a66abb0f.png)


今回はCSSをシャドウDOMに直接追加してますがスマートではありません。
それをどうするかはまた後で考えるとします。




## monaco 内部の重要コード


textareaのfocus/blueイベントで内部で論理的にフォーカス状態を変更しています。
もしtextareaにフォーカスを当てた場合どうなるか追っていきます。


### フォーカスが当たった時に実行される


![monaco-first.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/e36f7ecb-7a69-44f0-bde0-b4378924b73a.png)


### そのメソッドの中身


![monaco-focus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/03732036-fa71-4cf7-8fcb-1b6b4f3154ce.png)



オレンジの矢印の`_setHasFocus()`で論理的なフォーカス状態がセットされる(イベントを発火する)。

(1)フォーカスイベントを受け取ると実行されます。
(2)により問答無用でいったんtrueに設定される。
(3)その後(4)を実行し調整される。
(4)hasFocus()の結果で上書き設定される。



ちなみにfire()されたとき、論理的にフォーカス状態がセットされる場所。

![monaco-set.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/6ee9a178-3328-41c6-bebf-af53a3347605.png)




### 現在の論理的なフォーカス状態を取得する

`hasFocus()`の内部が超重要。

![monaco-has-focus.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/20d84988-e223-48ce-a664-d52483558b73.png)


textareaの先祖にシャドウＲＯＯＴがあればそのactiveElementとtextareaが比較される
無ければwindow.document.activeElementとtextareaが比較される。

iframe内にあるtextareaにはwindow.document.activeElementの対象にならない(常にiframe)ので、
シャドウＤＯＭを使用する。


### DOMのたどり方。


![monaco-dom.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956595/83af916a-8528-49c9-832c-42537764e2c0.png)