JavaFX で ace.js を使ったエディタのテキストがコピーできない

f:id:Naotsugu:20200319225006p:plain


はじめに

windows での挙動は試していませんが、MacOS 上で JavaFX の WebView を使い、ace.js を組み込んだ際にテキストのコピーが動かない事象がありました。

環境は JavaFX は 13、ace.js は 1.4.8 ですが、JavaFX は 11 でも 14 でも同様です。

多少の情報は存在しますが、どれも上手く機能する対応ではなかったため、念の為ここに記しておきます。


Workaround 1

ユーザエージェントで Chrome18 以下を偽装することでテキストのコピーが可能となります。

WebView webView = new WebView();
WebEngine engine = webView.getEngine();

engine.userAgentProperty().setValue(
  engine.userAgentProperty().get() + " Chrome/17");


Workaround 2

この Workaround は上手く機能しないかもしれません。 手元の環境では、JavaFX13 だと動かなかったり、script をローカルファイルから読み込むか、CDNから取得するかで挙動が変わったりと、なかなか不安定だったのでおすすめではありません。

以下のようなアップコール用のブリッジを作成します。

public class UpCallBridge {
    public void copyUpCall(String text) {
        final Clipboard clipboard = Clipboard.getSystemClipboard();
        final ClipboardContent content = new ClipboardContent();
        content.putString(text);
        clipboard.setContent(content);
    }
}

作成したブリッジを window に登録します。

WebView webView = new WebView();
WebEngine engine = webView.getEngine();
engine.setJavaScriptEnabled(true);
JSObject jsobj = (JSObject) engine .executeScript("window");
jsobj.setMember("java", new UpCallBridge());

Javascript 側で copy 時のリスナーとしてアップコールのブリッジを登録します。

editor.on('copy', function(obj) {
   java.copyUpCall(obj.text);
});


Workaround 3

こちらは無理やりですが、コピー時のコールバックで例外を throw することでテキストのコピーが可能となります。

var editor = ace.edit("editor");

editor.on('copy', function(obj) {
   throw new Error('dummy');
});


ace.js におけるコピー処理

コピー処理は以下のような実装になっています。

var doCopy = function(e, isCut) {
    var data = host.getCopyText();  // (1-1)
    if (!data)
        return event.preventDefault(e);
    if (handleClipboardData(e, data)) {  // (1-2)
        if (isIOS) {
            resetSelection(data);
            copied = data;
            setTimeout(function () {
                copied = false;
            }, 10);
        }
        isCut ? host.onCut() : host.onCopy();
        event.preventDefault(e); // (1-3)
    } else {
        copied = true;
        text.value = data;
        text.select();
        setTimeout(function(){
            copied = false;
            resetSelection();
            isCut ? host.onCut() : host.onCopy();
        });
    }
};

(1-1)hostgetCopyText() を呼び出し、コピー対象のテキストを取得しています。

(1-2) でクリップボードへの保存を行い、(1-3) で規定イベントをキャンセルしています。

(1-2) でクリップボードへの保存が失敗した場合には preventDefault は行いません。

(1-1) に戻り、 hostEditor で、以下のようになっています。

this.getCopyText = function() {
    var text = this.getSelectedText();
    // ...
    var e = {text: text};
    this._signal("copy", e); // (2-1)
    clipboard.lineMode = copyLine ? e.text : "";
    return e.text;
};

選択中のテキストを取得し、(2-1) でコールバック処理しています。editor.on('copy', function(obj) { }); とすることでコピー対象を受け取ることができます。

前述の Workaround 2 ではここにアップコール用のブリッジを登録しています。

前述の workaround 3 ではこのコールバック時に例外を発行し、ace.js でのコピー処理を無理やり行わないようにしたものです。


さて、(1-2) での handleClipboardData は以下のようになっています。

var handleClipboardData = function(e, data, forceIEMime) {
    var clipboardData = e.clipboardData || window.clipboardData;
    if (!clipboardData || BROKEN_SETDATA) // (3-1)
        return;
    var mime = USE_IE_MIME_TYPE || forceIEMime ? "Text" : "text/plain";
    try {
        if (data) {
            return clipboardData.setData(mime, data) !== false; // (3-2)
        } else {
            return clipboardData.getData(mime);
        }
    } catch(e) {
        if (!forceIEMime)
            return handleClipboardData(e, data, true);
    }
};

クリップボードへのコピーが動かない原因は (3-2) の箇所で、クリップボードデータへ設定するも、システムのクリップボードへの反映が行われないためです。

詳細は追っていませんが、JavaFX の WebView では(nashornではなく) webkit の JavaScriptCore が使われており、ClipboardEvent.clipboardData で取得した DataTransfer オブジェクトへの設定は正常に行われますが、クリップボードには値が反映されないようです。

実際 clipboardData.setData() で設定すれば、clipboardData.getData() で値を取り出すことができますが、クリップボードには反映されていませんでした。

clipboardData の取得を以下のように(ClipboardEvent からではなく) window から取得したものを使えば上手く動きます。

var clipboardData = /* e.clipboardData || */ window.clipboardData;


(3-1) で、BROKEN_SETDATA であればこの処理をスキップしています。

BROKEN_SETDATA は何かと言えば、以下のようにユーザエージェントで Chrome 18以下であることで初期化されています。

var BROKEN_SETDATA = useragent.isChrome < 18;

なので workaround 1 で示した方法で WebView のユーザエージェントを書き換えることで、動作しない、ClipboardEvent.clipboardData への処理をスキップできます。


まとめ

話は前後しますが、ace.js を JavaFx で使うには以下のように利用できます(前述の Workaround 2 による対応版です)。

public class App extends Application {

    @Override
    public void start(Stage stage) {

        WebView webView = new WebView();
        WebEngine engine = webView.getEngine();
        engine.setJavaScriptEnabled(true);
        JSObject jsobj = (JSObject) engine .executeScript("window");
        jsobj.setMember("java", new UpCallBridge());
        engine.load(getClass().getClassLoader()
                .getResource("ace.html").toExternalForm());

        Scene scene = new Scene(new StackPane(webView));
        stage.setScene(scene);
        stage.show();
    }

    public static void main(String[] args) {
        launch();
    }
}

アップコール用のブリッジです。

public class UpCallBridge {
    public void copyUpCall(String text) {
        final Clipboard clipboard = Clipboard.getSystemClipboard();
        final ClipboardContent content = new ClipboardContent();
        content.putString(text);
        clipboard.setContent(content);
    }
}

ace.html は以下のように作成します。

<!DOCTYPE html>
<html>
<head>
<style type="text/css" media="screen">
#editor { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
</style>
</head>
<body>
<div id="editor"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.8/ace.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.8/theme-monokai.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.8/mode-java.min.js"></script>
<script>
    var editor = ace.edit("editor");
    editor.setTheme("ace/theme/monokai");
    editor.getSession().setMode("ace/mode/java");

    editor.on('copy', function(obj) {
       java.copyUpCall(obj.text);
    });
</script>
</body>
</html>