fix(server): keep settings I/O off UI thread and bound dictionary rendering
This commit is contained in:
1 parent
b48dc82652
commit
06bba15d4f
19 files changed
+546
-148
No files matched your search
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"engine": {
|
||||
"repository": "metasequoiaime/MSIME-Engine",
|
||||
"commit": "6bd22549d6e275e401052c55cca6c57fe8408819"
|
||||
"commit": "403b4a4d72b0ab5110155aeb4a1f0c170730fc7b"
|
||||
}
|
||||
},
|
||||
"dictionary": {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include "settings/dictionary_manager.h"
|
||||
#include "settings/dictionary_page.h"
|
||||
#include <set>
|
||||
|
||||
#include "config/ime_config.h"
|
||||
#include "defines/defines.h"
|
||||
@@ -321,7 +323,21 @@ bool ValidateChineseEntry(const std::string &mode, const std::string &code, cons
|
||||
return true;
|
||||
}
|
||||
|
||||
json::object QueryChinese(const std::string &mode, const std::string &search)
|
||||
bool EnsureEnglishSchema()
|
||||
{
|
||||
// Installed dictionaries keep their schema for the lifetime of Settings.
|
||||
// Do not open a write transaction for every read-only search.
|
||||
static std::mutex mutex;
|
||||
static std::set<std::string> initialized;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const auto path = CommonUtils::get_ime_data_path() + "\\english.db";
|
||||
if (initialized.count(path)) return true;
|
||||
if (!EnglishDictionary::ensure_schema(path)) return false;
|
||||
initialized.insert(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
json::object QueryChinese(const std::string &mode, const std::string &search, const json::object &request)
|
||||
{
|
||||
std::string error;
|
||||
Db db = OpenDatabase("msime.db", error);
|
||||
@@ -334,14 +350,9 @@ json::object QueryChinese(const std::string &mode, const std::string &search)
|
||||
if (!normalized.empty())
|
||||
{
|
||||
const std::string table = quanpin::build_table_name(segments);
|
||||
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC", error);
|
||||
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC,value" + Paging::Sql(request), error);
|
||||
if (!stmt || !BindText(stmt.get(), 1, normalized)) return Result(false, "查询失败:" + error);
|
||||
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
|
||||
{
|
||||
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
|
||||
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
|
||||
{"weight", sqlite3_column_int(stmt.get(), 2)}});
|
||||
}
|
||||
return Paging::Read(stmt.get(), request);
|
||||
}
|
||||
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功");
|
||||
result["rows"] = std::move(rows);
|
||||
@@ -665,7 +676,7 @@ json::object ImportEnglish(const json::object &request)
|
||||
return Result(false, "文件内容为空");
|
||||
|
||||
std::string error;
|
||||
(void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db");
|
||||
if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败");
|
||||
Db db = OpenDatabase("english.db", error);
|
||||
if (!db) return Result(false, "打开英文词库失败:" + error);
|
||||
Stmt insert = Prepare(db.get(),
|
||||
@@ -795,22 +806,21 @@ json::object HandleEnglish(const json::object &request)
|
||||
const int weight = (std::max)(0, IntValue(request, "weight", 10));
|
||||
std::transform(word.begin(), word.end(), word.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
std::string error;
|
||||
(void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db");
|
||||
if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败");
|
||||
Db db = OpenDatabase("english.db", error);
|
||||
if (!db) return Result(false, "打开英文词库失败:" + error);
|
||||
if (action == "query")
|
||||
{
|
||||
Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word LIKE ?1 "
|
||||
"ORDER BY CASE WHEN word=?2 THEN 0 ELSE 1 END,weight DESC,length(word),word,display", error);
|
||||
const std::string pattern = word + "%";
|
||||
if (!stmt || !BindText(stmt.get(), 1, pattern) || !BindText(stmt.get(), 2, word)) return Result(false, "查询失败");
|
||||
json::array rows;
|
||||
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
|
||||
rows.push_back({{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
|
||||
{"display", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
|
||||
{"weight", sqlite3_column_int(stmt.get(), 2)}});
|
||||
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result;
|
||||
if (!IsAsciiWord(word)) return Result(false, "请输入英文单词前缀");
|
||||
std::string upper = word;
|
||||
++upper.back(); // ASCII prefix upper bound, compatible with the BINARY primary key.
|
||||
Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word>=?1 AND word<?3 "
|
||||
"ORDER BY CASE WHEN word=?2 THEN 0 ELSE 1 END,weight DESC,length(word),word,display" + Paging::Sql(request), error);
|
||||
if (!stmt || !BindText(stmt.get(), 1, word) || !BindText(stmt.get(), 2, word) || !BindText(stmt.get(), 3, upper))
|
||||
return Result(false, "查询失败:" + error);
|
||||
return Paging::Read(stmt.get(), request, true);
|
||||
}
|
||||
|
||||
if (!IsAsciiWord(word) || display.empty()) return Result(false, "英文单词仅支持英文字母、连字符和撇号,显示内容不能为空");
|
||||
std::string old_word = StringValue(request, "oldWord");
|
||||
const std::string old_display = StringValue(request, "oldDisplay");
|
||||
@@ -981,14 +991,9 @@ json::object HandleWubi(const json::object &request)
|
||||
{
|
||||
if (code.empty() || !std::all_of(code.begin(), code.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; }))
|
||||
return Result(false, "请输入合法的五笔编码");
|
||||
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC", error);
|
||||
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC,key,value" + Paging::Sql(request), error);
|
||||
if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error);
|
||||
json::array rows;
|
||||
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
|
||||
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
|
||||
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
|
||||
{"weight", sqlite3_column_int(stmt.get(), 2)}});
|
||||
json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result;
|
||||
return Paging::Read(stmt.get(), request);
|
||||
}
|
||||
if (code.empty() || word.empty() || !std::all_of(code.begin(), code.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; }))
|
||||
return Result(false, "五笔编码和词条不能为空,编码只能包含英文字母");
|
||||
@@ -1192,16 +1197,9 @@ json::object HandleQuickPhrase(const json::object &request)
|
||||
{
|
||||
if (!code.empty() && !valid_code(code)) return Result(false, "编码只能包含英文字母");
|
||||
Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM quick_parases WHERE key LIKE ?1 "
|
||||
"ORDER BY weight DESC,key,value", error);
|
||||
"ORDER BY weight DESC,key,value" + Paging::Sql(request), error);
|
||||
if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error);
|
||||
json::array rows;
|
||||
while (sqlite3_step(stmt.get()) == SQLITE_ROW)
|
||||
rows.push_back({{"code", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 0))},
|
||||
{"word", reinterpret_cast<const char *>(sqlite3_column_text(stmt.get(), 1))},
|
||||
{"weight", sqlite3_column_int(stmt.get(), 2)}});
|
||||
json::object result = Result(true, rows.empty() ? "没有找到快捷短语" : "查询成功");
|
||||
result["rows"] = std::move(rows);
|
||||
return result;
|
||||
return Paging::Read(stmt.get(), request);
|
||||
}
|
||||
|
||||
if (!valid_code(code) || phrase.empty()) return Result(false, "编码只能包含英文字母,短语不能为空");
|
||||
@@ -1266,7 +1264,7 @@ json::object HandleRequest(const json::object &request)
|
||||
if (dictionary == "wubi") return HandleWubi(request);
|
||||
if (dictionary == "quick") return HandleQuickPhrase(request);
|
||||
if (dictionary != "quanpin") return Result(false, "未知词库");
|
||||
if (action == "query") return QueryChinese(dictionary, StringValue(request, "code"));
|
||||
if (action == "query") return QueryChinese(dictionary, StringValue(request, "code"), request);
|
||||
if (action == "import") return ImportChinese(request);
|
||||
return MutateChinese(request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include <boost/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace SettingsDictionary::Paging
|
||||
{
|
||||
inline int Integer(const boost::json::object &request, const char *key, int fallback, int maximum)
|
||||
{
|
||||
const auto *value = request.if_contains(key);
|
||||
if (!value || !value->is_int64()) return fallback;
|
||||
return static_cast<int>((std::max)(int64_t{0}, (std::min)(value->as_int64(), int64_t{maximum})));
|
||||
}
|
||||
inline int Limit(const boost::json::object &request)
|
||||
{
|
||||
return (std::max)(1, Integer(request, "limit", 200, 200));
|
||||
}
|
||||
inline std::string Sql(const boost::json::object &request)
|
||||
{
|
||||
return " LIMIT " + std::to_string(Limit(request) + 1) + " OFFSET " +
|
||||
std::to_string(Integer(request, "offset", 0, 2147483647));
|
||||
}
|
||||
inline boost::json::object Read(sqlite3_stmt *statement, const boost::json::object &request, bool english = false)
|
||||
{
|
||||
namespace json = boost::json;
|
||||
json::array rows;
|
||||
int status;
|
||||
while ((status = sqlite3_step(statement)) == SQLITE_ROW)
|
||||
{
|
||||
const auto text = [statement](int column) {
|
||||
const auto *value = sqlite3_column_text(statement, column);
|
||||
return value ? reinterpret_cast<const char *>(value) : "";
|
||||
};
|
||||
rows.push_back({{english ? "word" : "code", text(0)}, {english ? "display" : "word", text(1)},
|
||||
{"weight", sqlite3_column_int(statement, 2)}});
|
||||
}
|
||||
if (status != SQLITE_DONE)
|
||||
return {{"ok", false}, {"message", "查询失败,请重试"}, {"rows", json::array{}}};
|
||||
const bool more = rows.size() > static_cast<size_t>(Limit(request));
|
||||
if (more) rows.pop_back();
|
||||
return {{"ok", true}, {"message", rows.empty() ? "没有找到词条" : "查询成功"},
|
||||
{"rows", std::move(rows)}, {"hasMore", more},
|
||||
{"offset", Integer(request, "offset", 0, 2147483647)}};
|
||||
}
|
||||
} // namespace SettingsDictionary::Paging
|
||||
@@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
// Work runs serially on one worker. Completions are explicitly drained by the
|
||||
// owner thread. Stop drains accepted work; no detached threads outlive the owner.
|
||||
class SerialTaskQueue
|
||||
{
|
||||
public:
|
||||
using Completion = std::function<void()>;
|
||||
using Task = std::function<Completion()>;
|
||||
SerialTaskQueue(Completion notify, Completion error, Completion cleanup = {})
|
||||
: notify_(std::move(notify)), error_(std::move(error)), cleanup_(std::move(cleanup)),
|
||||
thread_([this] { Run(); }) {}
|
||||
~SerialTaskQueue() { Stop(); thread_.join(); }
|
||||
bool Submit(Task task)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (stopping_) return false;
|
||||
tasks_.push_back(std::move(task));
|
||||
ready_.notify_one();
|
||||
return true;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
stopping_ = true;
|
||||
ready_.notify_one();
|
||||
}
|
||||
bool Stopped()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return stopped_;
|
||||
}
|
||||
void Drain(bool deliver = true)
|
||||
{
|
||||
std::deque<Completion> completed;
|
||||
{ std::lock_guard<std::mutex> lock(mutex_); completed.swap(completed_); }
|
||||
if (deliver) for (auto &completion : completed) completion();
|
||||
}
|
||||
private:
|
||||
void Run()
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
Task task;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
ready_.wait(lock, [this] { return stopping_ || !tasks_.empty(); });
|
||||
if (tasks_.empty()) break;
|
||||
task = std::move(tasks_.front()); tasks_.pop_front();
|
||||
}
|
||||
Completion result;
|
||||
try { result = task(); } catch (...) { result = error_; }
|
||||
if (result)
|
||||
{
|
||||
{ std::lock_guard<std::mutex> lock(mutex_); completed_.push_back(std::move(result)); }
|
||||
notify_();
|
||||
}
|
||||
}
|
||||
if (cleanup_) cleanup_();
|
||||
{ std::lock_guard<std::mutex> lock(mutex_); stopped_ = true; }
|
||||
notify_();
|
||||
}
|
||||
Completion notify_, error_, cleanup_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable ready_;
|
||||
std::deque<Task> tasks_;
|
||||
std::deque<Completion> completed_;
|
||||
bool stopping_ = false, stopped_ = false;
|
||||
std::thread thread_;
|
||||
};
|
||||
@@ -5,6 +5,9 @@
|
||||
#include "settings/settings_launcher.h"
|
||||
#include "settings/settings_splash.h"
|
||||
#include "settings/dictionary_manager.h"
|
||||
#include "settings/serial_task_queue.h"
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include "skin/candidate_skin_catalog.h"
|
||||
#include "utils/common_utils.h"
|
||||
#include "utils/single_instance.h"
|
||||
@@ -47,6 +50,7 @@ constexpr wchar_t kSingleInstanceMutex[] = L"Local\\MetasequoiaImeSettings.Singl
|
||||
constexpr UINT kActivateExistingWindow = WM_APP + 1;
|
||||
constexpr UINT kOpenAboutSection = WM_APP + 2;
|
||||
constexpr UINT kScanSkinCatalog = WM_APP + 3;
|
||||
constexpr UINT kWorkerCompleted = WM_APP + 4;
|
||||
constexpr UINT_PTR kConfigReloadTimer = 1;
|
||||
|
||||
ComPtr<ICoreWebView2Controller> g_controller;
|
||||
@@ -67,6 +71,14 @@ bool g_webview_content_started = false;
|
||||
HWND g_settings_hwnd = nullptr;
|
||||
std::optional<CandidateSkinCatalog::ScanResult> g_candidate_skin_catalog;
|
||||
uint64_t g_candidate_skin_catalog_revision = 0;
|
||||
std::unique_ptr<SerialTaskQueue> g_worker;
|
||||
bool g_closing = false;
|
||||
bool g_reload_pending = false;
|
||||
bool g_settings_light = false;
|
||||
std::wstring g_last_config_message;
|
||||
bool g_worker_com_initialized = false; // worker-only
|
||||
void CloseSettings(HWND hwnd);
|
||||
|
||||
|
||||
nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors &colors)
|
||||
{
|
||||
@@ -173,7 +185,7 @@ void ApplyWindowActivationAppearance()
|
||||
void ApplySettingsChromeTheme(HWND hwnd = nullptr)
|
||||
{
|
||||
const HWND target = hwnd ? hwnd : g_settings_hwnd;
|
||||
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
|
||||
const bool light = g_settings_light;
|
||||
if (target)
|
||||
{
|
||||
BOOL dark = light ? FALSE : TRUE;
|
||||
@@ -235,10 +247,8 @@ void ResetTitlebarHoverAfterVisibilityChange()
|
||||
})())JS", nullptr);
|
||||
}
|
||||
|
||||
void PostConfig(bool refresh_skin_catalog = false)
|
||||
std::wstring BuildConfigMessage(bool refresh_skin_catalog)
|
||||
{
|
||||
if (!g_webview)
|
||||
return;
|
||||
|
||||
const VoiceInputConfig &voice = GetConfiguredVoiceInput();
|
||||
const AiAssistantConfig &ai = GetConfiguredAiAssistant();
|
||||
@@ -430,9 +440,30 @@ void PostConfig(bool refresh_skin_catalog = false)
|
||||
payload["protocolVersion"] = metasequoia::webview::Version;
|
||||
const std::string serialized = payload.dump();
|
||||
if (!metasequoia::webview::Validate(json::parse(serialized), "server"))
|
||||
return;
|
||||
const std::wstring message = string_to_wstring(serialized);
|
||||
g_webview->PostWebMessageAsJson(message.c_str());
|
||||
throw std::runtime_error("Invalid config snapshot");
|
||||
return string_to_wstring(serialized);
|
||||
}
|
||||
|
||||
SerialTaskQueue::Completion ConfigCompletion(bool refresh_skin_catalog = false)
|
||||
{
|
||||
auto message = BuildConfigMessage(refresh_skin_catalog);
|
||||
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
|
||||
return [message = std::move(message), light] {
|
||||
g_settings_light = light;
|
||||
ApplySettingsChromeTheme();
|
||||
SettingsSplash::SetTheme(light);
|
||||
if (g_webview && message != g_last_config_message)
|
||||
{
|
||||
if (SUCCEEDED(g_webview->PostWebMessageAsJson(message.c_str())))
|
||||
g_last_config_message = message;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void PostConfig(bool refresh_skin_catalog = false)
|
||||
{
|
||||
if (!g_worker || g_closing) return;
|
||||
g_worker->Submit([refresh_skin_catalog] { return ConfigCompletion(refresh_skin_catalog); });
|
||||
}
|
||||
|
||||
void PostWindowState(HWND hwnd)
|
||||
@@ -699,6 +730,7 @@ bool CopyTextToClipboard(HWND hwnd, const std::wstring &text)
|
||||
|
||||
void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
|
||||
{
|
||||
if (g_closing || !g_worker) return;
|
||||
wil::unique_cotaskmem_string raw;
|
||||
if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw)
|
||||
return;
|
||||
@@ -733,7 +765,7 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
|
||||
if (command == "minimize") ShowWindow(hwnd, SW_MINIMIZE);
|
||||
else if (command == "maximize") ShowWindow(hwnd, SW_MAXIMIZE);
|
||||
else if (command == "restore") ShowWindow(hwnd, SW_RESTORE);
|
||||
else if (command == "close") DestroyWindow(hwnd);
|
||||
else if (command == "close") CloseSettings(hwnd);
|
||||
}
|
||||
else if (type == "maximizeButtonRect")
|
||||
{
|
||||
@@ -757,8 +789,11 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
|
||||
}
|
||||
else if (type == "skinCatalogRequest")
|
||||
{
|
||||
PostConfig(true);
|
||||
NotifyImeServerCandidateSkinRefresh();
|
||||
g_worker->Submit([] {
|
||||
auto completion = ConfigCompletion(true);
|
||||
NotifyImeServerCandidateSkinRefresh();
|
||||
return completion;
|
||||
});
|
||||
}
|
||||
else if (type == "openSkinDirectory")
|
||||
{
|
||||
@@ -768,13 +803,17 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
|
||||
std::filesystem::create_directories(skins, ec);
|
||||
if (!ec) ShellExecuteW(hwnd, L"open", skins.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
}
|
||||
else if (type == "configUpdate" && ApplyConfigUpdate(value.at("data").as_object()))
|
||||
else if (type == "configUpdate")
|
||||
{
|
||||
const auto &data = value.at("data").as_object();
|
||||
const std::string path = json::value_to<std::string>(data.at("path"));
|
||||
if (path == "appearance.theme_mode" || path == "appearance.theme_settings")
|
||||
ApplySettingsChromeTheme(hwnd);
|
||||
PostConfig();
|
||||
const auto data = value.at("data").as_object();
|
||||
g_worker->Submit([data] {
|
||||
const bool saved = ApplyConfigUpdate(data);
|
||||
auto completion = ConfigCompletion();
|
||||
return [saved, completion = std::move(completion)] {
|
||||
completion();
|
||||
if (!saved) MessageBoxW(g_settings_hwnd, L"设置保存失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING);
|
||||
};
|
||||
});
|
||||
}
|
||||
else if (type == "openKeyboardPanel")
|
||||
{
|
||||
@@ -796,16 +835,24 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args)
|
||||
}
|
||||
else if (type == "dictionaryRequest")
|
||||
{
|
||||
const auto &data = value.at("data").as_object();
|
||||
json::value response = SettingsDictionary::HandleRequest(data);
|
||||
response.as_object()["type"] = "dictionaryResponse";
|
||||
if (const auto *request_id = data.if_contains("requestId"); request_id && request_id->is_string())
|
||||
response.as_object()["requestId"] = json::value_to<std::string>(*request_id);
|
||||
response.as_object()["protocolVersion"] = metasequoia::webview::Version;
|
||||
if (!metasequoia::webview::Validate(response, "server"))
|
||||
return;
|
||||
const std::wstring message = string_to_wstring(json::serialize(response));
|
||||
g_webview->PostWebMessageAsJson(message.c_str());
|
||||
const auto data = value.at("data").as_object();
|
||||
g_worker->Submit([data]() -> SerialTaskQueue::Completion {
|
||||
json::value response;
|
||||
try { response = SettingsDictionary::HandleRequest(data); }
|
||||
catch (...) { response = json::object{{"ok", false}, {"message", "词库操作失败,请重试"}, {"rows", json::array{}}}; }
|
||||
auto &object = response.as_object();
|
||||
object["type"] = "dictionaryResponse";
|
||||
object["requestId"] = data.at("requestId");
|
||||
object["action"] = data.at("action");
|
||||
object["dictionary"] = data.at("dictionary");
|
||||
object["protocolVersion"] = metasequoia::webview::Version;
|
||||
if (!metasequoia::webview::Validate(response, "server"))
|
||||
throw std::runtime_error("Invalid dictionary response");
|
||||
auto message = string_to_wstring(json::serialize(response));
|
||||
return [message = std::move(message)] {
|
||||
if (g_webview) g_webview->PostWebMessageAsJson(message.c_str());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (const std::exception &)
|
||||
@@ -883,6 +930,7 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC
|
||||
g_webview->add_ContentLoading(
|
||||
Callback<ICoreWebView2ContentLoadingEventHandler>(
|
||||
[](ICoreWebView2 *, ICoreWebView2ContentLoadingEventArgs *) -> HRESULT {
|
||||
g_last_config_message.clear();
|
||||
g_webview_content_started = true;
|
||||
SettingsSplash::Dismiss();
|
||||
return S_OK;
|
||||
@@ -1027,12 +1075,24 @@ int TopNonClientInset(HWND hwnd)
|
||||
|
||||
void ActivateWindow(HWND hwnd)
|
||||
{
|
||||
if (g_closing) return;
|
||||
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
|
||||
else ShowWindow(hwnd, SW_SHOW);
|
||||
SetForegroundWindow(hwnd);
|
||||
SetFocus(hwnd);
|
||||
}
|
||||
|
||||
void CloseSettings(HWND hwnd)
|
||||
{
|
||||
if (g_closing) return;
|
||||
g_closing = true;
|
||||
KillTimer(hwnd, kConfigReloadTimer);
|
||||
SettingsSplash::Dismiss();
|
||||
ShowWindow(hwnd, SW_HIDE);
|
||||
if (g_worker) g_worker->Stop();
|
||||
else DestroyWindow(hwnd);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_param)
|
||||
{
|
||||
switch (message)
|
||||
@@ -1138,7 +1198,7 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
{
|
||||
RECT client{};
|
||||
GetClientRect(hwnd, &client);
|
||||
const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
|
||||
const bool light = g_settings_light;
|
||||
HBRUSH brush = CreateSolidBrush(light ? RGB(243, 243, 243) : RGB(32, 32, 32));
|
||||
FillRect(reinterpret_cast<HDC>(w_param), &client, brush);
|
||||
DeleteObject(brush);
|
||||
@@ -1168,16 +1228,33 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa
|
||||
break;
|
||||
}
|
||||
case WM_TIMER:
|
||||
if (w_param == kConfigReloadTimer && ReloadImeConfigIfChanged())
|
||||
if (w_param == kConfigReloadTimer && g_worker && !g_closing && !g_reload_pending)
|
||||
{
|
||||
ApplySettingsChromeTheme(hwnd);
|
||||
PostConfig();
|
||||
g_reload_pending = true;
|
||||
g_worker->Submit([] {
|
||||
SerialTaskQueue::Completion completion;
|
||||
try { if (ReloadImeConfigIfChanged()) completion = ConfigCompletion(); }
|
||||
catch (...) { /* Retry on the next timer tick; do not poison the queue. */ }
|
||||
return [completion = std::move(completion)] {
|
||||
g_reload_pending = false;
|
||||
if (completion) completion();
|
||||
};
|
||||
});
|
||||
}
|
||||
return 0;
|
||||
case kWorkerCompleted:
|
||||
if (g_worker)
|
||||
{
|
||||
g_worker->Drain(!g_closing);
|
||||
if (g_closing && g_worker->Stopped()) DestroyWindow(hwnd);
|
||||
}
|
||||
return 0;
|
||||
case WM_CLOSE:
|
||||
DestroyWindow(hwnd);
|
||||
CloseSettings(hwnd);
|
||||
return 0;
|
||||
case WM_DESTROY:
|
||||
g_closing = true;
|
||||
if (g_worker) g_worker->Stop();
|
||||
KillTimer(hwnd, kConfigReloadTimer);
|
||||
SettingsSplash::Dismiss();
|
||||
g_webview.Reset();
|
||||
@@ -1214,7 +1291,6 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
|
||||
return 0;
|
||||
}
|
||||
|
||||
InitImeConfig();
|
||||
WNDCLASSEXW window_class{sizeof(window_class)};
|
||||
window_class.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
|
||||
window_class.lpfnWndProc = WindowProc;
|
||||
@@ -1235,6 +1311,14 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
|
||||
x, y, width, height, nullptr, nullptr, instance, nullptr);
|
||||
if (!hwnd) return 1;
|
||||
g_settings_hwnd = hwnd;
|
||||
g_worker = std::make_unique<SerialTaskQueue>([hwnd] { PostMessageW(hwnd, kWorkerCompleted, 0, 0); },
|
||||
[] { MessageBoxW(g_settings_hwnd, L"设置操作失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING); },
|
||||
[] { if (g_worker_com_initialized) CoUninitialize(); });
|
||||
g_worker->Submit([] {
|
||||
g_worker_com_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
|
||||
InitImeConfig();
|
||||
return ConfigCompletion();
|
||||
});
|
||||
|
||||
DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND;
|
||||
DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE;
|
||||
@@ -1254,7 +1338,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
|
||||
InitWebView(hwnd);
|
||||
ShowWindow(hwnd, show_command == SW_HIDE ? SW_SHOWNORMAL : show_command);
|
||||
UpdateWindow(hwnd);
|
||||
SettingsSplash::Show(hwnd);
|
||||
SettingsSplash::Show(hwnd, g_settings_light);
|
||||
if (g_webview_content_started)
|
||||
SettingsSplash::Dismiss();
|
||||
|
||||
@@ -1264,6 +1348,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
g_worker.reset(); // Drain accepted writes even if the window was destroyed externally.
|
||||
CoUninitialize();
|
||||
return static_cast<int>(message.wParam);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "settings/settings_splash.h"
|
||||
#include "config/ime_config.h"
|
||||
|
||||
#include <d2d1.h>
|
||||
#include <d2d1helper.h>
|
||||
@@ -46,11 +45,6 @@ bool g_close_hover = false;
|
||||
float g_phase = 0.0f;
|
||||
ULONGLONG g_last_tick = 0;
|
||||
|
||||
bool IsLightTheme()
|
||||
{
|
||||
return ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light";
|
||||
}
|
||||
|
||||
void ReleaseDeviceResources()
|
||||
{
|
||||
g_text_brush.Reset();
|
||||
@@ -76,7 +70,7 @@ void ApplyNativeChrome(HWND hwnd)
|
||||
DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner));
|
||||
|
||||
const BOOL dark = IsLightTheme() ? FALSE : TRUE;
|
||||
const BOOL dark = g_light ? FALSE : TRUE;
|
||||
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark));
|
||||
|
||||
DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE;
|
||||
@@ -145,7 +139,6 @@ HRESULT EnsureRenderTarget()
|
||||
D2D1::HwndRenderTargetProperties(g_hwnd, D2D1::SizeU(width, height)), &g_rt);
|
||||
if (FAILED(hr)) return hr;
|
||||
|
||||
g_light = IsLightTheme();
|
||||
const D2D1_COLOR_F text = g_light ? D2D1::ColorF(0x1A1A1A) : D2D1::ColorF(0xF0F0F0);
|
||||
const D2D1_COLOR_F muted = g_light ? D2D1::ColorF(0x6B6B6B) : D2D1::ColorF(0xA8A8A8);
|
||||
const D2D1_COLOR_F accent = g_light ? D2D1::ColorF(0x7A3E91) : D2D1::ColorF(0xD88BDE);
|
||||
@@ -342,8 +335,17 @@ ATOM RegisterSplashClass(HINSTANCE instance)
|
||||
|
||||
namespace SettingsSplash
|
||||
{
|
||||
bool Show(HWND owner)
|
||||
void SetTheme(bool light)
|
||||
{
|
||||
if (g_light == light) return;
|
||||
g_light = light;
|
||||
ReleaseDeviceResources();
|
||||
if (g_hwnd) { ApplyNativeChrome(g_hwnd); InvalidateRect(g_hwnd, nullptr, FALSE); }
|
||||
}
|
||||
|
||||
bool Show(HWND owner, bool light)
|
||||
{
|
||||
g_light = light;
|
||||
if (!owner) return false;
|
||||
if (g_hwnd && IsWindow(g_hwnd))
|
||||
{
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// Uses the same DWM rounded-corner chrome so edges match the host window.
|
||||
namespace SettingsSplash
|
||||
{
|
||||
bool Show(HWND owner);
|
||||
bool Show(HWND owner, bool light);
|
||||
void SetTheme(bool light);
|
||||
void Dismiss();
|
||||
void SyncToOwner();
|
||||
bool IsVisible();
|
||||
|
||||
@@ -30,6 +30,7 @@ set(TEST_SOURCE_FILES
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_text_policy.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_window_template.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_inline_protocol.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_settings_performance.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_size_estimator.cpp"
|
||||
"${CMAKE_SOURCE_DIR}/src/utils/candidate_size_estimator.cpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_ipc_protocol_constants.cpp"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "tests/includes/test_framework.h"
|
||||
#include "settings/dictionary_page.h"
|
||||
#include "settings/serial_task_queue.h"
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
#include <set>
|
||||
|
||||
TEST_CASE(settings_worker_preserves_order_and_drains_on_close)
|
||||
{
|
||||
const auto owner = std::this_thread::get_id();
|
||||
std::promise<void> started, release;
|
||||
auto wait = release.get_future().share();
|
||||
std::vector<int> work, delivered;
|
||||
bool off_ui = false;
|
||||
{
|
||||
SerialTaskQueue queue([] {}, [] {});
|
||||
REQUIRE(queue.Submit([&] {
|
||||
off_ui = std::this_thread::get_id() != owner;
|
||||
started.set_value(); wait.wait(); work.push_back(1);
|
||||
return [&] { delivered.push_back(1); };
|
||||
}));
|
||||
const auto status = started.get_future().wait_for(std::chrono::seconds(5));
|
||||
// Release even if scheduling failed so a failed test cannot deadlock destruction.
|
||||
if (status != std::future_status::ready) release.set_value();
|
||||
REQUIRE(status == std::future_status::ready);
|
||||
REQUIRE(queue.Submit([&] { work.push_back(2); return [&] { delivered.push_back(2); }; }));
|
||||
queue.Stop();
|
||||
REQUIRE(!queue.Submit([] { return SerialTaskQueue::Completion{}; }));
|
||||
queue.Drain(false);
|
||||
REQUIRE(delivered.empty());
|
||||
release.set_value();
|
||||
}
|
||||
REQUIRE(off_ui);
|
||||
REQUIRE_EQ(work, (std::vector<int>{1, 2}));
|
||||
REQUIRE(delivered.empty()); // Destruction never invokes a callback into a closed UI.
|
||||
}
|
||||
|
||||
TEST_CASE(settings_worker_delivers_results_only_on_owner_and_survives_errors)
|
||||
{
|
||||
const auto owner = std::this_thread::get_id();
|
||||
std::promise<void> done;
|
||||
int errors = 0, delivered = 0;
|
||||
SerialTaskQueue queue([] {}, [&] { REQUIRE(std::this_thread::get_id() == owner); ++errors; }, [&] { done.set_value(); });
|
||||
queue.Submit([]() -> SerialTaskQueue::Completion { throw std::runtime_error("test"); });
|
||||
queue.Submit([&] { return [&] { REQUIRE(std::this_thread::get_id() == owner); ++delivered; }; });
|
||||
queue.Stop();
|
||||
REQUIRE(done.get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
|
||||
REQUIRE_EQ(errors, 0); REQUIRE_EQ(delivered, 0);
|
||||
queue.Drain();
|
||||
REQUIRE_EQ(errors, 1); REQUIRE_EQ(delivered, 1);
|
||||
}
|
||||
|
||||
TEST_CASE(dictionary_pages_cover_ties_without_duplicates_or_unbounded_results)
|
||||
{
|
||||
namespace json = boost::json;
|
||||
sqlite3 *db = nullptr;
|
||||
REQUIRE(sqlite3_open(":memory:", &db) == SQLITE_OK);
|
||||
struct Close { sqlite3 *db; ~Close() { sqlite3_close(db); } } close{db};
|
||||
REQUIRE(sqlite3_exec(db, "CREATE TABLE words(code TEXT,word TEXT PRIMARY KEY,weight INTEGER)", nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
for (int i = 0; i < 205; ++i)
|
||||
{
|
||||
const auto sql = "INSERT INTO words VALUES('a','word" + std::to_string(i) + "',100)";
|
||||
REQUIRE(sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK);
|
||||
}
|
||||
std::set<std::string> seen;
|
||||
for (int offset : {0, 100, 200})
|
||||
{
|
||||
json::object request{{"offset", offset}, {"limit", 100}};
|
||||
const auto sql = "SELECT code,word,weight FROM words ORDER BY weight DESC,code,word" + SettingsDictionary::Paging::Sql(request);
|
||||
sqlite3_stmt *stmt = nullptr;
|
||||
REQUIRE(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK);
|
||||
auto result = SettingsDictionary::Paging::Read(stmt, request);
|
||||
sqlite3_finalize(stmt);
|
||||
REQUIRE(result.at("ok").as_bool());
|
||||
REQUIRE_EQ(result.at("offset").as_int64(), offset);
|
||||
REQUIRE_EQ(result.at("hasMore").as_bool(), offset < 200);
|
||||
REQUIRE_EQ(result.at("rows").as_array().size(), offset < 200 ? size_t{100} : size_t{5});
|
||||
for (const auto &row : result.at("rows").as_array())
|
||||
REQUIRE(seen.insert(json::value_to<std::string>(row.at("word"))).second);
|
||||
}
|
||||
REQUIRE_EQ(seen.size(), size_t{205});
|
||||
REQUIRE_EQ(SettingsDictionary::Paging::Limit({{"limit", 999999}}), 200);
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
import { onHostMessage } from '../utils/host-messages';
|
||||
import { serializeHostMessage } from '../../../../shared/messages';
|
||||
import { applyCandidateArrange, applyDropdownValue, applyToggleState } from './shared';
|
||||
import { applyCandidateArrange, applyDropdownValue as applyDropdown, applyToggleState as applyToggle } from './shared';
|
||||
|
||||
let lastSnapshot: Record<string, any> | null = null;
|
||||
const readyModules = new Set<string>();
|
||||
|
||||
export function notifySettingsModuleReady(name: string): void {
|
||||
readyModules.add(name);
|
||||
if (lastSnapshot) applyConfigData(lastSnapshot);
|
||||
if (lastSnapshot) applyConfigData(lastSnapshot, name);
|
||||
}
|
||||
|
||||
function applyConfigData(data: Record<string, any>): void {
|
||||
function applyConfigData(data: Record<string, any>, target?: string): void {
|
||||
const applies = (name: string) => readyModules.has(name) && (!target || target === name);
|
||||
const findElement = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
return !target || (element && document.getElementById(target)?.contains(element)) ? element : null;
|
||||
};
|
||||
const applyToggleState = (id: string, value: boolean) => { if (findElement(id)) applyToggle(id, value); };
|
||||
const applyDropdownValue = (id: string, menu: string, value: string | undefined) => {
|
||||
if (findElement(id)) applyDropdown(id, menu, value);
|
||||
};
|
||||
lastSnapshot = data;
|
||||
|
||||
applyCandidateArrange(data?.appearance?.candidate_window_layout);
|
||||
if (!target || target === 'appearance' || target === 'skin') applyCandidateArrange(data?.appearance?.candidate_window_layout);
|
||||
|
||||
if (readyModules.has('appearance')) {
|
||||
if (applies('appearance')) {
|
||||
void import('./appearance').then((module) => {
|
||||
if (data !== lastSnapshot) return;
|
||||
module.applyAppearanceConfig(
|
||||
data?.appearance?.candidate_window_preedit_style,
|
||||
data?.appearance?.tsf_preedit_style,
|
||||
@@ -57,8 +67,9 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
});
|
||||
}
|
||||
|
||||
if (readyModules.has('skin')) {
|
||||
if (!target || target === 'skin') {
|
||||
void import('./skin').then((module) => {
|
||||
if (data !== lastSnapshot) return;
|
||||
module.applyCandidateSkinCatalog(
|
||||
data?.appearance?.external_candidate_skins,
|
||||
data?.appearance?.candidate_skin_scan_issues,
|
||||
@@ -102,7 +113,7 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
}
|
||||
if (typeof data?.general?.candidate_translations === 'boolean') {
|
||||
applyToggleState('candidateTranslationsToggleBtn', data.general.candidate_translations);
|
||||
document.getElementById('candidateTranslationApiOptions')?.classList.toggle(
|
||||
findElement('candidateTranslationApiOptions')?.classList.toggle(
|
||||
'is-disabled',
|
||||
!data.general.candidate_translations
|
||||
);
|
||||
@@ -144,27 +155,27 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
applyToggleState('clipboardHistoryToggleBtn', data.utility.clipboard_history);
|
||||
}
|
||||
if (typeof data?.general?.paging_minus_equal === 'boolean') {
|
||||
const checkbox = document.getElementById('pagingMinusEqualCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('pagingMinusEqualCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.paging_minus_equal;
|
||||
}
|
||||
if (typeof data?.general?.paging_tab === 'boolean') {
|
||||
const checkbox = document.getElementById('pagingTabCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('pagingTabCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.paging_tab;
|
||||
}
|
||||
if (typeof data?.general?.paging_comma_period === 'boolean') {
|
||||
const checkbox = document.getElementById('pagingCommaPeriodCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('pagingCommaPeriodCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.paging_comma_period;
|
||||
}
|
||||
if (typeof data?.general?.paging_brackets === 'boolean') {
|
||||
const checkbox = document.getElementById('pagingBracketsCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('pagingBracketsCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.paging_brackets;
|
||||
}
|
||||
if (typeof data?.general?.paging_page_up_down === 'boolean') {
|
||||
const checkbox = document.getElementById('pagingPageUpDownCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('pagingPageUpDownCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.paging_page_up_down;
|
||||
}
|
||||
if (typeof data?.general?.candidate_arrow_navigation === 'boolean') {
|
||||
const checkbox = document.getElementById('candidateArrowNavigationCheckbox') as HTMLInputElement | null;
|
||||
const checkbox = findElement('candidateArrowNavigationCheckbox') as HTMLInputElement | null;
|
||||
if (checkbox) checkbox.checked = data.general.candidate_arrow_navigation;
|
||||
}
|
||||
if (typeof data?.helpcode?.show_sp_helpcode_in_candidate_window === 'boolean') {
|
||||
@@ -215,8 +226,9 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (readyModules.has('input') || readyModules.has('helpcode') || readyModules.has('tools-settings')) {
|
||||
if (applies('input') || applies('helpcode') || applies('tools-settings')) {
|
||||
void import('./input').then((module) => {
|
||||
if (data !== lastSnapshot) return;
|
||||
module.applyInputConfig(
|
||||
data?.input?.mode,
|
||||
data?.input?.schema,
|
||||
@@ -236,14 +248,15 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
module.applyCustomTranslationConfig(data?.custom_translation);
|
||||
});
|
||||
}
|
||||
if (readyModules.has('voice') && data?.voice_input && typeof data.voice_input === 'object') {
|
||||
void import('./voice').then((module) => module.applyVoiceConfig(data.voice_input));
|
||||
if (applies('voice') && data?.voice_input && typeof data.voice_input === 'object') {
|
||||
void import('./voice').then((module) => { if (data === lastSnapshot) module.applyVoiceConfig(data.voice_input); });
|
||||
}
|
||||
if (readyModules.has('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') {
|
||||
void import('./ai-settings').then((module) => module.applyAiConfig(data.ai_assistant));
|
||||
if (applies('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') {
|
||||
void import('./ai-settings').then((module) => { if (data === lastSnapshot) module.applyAiConfig(data.ai_assistant); });
|
||||
}
|
||||
if (readyModules.has('floating-toolbar')) {
|
||||
if (applies('floating-toolbar')) {
|
||||
void import('./floating-toolbar').then((module) => {
|
||||
if (data !== lastSnapshot) return;
|
||||
module.applyFloatingToolbarItemsConfig({
|
||||
fullwidth: data?.general?.floating_toolbar_fullwidth,
|
||||
punctuation: data?.general?.floating_toolbar_punctuation,
|
||||
@@ -258,8 +271,8 @@ function applyConfigData(data: Record<string, any>): void {
|
||||
);
|
||||
});
|
||||
}
|
||||
if (readyModules.has('shortcut')) {
|
||||
void import('./shortcut').then((module) => module.applyShortcutConfig(data?.keybindings));
|
||||
if (applies('shortcut')) {
|
||||
void import('./shortcut').then((module) => { if (data === lastSnapshot) module.applyShortcutConfig(data?.keybindings); });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
|
||||
import { onHostMessage } from '../utils/host-messages';
|
||||
import type { SettingsMessage } from '../../../../shared/messages';
|
||||
type DictionaryRequest = Extract<SettingsMessage, { type: 'dictionaryRequest' }>['data'];
|
||||
@@ -9,14 +10,19 @@ let dictionary: DictionaryType = 'quanpin';
|
||||
let editing: DictionaryRow | null = null;
|
||||
let requestCounter = 0;
|
||||
let lastQuery = '';
|
||||
let lastAction = 'query';
|
||||
const pendingRequests = new Map<string, { action: DictionaryRequest['action']; dictionary: string }>();
|
||||
let latestQueryId = '';
|
||||
let pager: ReturnType<typeof createDictionaryPager> | undefined;
|
||||
let toastTimer: number | null = null;
|
||||
|
||||
function post(action: DictionaryRequest['action'], data: Partial<Omit<DictionaryRequest, 'action' | 'requestId'>> = {}): void {
|
||||
lastAction = action;
|
||||
const requestId = `dict-${++requestCounter}`;
|
||||
const targetDictionary = dictionary;
|
||||
pendingRequests.set(requestId, { action, dictionary: targetDictionary });
|
||||
if (action === 'query') { latestQueryId = requestId; pager?.loading(); }
|
||||
window.chrome?.webview?.postMessage(serializeHostMessage({
|
||||
type: 'dictionaryRequest',
|
||||
data: { requestId: `dict-${++requestCounter}`, dictionary, action, ...data }
|
||||
data: { requestId, dictionary, action, ...data }
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -52,7 +58,7 @@ function renderRows(rows: DictionaryRow[]): void {
|
||||
const tr = document.createElement('tr');
|
||||
const indexCell = document.createElement('td');
|
||||
indexCell.className = 'dict-index-column';
|
||||
indexCell.textContent = String(index + 1);
|
||||
indexCell.textContent = String((pager?.offset ?? 0) + index + 1);
|
||||
tr.appendChild(indexCell);
|
||||
const values = dictionary === 'english'
|
||||
? [row.word, row.display ?? row.word, String(row.weight ?? 0)]
|
||||
@@ -90,6 +96,8 @@ function syncTableHeaderWidth(): void {
|
||||
}
|
||||
|
||||
function updateMode(): void {
|
||||
latestQueryId = '';
|
||||
pager?.reset();
|
||||
const english = dictionary === 'english';
|
||||
const quanpin = dictionary === 'quanpin';
|
||||
const search = document.getElementById('dictSearch') as HTMLInputElement;
|
||||
@@ -129,10 +137,10 @@ function closeDialog(): void {
|
||||
const modal = document.getElementById('dictModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null;
|
||||
}
|
||||
|
||||
function query(): void {
|
||||
lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim();
|
||||
function query(offset = 0): void {
|
||||
if (offset === 0) lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim();
|
||||
if (!lastQuery) { showToast('请输入查询内容', false); return; }
|
||||
post('query', dictionary === 'english' ? { word: lastQuery } : { code: lastQuery });
|
||||
post('query', { ...(dictionary === 'english' ? { word: lastQuery } : { code: lastQuery }), offset, limit: DICTIONARY_PAGE_SIZE });
|
||||
}
|
||||
|
||||
function downloadExport(content: string, filename: string): void {
|
||||
@@ -151,11 +159,13 @@ function downloadExport(content: string, filename: string): void {
|
||||
}
|
||||
|
||||
export function setupDictionary(): void {
|
||||
const table = document.querySelector<HTMLElement>('.dict-table-wrap');
|
||||
if (table) pager = createDictionaryPager(table, offset => query(offset));
|
||||
document.querySelectorAll<HTMLButtonElement>('.dict-tab').forEach((tab) => tab.addEventListener('click', () => {
|
||||
document.querySelector('.dict-tab.active')?.classList.remove('active'); tab.classList.add('active');
|
||||
dictionary = tab.dataset.dictionary as DictionaryType; updateMode();
|
||||
}));
|
||||
document.getElementById('dictSearchButton')?.addEventListener('click', query);
|
||||
document.getElementById('dictSearchButton')?.addEventListener('click', () => query());
|
||||
document.getElementById('dictSearch')?.addEventListener('keydown', (event) => { if ((event as KeyboardEvent).key === 'Enter') query(); });
|
||||
document.getElementById('dictAddButton')?.addEventListener('click', () => openDialog());
|
||||
document.getElementById('dictImportButton')?.addEventListener('click', () => {
|
||||
@@ -224,6 +234,17 @@ export function setupDictionary(): void {
|
||||
});
|
||||
onHostMessage('dictionaryResponse', payload => {
|
||||
if (!payload.requestId.startsWith('dict-')) return;
|
||||
const context = pendingRequests.get(payload.requestId);
|
||||
if (!context) return;
|
||||
pendingRequests.delete(payload.requestId);
|
||||
if (context.action === 'query' && payload.requestId !== latestQueryId) return;
|
||||
if (context.action === 'query' && context.dictionary !== dictionary) return;
|
||||
const lastAction = context.action;
|
||||
if (lastAction === 'query') {
|
||||
if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true);
|
||||
else pager?.failed();
|
||||
}
|
||||
|
||||
const isImport = lastAction === 'import' || lastAction === 'importHans';
|
||||
const isExport = lastAction === 'export';
|
||||
if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') {
|
||||
@@ -234,8 +255,8 @@ export function setupDictionary(): void {
|
||||
Boolean(payload.ok),
|
||||
isImport ? 5600 : 3200,
|
||||
);
|
||||
if (Array.isArray(payload.rows)) renderRows(payload.rows);
|
||||
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); if (lastQuery) query(); }
|
||||
if (lastAction === 'query' && payload.ok) renderRows(payload.rows);
|
||||
if (payload.ok && lastAction !== 'query' && !isExport && context.dictionary === dictionary) { closeDialog(); if (lastQuery) query(pager?.offset ?? 0); }
|
||||
});
|
||||
updateMode();
|
||||
}
|
||||
@@ -6,7 +6,6 @@ const BACKGROUND_MODULES = [
|
||||
'helpcode',
|
||||
'shortcut',
|
||||
'dict',
|
||||
'skin',
|
||||
'voice',
|
||||
'screenkb-settings',
|
||||
'handwriting-settings',
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
let skinInitialized = false;
|
||||
let catalogKey = '';
|
||||
let styleGeneration = 0;
|
||||
const styleLoads = new Map<string, Promise<void>>();
|
||||
import { serializeHostMessage } from '../../../../shared/messages';
|
||||
import { loadHTML } from '../utils/common-utils';
|
||||
import ftbHTML from '../../../../ftb/default.html?raw';
|
||||
@@ -138,26 +142,30 @@ function candidatePreviewCss(skin: ExternalSkin): string {
|
||||
return css;
|
||||
}
|
||||
|
||||
async function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise<void> {
|
||||
if (!stylesheet) return;
|
||||
if (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId)) return;
|
||||
try {
|
||||
const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope');
|
||||
const scoped = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`;
|
||||
let style = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.dataset.externalSkinStyle = skin.id;
|
||||
document.head.appendChild(style);
|
||||
function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise<void> {
|
||||
if (!stylesheet || (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId))) return Promise.resolve();
|
||||
const pending = styleLoads.get(styleId);
|
||||
if (pending) return pending;
|
||||
const generation = styleGeneration;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope');
|
||||
if (generation !== styleGeneration) return;
|
||||
let style = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
if (!style) {
|
||||
style = document.createElement('style'); style.id = styleId;
|
||||
style.dataset.externalSkinStyle = skin.id; document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`;
|
||||
loadedExternalStyleIds.add(styleId);
|
||||
} catch {
|
||||
// Keep the inherited built-in preview if a stylesheet is unavailable.
|
||||
}
|
||||
style.textContent = scoped;
|
||||
loadedExternalStyleIds.add(styleId);
|
||||
} catch {
|
||||
// Preview stays on the inherited built-in base if the stylesheet cannot load.
|
||||
}
|
||||
})().finally(() => { if (styleLoads.get(styleId) === task) styleLoads.delete(styleId); });
|
||||
styleLoads.set(styleId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function injectGeneratedCandidateCss(skin: ExternalSkin, force = false): void {
|
||||
@@ -183,6 +191,8 @@ async function ensureExternalSkinStyle(skin: ExternalSkin, force = false): Promi
|
||||
}
|
||||
|
||||
function resetExternalSkinStyles(): void {
|
||||
styleGeneration++;
|
||||
styleLoads.clear();
|
||||
document.querySelectorAll('style[data-external-skin-style]').forEach((node) => node.remove());
|
||||
loadedExternalStyleIds.clear();
|
||||
}
|
||||
@@ -302,6 +312,7 @@ function bindSkinSwitch(element: HTMLElement, skinId: string, enabled: boolean):
|
||||
}
|
||||
|
||||
function renderExternalSkins(): void {
|
||||
if (!skinInitialized) return;
|
||||
const list = document.getElementById('externalSkinList');
|
||||
const empty = document.getElementById('externalSkinEmpty');
|
||||
const directory = document.getElementById('externalSkinDirectory');
|
||||
@@ -389,7 +400,7 @@ function renderExternalSkins(): void {
|
||||
previews.append(horizontal, vertical, toolbar);
|
||||
|
||||
card.append(header, previews);
|
||||
void ensureExternalSkinStyle(skin, true).then(() => applyExternalCardTheme(skin));
|
||||
void ensureExternalSkinStyle(skin).then(() => applyExternalCardTheme(skin));
|
||||
return card;
|
||||
}));
|
||||
empty.textContent = catalogScanned ? '没有发现外部皮肤。' : '尚未扫描。点击“刷新皮肤”读取皮肤目录。';
|
||||
@@ -416,6 +427,9 @@ function renderExternalSkins(): void {
|
||||
export function applyCandidateSkinCatalog(
|
||||
skins: unknown, issues: unknown, directory: unknown, scanned: unknown, revision: unknown
|
||||
): void {
|
||||
const key = JSON.stringify([skins, issues, directory, scanned, revision]);
|
||||
if (key === catalogKey) return;
|
||||
catalogKey = key;
|
||||
const nextRevision = typeof revision === 'number' && Number.isFinite(revision) ? revision : 0;
|
||||
if (nextRevision !== catalogRevision) resetExternalSkinStyles();
|
||||
externalSkins = Array.isArray(skins) ? skins.filter((skin): skin is ExternalSkin =>
|
||||
@@ -449,7 +463,6 @@ export function syncSkinPreviewTheme(theme: SkinPreviewTheme): void {
|
||||
}
|
||||
|
||||
export async function setupSkin(): Promise<void> {
|
||||
resetExternalSkinStyles();
|
||||
previewHorizontalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-h.html');
|
||||
previewVerticalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-v.html');
|
||||
document.querySelectorAll<HTMLElement>('[data-skin-horizontal]').forEach((host) => {
|
||||
@@ -459,6 +472,7 @@ export async function setupSkin(): Promise<void> {
|
||||
host.innerHTML = previewVerticalHtml; limitCandidatePreview(host);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>('[data-skin-toolbar]').forEach(fillToolbar);
|
||||
skinInitialized = true;
|
||||
BUILTIN_SKINS.forEach((skin) => applyBuiltinCardTheme(skin, resolvedPreviewTheme(skin)));
|
||||
syncSkinSwitches();
|
||||
syncAppearancePreviews();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
|
||||
import { onHostMessage } from '../utils/host-messages';
|
||||
import type { SettingsMessage } from '../../../../shared/messages';
|
||||
type DictionaryRequest = Extract<SettingsMessage, { type: 'dictionaryRequest' }>['data'];
|
||||
@@ -9,14 +10,20 @@ type QuickPhraseRow = { code: string; word: string; weight: number };
|
||||
|
||||
let editing: QuickPhraseRow | null = null;
|
||||
let requestCounter = 0;
|
||||
let lastAction = 'query';
|
||||
const pendingRequests = new Map<string, { action: DictionaryRequest['action']; dictionary: string }>();
|
||||
let latestQueryId = '';
|
||||
let lastQuery = '';
|
||||
let pager: ReturnType<typeof createDictionaryPager> | undefined;
|
||||
let toastTimer: number | null = null;
|
||||
|
||||
function post(action: DictionaryRequest['action'], data: Partial<Omit<DictionaryRequest, 'action' | 'requestId'>> = {}): void {
|
||||
lastAction = action;
|
||||
const requestId = `quick-${++requestCounter}`;
|
||||
const targetDictionary = 'quick';
|
||||
pendingRequests.set(requestId, { action, dictionary: targetDictionary });
|
||||
if (action === 'query') { latestQueryId = requestId; pager?.loading(); }
|
||||
window.chrome?.webview?.postMessage(serializeHostMessage({
|
||||
type: 'dictionaryRequest',
|
||||
data: { requestId: `quick-${++requestCounter}`, dictionary: 'quick', action, ...data }
|
||||
data: { requestId, dictionary: 'quick', action, ...data }
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -46,7 +53,7 @@ function renderRows(rows: QuickPhraseRow[]): void {
|
||||
if (!rows.length) { body.innerHTML = '<tr><td colspan="5" class="dict-empty">没有找到快捷短语</td></tr>'; syncHeader(); return; }
|
||||
body.replaceChildren(...rows.map((row, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
[String(index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => {
|
||||
[String((pager?.offset ?? 0) + index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => {
|
||||
const td = document.createElement('td'); td.textContent = value;
|
||||
if (cellIndex === 0) td.className = 'dict-index-column';
|
||||
td.addEventListener('mouseenter', () => td.scrollWidth > td.clientWidth ? td.title = value : td.removeAttribute('title'));
|
||||
@@ -74,8 +81,9 @@ function closeDialog(): void {
|
||||
const modal = document.getElementById('quickPhraseModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null;
|
||||
}
|
||||
|
||||
function query(): void {
|
||||
post('query', { code: (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim() });
|
||||
function query(offset = 0): void {
|
||||
if (offset === 0) lastQuery = (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim();
|
||||
post('query', { code: lastQuery, offset, limit: DICTIONARY_PAGE_SIZE });
|
||||
}
|
||||
|
||||
function downloadExport(content: string, filename: string): void {
|
||||
@@ -94,6 +102,8 @@ function downloadExport(content: string, filename: string): void {
|
||||
}
|
||||
|
||||
export function setupToolsSettings(): void {
|
||||
const table = document.getElementById('quickPhraseTableWrap');
|
||||
if (table) pager = createDictionaryPager(table, offset => query(offset));
|
||||
setupToggleButton('clipboardHistoryToggleBtn', (active) => {
|
||||
updateConfig('utility.clipboard_history', active);
|
||||
});
|
||||
@@ -121,7 +131,7 @@ export function setupToolsSettings(): void {
|
||||
setupToggleButton('rModeToggleBtn', (active) => {
|
||||
updateConfig('utility.r_mode', active);
|
||||
});
|
||||
document.getElementById('quickPhraseSearchButton')?.addEventListener('click', query);
|
||||
document.getElementById('quickPhraseSearchButton')?.addEventListener('click', () => query());
|
||||
document.getElementById('quickPhraseSearch')?.addEventListener('keydown', event => { if ((event as KeyboardEvent).key === 'Enter') query(); });
|
||||
document.getElementById('quickPhraseAddButton')?.addEventListener('click', () => openDialog());
|
||||
document.getElementById('quickPhraseImportButton')?.addEventListener('click', () => {
|
||||
@@ -158,14 +168,24 @@ export function setupToolsSettings(): void {
|
||||
window.addEventListener('resize', syncHeader);
|
||||
onHostMessage('dictionaryResponse', payload => {
|
||||
if (!payload.requestId.startsWith('quick-')) return;
|
||||
const context = pendingRequests.get(payload.requestId);
|
||||
if (!context) return;
|
||||
pendingRequests.delete(payload.requestId);
|
||||
if (context.action === 'query' && payload.requestId !== latestQueryId) return;
|
||||
const lastAction = context.action;
|
||||
if (lastAction === 'query') {
|
||||
if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true);
|
||||
else pager?.failed();
|
||||
}
|
||||
|
||||
const isImport = lastAction === 'import';
|
||||
const isExport = lastAction === 'export';
|
||||
if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') {
|
||||
downloadExport(payload.content, payload.filename);
|
||||
}
|
||||
showToast(payload.message ?? (payload.ok ? '操作成功' : '操作失败'), Boolean(payload.ok), isImport ? 5600 : 3200);
|
||||
renderRows(payload.rows.filter((row): row is QuickPhraseRow =>
|
||||
if (lastAction === 'query' && payload.ok) renderRows(payload.rows.filter((row): row is QuickPhraseRow =>
|
||||
typeof row.code === 'string' && typeof row.weight === 'number'));
|
||||
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(); }
|
||||
if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(pager?.offset ?? 0); }
|
||||
});
|
||||
}
|
||||
@@ -400,3 +400,6 @@ html[data-theme="dark"] .dict-toast.error .dict-toast-icon {
|
||||
.dict-toast-close:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dict-pagination { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
|
||||
.dict-pagination button:disabled { opacity: .45; cursor: default; }
|
||||
@@ -0,0 +1,35 @@
|
||||
export const DICTIONARY_PAGE_SIZE = 100;
|
||||
|
||||
export function createDictionaryPager(table: HTMLElement, load: (offset: number) => void) {
|
||||
let offset = 0;
|
||||
let hasMore = false;
|
||||
let pending = false;
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'dict-pagination';
|
||||
const previous = document.createElement('button');
|
||||
const next = document.createElement('button');
|
||||
const status = document.createElement('span');
|
||||
previous.type = next.type = 'button';
|
||||
previous.className = next.className = 'dict-button secondary';
|
||||
previous.textContent = '上一页'; next.textContent = '下一页';
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
footer.append(previous, status, next); table.after(footer);
|
||||
const sync = () => {
|
||||
previous.disabled = pending || offset === 0;
|
||||
next.disabled = pending || !hasMore;
|
||||
};
|
||||
previous.addEventListener('click', () => load(Math.max(0, offset - DICTIONARY_PAGE_SIZE)));
|
||||
next.addEventListener('click', () => load(offset + DICTIONARY_PAGE_SIZE));
|
||||
sync();
|
||||
return {
|
||||
get offset() { return offset; },
|
||||
loading() { pending = true; status.textContent = '查询中…'; sync(); },
|
||||
failed() { pending = false; status.textContent = '查询失败,请重试'; sync(); },
|
||||
reset() { offset = 0; hasMore = false; pending = false; status.textContent = ''; sync(); },
|
||||
update(start: number, count: number, more: boolean) {
|
||||
offset = start; hasMore = more; pending = false;
|
||||
status.textContent = count ? `第 ${start + 1}–${start + count} 条${more ? ',后面还有结果' : ''}` : '没有更多结果';
|
||||
sync();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export type ClientMessage =
|
||||
| { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" }
|
||||
| { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } }
|
||||
| { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } }
|
||||
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } }
|
||||
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } }
|
||||
| { "type": "openKeyboardPanel"; "protocolVersion"?: 1 }
|
||||
| { "type": "candidate"; "protocolVersion"?: 1; "data": number }
|
||||
| { "type": "delete"; "protocolVersion"?: 1; "data": number }
|
||||
@@ -48,7 +48,7 @@ export type ServerMessage =
|
||||
| { "type": "configSnapshot"; "protocolVersion"?: 1; "data": { [key: string]: unknown } }
|
||||
| { "type": "windowState"; "protocolVersion"?: 1; "data": { "isMaximized": boolean } }
|
||||
| { "type": "maxButtonEvent"; "protocolVersion"?: 1; "data": { "event": "enter" | "leave" | "down" | "up" | "click" } }
|
||||
| { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; [key: string]: unknown };
|
||||
| { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; "offset"?: number; "hasMore"?: boolean; [key: string]: unknown };
|
||||
export type SettingsMessage =
|
||||
| { "type": "configRequest"; "protocolVersion"?: 1 }
|
||||
| { "type": "skinCatalogRequest"; "protocolVersion"?: 1 }
|
||||
@@ -63,7 +63,7 @@ export type SettingsMessage =
|
||||
| { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" }
|
||||
| { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } }
|
||||
| { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } }
|
||||
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } }
|
||||
| { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } }
|
||||
| { "type": "openKeyboardPanel"; "protocolVersion"?: 1 };
|
||||
declare global {
|
||||
var MsimeProtocol: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated by generate.py; edit messages.json.
|
||||
(function(root) {
|
||||
const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["fullwidth","halfwidth"]}},"changePuncMode":{"surfaces":["toolbar"],"data":{"tLine truncated
|
||||
const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"},"offset":{"type":"integer","minimum":0,"maximum":2147483647},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbaLine truncated
|
||||
root.MsimeMessageSchema = schema;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = schema;
|
||||
})(globalThis);
|
||||
Vendored
+1
-1
Submodule vendor/MetasequoiaImeEngine updated: 6bd22549d6...403b4a4d72.
Reference in new issue
Block a user