1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

IFrame内に配置した「Monaco Editor」のIMEの位置がズレる問題。

1
Posted at

IFrame内に配置した「Monaco Editor」のIMEの位置がズレる問題。

Monaco Editor v0.55.1 での出来事です。

WordPressのブロックエディタでMonaco Editorを埋めこんだらIMEの位置がずれました。

monaco-vs.png

いつごろからかWordPressは投稿ページでブロックエディタの配置をIFrame内に置くようになりました。
結果、使用する外部ライブラリがことごとくIFrameの制約を受け大変な思いしてます。

CSSのインポートなんかトップレベルウィンドウに追加されることを前提にしているため
IFrame内に適用されずレイアウトが崩れる典型的なものから、今回のようにIFrame座標問題まで。

結論。

Monaco Editor がIFrameを考慮してない。

理由。

一般的にブラウザ上で動くオリジナルエディタの開発にはcontenteditableが使われてきたみたいです。
ただ最近はChromeにEditContextという新種のAPIが登場しました。

Monaco EditorはこのAPIを使っているようです。

IMEの座標を制御するにはEditContextクラスのupdateSelectionBounds()を呼び出す必要があり、
この引数に渡すRectangle(DOMRect)にある特定のDOM(MonacoEditorはそれように境界用のDOMを忍ばせてます)が使われてます。
問題はこの時DOMのgetBoundingClientRect()を取得しますが、このメソッドはあくまで内側の領域からの座標で、
トップレベルウィンドウからの座標ではありません(IFrameの差分が考慮されてません)。

エディタ内部にいくつかの座標用のDOMが忍ばせてあり、

エディタの範囲
div.overflow-guard

カーソルの位置
div.native-edit-context

これとスクロール座標等から最終的なIMEの座標を計算しているようです。

private _updateSelectionAndControlBoundsAfterRender() {

	private _updateSelectionAndControlBoundsAfterRender() {
		const options = this._context.configuration.options;
		const contentLeft = options.get(EditorOption.layoutInfo).contentLeft;

		const viewSelection = this._context.viewModel.coordinatesConverter.convertModelRangeToViewRange(this._primarySelection);
		const verticalOffsetStart = this._context.viewLayout.getVerticalOffsetForLineNumber(viewSelection.startLineNumber);
		const verticalOffsetEnd = this._context.viewLayout.getVerticalOffsetAfterLineNumber(viewSelection.endLineNumber);

		// Make sure this doesn't force an extra layout (i.e. don't call it before rendering finished)
		const parentBounds = this._parent.getBoundingClientRect();
		const top = parentBounds.top + verticalOffsetStart - this._scrollTop;
		const height = verticalOffsetEnd - verticalOffsetStart;
		let left = parentBounds.left + contentLeft - this._scrollLeft;
		let width: number;

		if (this._primarySelection.isEmpty()) {
			if (this._linesVisibleRanges) {
				left += this._linesVisibleRanges.left;
			}
			width = 0;
		} else {
			width = parentBounds.width - contentLeft;
		}

		const selectionBounds = new DOMRect(left, top, width, height);
		this._editContext.updateSelectionBounds(selectionBounds);
		this._editContext.updateControlBounds(selectionBounds);
	}

この中の

this._parent.getBoundingClientRect()

が「div.overflow-guard」の領域ですね。
そこで「div.overflow-guard」にIFrame分を追加するとうまく機能しそうです。

その前に、

// Make sure this doesn't force an extra layout (i.e. don't call it before rendering finished)

これはどういう意味・・・?

EditContextの挙動を知る。

EditContextがどのようなものかをざっくり見て、

試しに使ってみます。
IFrameを実現するため、外側のHTMLがあったとします。

<html>
    <head></head>
    <body>
        <div style="padding: 100px">
            <iframe src="./test.html" width="600" height="600"></iframe>
        </div>
    </body>
</html>

本題の内側のHTML。

<html>
    <body>

        <div>
        <p>inputスペース</p>
        <div id="editor" style="background: lime; width: 300px; height: 100px;"></div>

        <p>outputスペース</p>
        <div id="view" style="background: yellow; width: 300px;"></div>


        </div>
        <script>

    const input = document.getElementById("editor");
    const output = document.getElementById("view");
    const editContext = new EditContext();
    input.editContext = editContext;

    editContext.addEventListener("textupdate", (event) => {
        output.textContent = editContext.text;
        update();
    });

    const update = () =>
    {
        const rect = output.getBoundingClientRect();
        editContext.updateSelectionBounds(rect);
        editContext.updateControlBounds(rect);
    }

    update();

        </script>
    </body>
</html>

monaco-ime-pos.png

黄色の下にIMEを表示したいのに、当然IFrameの外側の余白の分だけズレます。

そこでIFrameがあった場合の座標の取得をAIに聞いてみると以下のコードが。

function getTopBoundingClientRect(element) {
    // まず、要素自身のビューポート基準の座標を取得
    const rect = element.getBoundingClientRect();
    let top = rect.top;
    let left = rect.left;
    
    // 現在のウィンドウから親をたどる
    let currentWindow = element.ownerDocument.defaultView;
    while (currentWindow !== window.top) {
        // 親ウィンドウにおける現在のIFrame要素を取得
        const parentWindow = currentWindow.parent;
        const iframe = currentWindow.frameElement;
        
        if (!iframe) break; // frameElementが存在しない場合(クロスオリジンなど)は終了

        // 親ウィンドウでのIFrameの位置を取得
        const iframeRect = iframe.getBoundingClientRect();
        
        // 座標にIFrameのオフセットを加算
        top += iframeRect.top;
        left += iframeRect.left;
        
        // 次の親へ移動
        currentWindow = parentWindow;
    }
    
    // 結果をDOMRect形式で返す(必要に応じて他のプロパティも計算可能)
    return {
        top: top,
        left: left,
        right: left + rect.width,
        bottom: top + rect.height,
        width: rect.width,
        height: rect.height,
        x: left,
        y: top
    };
}

この関数を使ってちょっとだけ工夫。

<html>
    <body>

        <div>
        <p>inputスペース</p>
        <div id="editor" style="background: lime; width: 300px; height: 100px;"></div>

        <p>outputスペース</p>
        <div id="view" style="background: yellow; width: 300px;"></div>


        </div>
        <script>

    const input = document.getElementById("editor");
    const output = document.getElementById("view");
    const editContext = new EditContext();
    input.editContext = editContext;

    editContext.addEventListener("textupdate", (event) => {
        output.textContent = editContext.text;
        update();
    });

    const update = () =>
    {

        const rect = output.getBoundingClientRect();

        // ここでIFrame考慮されたxとyを取得する。
        const { x, y } = getTopBoundingClientRect(output);

        // 新しくDOMRectを作成しなおす。
        const newRect = new DOMRect(x, y, rect.width, rect.height);

        editContext.updateSelectionBounds(newRect);
        editContext.updateControlBounds(newRect);
    }

    update();




        </script>
    </body>
</html>

monaco-iframe.png

ででんっ!

実験してみる。

ちょっとトリッキーな実験をしてみます。

DOM(div.overflow-guard)の.getBoundingClientRect()をすり替え、IFrame分の座標を追加する実験をしてみます。
ただしgetTopBoundingClientRect()をそのまま使うと無限ループしてしまうのでIFrameの誤差を計算するだけに改良します。


// これは環境に合わせます。
const shadow = domRef.current?.ownerDocument?.getElementsByClassName('monaco-shadow-dom')[0]?.shadowRoot;
if(shadow)
{
    const guard = shadow.querySelector('.overflow-guard');
    if(guard)
    {
        const buf = guard.getBoundingClientRect;
        guard.getBoundingClientRect = () => {
            const rect = buf.apply(guard);
            const { x, y } = getTopBoundingClientRect(guard);
            return new DOMRect(rect.x + x, rect.y + y, rect.width, rect.height);
        }
    }
}

function getTopBoundingClientRect(element: any) {
    let top =0;
    let left = 0;
    
    // 現在のウィンドウから親をたどる
    let currentWindow = element.ownerDocument.defaultView;
    while (currentWindow !== window.top) {
        // 親ウィンドウにおける現在のIFrame要素を取得
        const parentWindow = currentWindow.parent;
        const iframe = currentWindow.frameElement;
        
        if (!iframe) break; // frameElementが存在しない場合(クロスオリジンなど)は終了

        // 親ウィンドウでのIFrameの位置を取得
        const iframeRect = iframe.getBoundingClientRect();
        
        // 座標にIFrameのオフセットを加算
        top += iframeRect.top;
        left += iframeRect.left;
        
        // 次の親へ移動
        currentWindow = parentWindow;
    }
    
    // 結果をDOMRect形式で返す(必要に応じて他のプロパティも計算可能)
    return {
        x: left,
        y: top
    };
}

monaco-imex.png

正常に動くようです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?