IDEA Web渲染外掛開發(二)— 自定義JsDialog

w4ngzhen發表於2021-10-12

《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

相關文章