IDEA Web渲染插件開發(二)— 自定義JsDialog

《IDEA Web渲染插件開發(一)》中,我們了解到了如何編寫一款用於顯示網頁的插件,所需要的核心知識點就是IDEA插件開發JCEF,在本文中,我們將繼續插件的開發,為該插件的JS Dialog顯示進行自定義處理。

背景

在開發之前,我們首先要了解下什麼是JS Dialog。有過Web頁面開發經歷的開發者都或多或少使用過這樣一個JS的API:alert('this is a message'),當JS頁面執行這段腳本的時候,在瀏覽器上會有類似於如下的顯示:

同樣,當我們使用confirm('ok?')的時候,會顯示如下:

以及,使用prompt(input your name: '),有如下的顯示:

這些彈框一般來說都是原生的窗體,例如,當我們在之前的《IDEA Web渲染插件開發(一)》中的Web渲染插件來打開上面的Demo網頁的時候,效果如下:

alert

confirm

prompt

可以看到,原生窗體顯得不是那麼好看。那麼,我們能不能自定義這個原生窗體呢?答案是肯定的,接下來就要用到JCEF裡面一個Handler CefJSDialogHandler(java-cef/CefJSDialogHandler)。

CefJSDialogHandler

對於該Handler,官方注釋為:

Implement this interface to handle events related to JavaScript dialogs. The methods of this class will be called on the UI thread.

實現此介面以處理與JavaScript對話框相關的事件。將在UI執行緒上調用此類的方法。

對於該Handler,裡面有一個核心的介面方法:

    /**
     * Called to run a JavaScript dialog. Set suppress_message to true and
     * return false to suppress the message (suppressing messages is preferable
     * to immediately executing the callback as this is used to detect presumably
     * malicious behavior like spamming alert messages in onbeforeunload). Set
     * suppress_message to false and return false to use the default
     * implementation (the default implementation will show one modal dialog at a
     * time and suppress any additional dialog requests until the displayed dialog
     * is dismissed). Return true if the application will use a custom dialog or
     * if the callback has been executed immediately. Custom dialogs may be either
     * modal or modeless. If a custom dialog is used the application must execute
     * callback once the custom dialog is dismissed.
     *
     * @param browser The corresponding browser.
     * @param origin_url The originating url.
     * @param dialog_type the dialog type.
     * @param message_text the text to be displayed.
     * @param default_prompt_text value will be specified for prompt dialogs only.
     * @param callback execute callback once the custom dialog is dismissed.
     * @param suppress_message set to true to suppress displaying the message.
     * @return false to use the default dialog implementation. Return true if the
     * application will use a custom dialog.
     */
    public boolean onJSDialog(CefBrowser browser, String origin_url, JSDialogType dialog_type,
            String message_text, String default_prompt_text, CefJSDialogCallback callback,
            BoolRef suppress_message);

注釋翻譯如下:

在調用一個JS的Dialog的時候會調用該方法。設置suppress_messagetrue並使該方法返回false來抑制這個消息(抑制消息比立即執行回調更可取,因為它用於檢測可能的惡意行為,如onbeforeunload中的垃圾郵件警報消息)。設置suppress_messagefalse並且返回false來使用默認的實現(默認的實現將會立刻展示一個模態對話框並抑制任何額外的對話框請求直到當前展示的對話框已經銷毀)。如果應用程式想要使用一個自定義的對話框或是回調callback已經立刻被執行了,則返回true。自定義的對話框可以是模態或是非模態的。如果使用了一個自定義的對話框,那麼一旦自定義對話框銷毀後,應用程式需要立即執行回調。

首先,我們編寫類JsDialogHandler,實現該介面:

package com.compilemind.demo.handler;

import org.cef.browser.CefBrowser;
import org.cef.callback.CefJSDialogCallback;
import org.cef.handler.CefJSDialogHandler;
import org.cef.misc.BoolRef;

import static org.cef.handler.CefJSDialogHandler.JSDialogType.*;

public class JsDialogHandler implements CefJSDialogHandler {
    
    @Override
    public boolean onJSDialog(CefBrowser browser,
                              java.lang.String origin_url,
                              CefJSDialogHandler.JSDialogType dialog_type,
                              java.lang.String message_text,
                              java.lang.String default_prompt_text,
                              CefJSDialogCallback callback,
                              BoolRef suppress_message) {
        // 具體內容見下文
    }

    @Override
    public boolean onBeforeUnloadDialog(CefBrowser cefBrowser, String s, boolean b, CefJSDialogCallback cefJSDialogCallback) {
        return false;
    }

    @Override
    public void onResetDialogState(CefBrowser cefBrowser) {

    }

    @Override
    public void onDialogClosed(CefBrowser cefBrowser) {

    }
}

除了onJSDialog方法,其他的我們暫時不關心,使用默認的處理。對於onJSDialog的方法,我們編寫如下的內容:

    @Override
    public boolean onJSDialog(CefBrowser browser,
                              java.lang.String origin_url,
                              CefJSDialogHandler.JSDialogType dialog_type,
                              java.lang.String message_text,
                              java.lang.String default_prompt_text,
                              CefJSDialogCallback callback,
                              BoolRef suppress_message) {
        // 不抑制消息
        suppress_message.set(false);
        if (dialog_type == JSDIALOGTYPE_ALERT) {
            // alert 對話框

        } else if (dialog_type == JSDIALOGTYPE_CONFIRM) {
            // confirm 對話框
            
        } else if (dialog_type == JSDIALOGTYPE_PROMPT) {
            // prompt 對話框
            
        } else {
            // 默認處理,不過理論不會進入這一步
            return false;
        }

        // 返回true,表明自行處理
        return false;
    }

接下來,我們向CefBrowser進行註冊(MyWebToolWindowContent類的構造函數中):

// 創建 JBCefBrowser
JBCefBrowser jbCefBrowser = new JBCefBrowser();
// 註冊我們的Handler
jbCefBrowser.getJBCefClient()
        .addJSDialogHandler(
                new JsDialogHandler(),
                jbCefBrowser.getCefBrowser());
// 將 JBCefBrowser 的UI控制項設置到Panel中
this.content.add(jbCefBrowser.getComponent(), BorderLayout.CENTER);

至此,我們已經在該方法中對js的對話框類型進行了區分。接下來,就需要我們針對不同的對話框類型,展示不同的UI,那麼需要我們了解如何在IDEA插件中彈出對話框。

IDEA插件對話框

DialogWrapper

DialogWrapper是IntelliJ下的所有對話框的基類,他並不是一個實際的UI控制項,而是一個抽象類,在調用其show方法的時候,由IntelliJ框架進行展示。

Dialogs | IntelliJ Platform Plugin SDK (jetbrains.com)

我們需要做的就是編寫一個類來繼承該Wrapper。

AlertDialog

為了實現JS中的alert效果,我們首先編寫AlertDialog:

import com.intellij.openapi.ui.DialogWrapper;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;

public class AlertDialog extends DialogWrapper {

    private final String content;

    public AlertDialog(String title, String content) {
        super(false);
        setTitle(title);
        this.content = content;
        // init方法需要在所有的值設置到位的時候才進行調用
        init();
    }

    @Override
    protected @Nullable JComponent createCenterPanel() {
        return new JLabel(this.content);
    }

}

這個Dialog的實現非常的簡單,通過構造函數傳入對話框的title和content。其中,title在構造函數執行的時候,就通過DialogWrapper.setTitle(string)完成設置;content賦值給AlertDialog的私有變數content,之後調用DialogWrapper.init()方法進行初始化。

這裡需要特別說明的是,init方法最好放在Dialog的私有變數賦值保存完成後才進行,因為init方法內部就會調用下面重寫的createCenterPanel方法。如果沒有這樣做,而是先init(),再進行this.content = content賦值,那麼初始化的時候流程就是:

  1. 設置title。
  2. 調用init()。
  3. Init()內部調用createCenterPanel()
  4. createCenterPanel返回一個空白的JLabel,因為此時this.content還是null。
  5. 進行this.content = content賦值操作。

最終彈出的對話框效果就是沒有任何的內容,本人在這裡也是踩了坑。

AlertDialog編寫完成後,我們可以在需要的地方編寫如下的程式碼進行彈框展示:

new AlertDialog("注意", "這是一個彈出框").show();
// 或
boolean isOk = new AlertDialog("注意", "這是一個彈出框").showAndGet();

於是,我們在之前的JSDialogHandler.onJSDialog中處理dialog_type == JSDIALOGTYPE_ALERT的場景:

@Override
public boolean onJSDialog(CefBrowser browser,
                          java.lang.String origin_url,
                          CefJSDialogHandler.JSDialogType dialog_type,
                          java.lang.String message_text,
                          java.lang.String default_prompt_text,
                          CefJSDialogCallback callback,
                          BoolRef suppress_message) {
    // 不抑制消息
    suppress_message.set(false);
    if (dialog_type == JSDIALOGTYPE_ALERT) {
        // alert 對話框
        new AlertDialog("注意", message_text).show();
        return true;
    }
    return false;
}

問題處理

調試插件,當JS執行alert的時候,發現依然還是原生窗體。經過排查還會發現,問題情況如下:

  • JS的alert依然是原生窗體。
  • onJSDialog方法也進入了(可以使用斷點或是控制台輸出確認)。
  • 控制台有異常:Exception in thread "AWT-AppKit"

對於控制台的異常,詳細如下:

Exception in thread "AWT-AppKit" com.intellij.openapi.diagnostic.RuntimeExceptionWithAttachments: EventQueue.isDispatchThread()=false Toolkit.getEventQueue()=com.intellij.ide.IdeEventQueue@fa771e7

對於EventQueue關鍵字的異常,有過GUI開發的讀者應該很容易聯想到應該是窗體事件消息機制的問題。

簡單來說,窗體GUI的執行緒一般都是獨立的,在這個執行緒中,會啟動一個GUI事件隊列循環,外部GUI輸入(點擊、拖動等等)會不斷產生GUI事件對象,並按照一定的順序進入事件循環隊列,事件循環框架不斷處理隊列中的事件。對GUI的操作,比如修改窗體某個控制項的文本或是想要對一個窗體進行模態顯示,都需要在窗體GUI主執行緒進行,否則就會出現GUI的處理異常。

對於這類情況最常見問題場景就是:在窗體中點擊一個按鈕,點擊後會單開一個執行緒非同步載入大數據,載入完成後顯示在窗體上。如果直接在載入大數據的執行緒中調用Form.setBigData()(假如有這樣一個設置文本的方法),一般來說就會出現異常:在非GUI執行緒中嘗試修改GUI的相關值。在Java AWT中解決的方式,調用EventQueue.invokeLater(() -> { // do something} )(非同步)或是EventQueue.invokeAndWait(() -> { // do something} )(同步)。調用之後,do something就會被事件框架送入GUI執行緒執行了。

現在,我們回到一開始的問題,我們重新修改程式碼:

if (dialog_type == JSDIALOGTYPE_ALERT) {
    // alert 對話框
    EventQueue.invokeLater(() -> {
      new AlertDialog("注意", message_text).show();
      callback.Continue(true, "");
    });
    return true;
}

我們對程式碼進行斷點確認執行緒,在onJSDialog執行的時候,所運行的執行緒是:AWT-AppKit

而EventQueue.invokeLater中所運行的執行緒是:AWT-EventQueue-0,這個執行緒就是IDEA插件中的GUI執行緒。

修改執行緒處理後,讓我們再次調用alert:

可以看到對話框已經顯示為了使用IDEA插件下的dialog形式,但是這個dialog還不完全正確,一般的alert對話框,只會有一個確認按鈕,而IDEA下的dialog默認是Cancel+OK的按鈕組合。

Dialog按鈕自定義(重寫createActions)

IDEA插件的DialogWrapper默認情況下是Cancel+OK的按鈕組合。那麼如何自定義我們的按鈕呢?可行的一種方式就是重寫createActions。這個方法需要我們返回實現javax.swing.Action介面的實例的數組,當然,IDEA插件也有對應的Wrapper:DialogWrapperAction。我們編寫我們自己的OkAction:

    protected class OkAction extends DialogWrapperAction {

        public OkAction() {
            super("確定");
        }

        @Override
        protected void doAction(ActionEvent e) {
            close(OK_EXIT_CODE);
        }
    }

務必注意,DialogWrapperAction的實現子類,必須是DialogWrapper的內部類,否則無法查看。

重新運行,查看AlertDialog的效果:

接下來,我們需要編寫ConfirmDialog,來處理JS中的confirm。

ConfirmDialog

由於confirm天生需要取消和確定按鈕,所以我們可以直接使用默認的DialogWrapper,不用重寫Action的返回:

import com.intellij.openapi.ui.DialogWrapper;
import org.jetbrains.annotations.Nullable;

import javax.swing.*;

public class ConfirmDialog extends DialogWrapper {

    private final String content;

    public ConfirmDialog(String title, String content) {
        super(false);
        setTitle(title);
        this.content = content;
        // init方法需要在所有的值設置到位的時候才進行調用
        init();
    }

    @Override
    protected @Nullable JComponent createCenterPanel() {
        return new JLabel(this.content);
    }

}

在Handler中,我們對JSDIALOGTYPE_CONFIRM分支進行:

if (dialog_type == JSDIALOGTYPE_CONFIRM) {
    // confirm 對話框
    EventQueue.invokeLater(() -> {
        boolean isOk = new ConfirmDialog("注意", message_text).showAndGet();
        callback.Continue(isOk, "");
    });
    return true;
}

這點和AlertDialog的差別在於,需要調用showAndGet方法獲取用戶的點擊是cancel還是ok的結果,使用callback返回給JS,才能使得JS的confirm調用獲得正確的返回。下面是效果:

PromptDialog

對於PromptDialog,在對話框的介面,需要兩個元素:文本提示文本輸入。同時,在對話框點擊結束後,還需要獲取用戶的輸入,程式碼如下:

public class PromptDialog extends DialogWrapper {

    /**
     * 顯示資訊
     */
    private final String content;

    /**
     * 文本輸入框
     */
    private final JTextField jTextField;

    public PromptDialog(String title, String content) {
        super(false);

        this.jTextField = new JTextField(10);
        this.content = content;

        setTitle(title);
        // init方法需要在所有的值設置到位的時候才進行調用
        init();
    }

    @Override
    protected @Nullable JComponent createCenterPanel() {
        // 2行1列的結構
        JPanel jPanel = new JPanel(new GridLayout(2, 1));
        jPanel.add(new JLabel(this.content));
        jPanel.add(this.jTextField);
        return jPanel;
    }

    public String getText() {
        return this.jTextField.getText();
    }
}

在這個類中,我們定義了一個私有欄位JTextField,之所以需要在類中持有該引用,是因為我們定義一個方法getText,以便在對話框結束時,可以通過調用PromptDialog.getText來獲取用戶輸入。

編寫完成後,我們在onJSDialog中對prompt類型的對話框進行處理:

if (dialog_type == JSDIALOGTYPE_PROMPT) {
    // prompt 對話框
    EventQueue.invokeLater(() -> {
        PromptDialog promptDialog = new PromptDialog("注意", message_text);
        boolean isOk = promptDialog.showAndGet();
        String text = promptDialog.getText();
        callback.Continue(isOk, text);
    });
    return true;
}

和之前不太一樣的是,這裡需要在showAndGet之後,調用getText來獲取用戶輸入,並在callback.Continue(isOk, text)方法中傳入用戶的數據數據。最終效果如下:

源碼

w4ngzhen/intellij-jcef-plugin (github.com)

本次相關程式碼提交:support JsDialog