diff --git a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts
index 4b85c990..a42bcb53 100644
--- a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts
+++ b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts
@@ -1,5 +1,7 @@
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
import { readDictionaryFile } from '../utils/dictionary-file';
+import { confirmDialog } from '../utils/confirm-dialog';
+import { hoistOverlay } from '../utils/overlay-host';
import { onHostMessage } from '../utils/host-messages';
import type { SettingsMessage } from '../../../../shared/messages';
type DictionaryRequest = Extract['data'];
@@ -30,11 +32,6 @@ function post(action: DictionaryRequest['action'], data: Partial('.dict-table-wrap');
- if (table) {
- const rect = table.getBoundingClientRect();
- toast.style.left = `${rect.left + rect.width / 2}px`;
- }
const messageElement = document.getElementById('dictToastMessage');
const iconElement = document.getElementById('dictToastIcon');
if (messageElement) messageElement.textContent = message;
@@ -77,8 +74,8 @@ function renderRows(rows: DictionaryRow[]): void {
const edit = document.createElement('button'); edit.className = 'dict-row-action'; edit.textContent = '编辑';
edit.addEventListener('click', () => openDialog(row));
const remove = document.createElement('button'); remove.className = 'dict-row-action danger'; remove.textContent = '删除';
- remove.addEventListener('click', () => {
- if (!window.confirm(`确定删除“${row.word}”吗?`)) return;
+ remove.addEventListener('click', async () => {
+ if (!await confirmDialog(`确定删除“${row.word}”吗?`)) return;
if (dictionary === 'english') post('delete', { oldWord: row.word, oldDisplay: row.display ?? row.word, word: row.word, display: row.display ?? row.word });
else post('delete', { oldCode: row.code, oldWord: row.word, code: row.code, word: row.word, weight: row.weight });
});
@@ -160,6 +157,8 @@ function downloadExport(content: string, filename: string): void {
}
export function setupDictionary(): void {
+ hoistOverlay(document.getElementById('dictToast'));
+ hoistOverlay(document.getElementById('dictModal'));
const table = document.querySelector('.dict-table-wrap');
if (table) pager = createDictionaryPager(table, offset => query(offset));
document.querySelectorAll('.dict-tab').forEach((tab) => tab.addEventListener('click', () => {
diff --git a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts
index a6acb202..e995d8fc 100644
--- a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts
+++ b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts
@@ -1,5 +1,7 @@
import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination';
import { onHostMessage } from '../utils/host-messages';
+import { confirmDialog } from '../utils/confirm-dialog';
+import { hoistOverlay } from '../utils/overlay-host';
import type { SettingsMessage } from '../../../../shared/messages';
type DictionaryRequest = Extract['data'];
import { serializeHostMessage } from '../../../../shared/messages';
@@ -37,9 +39,7 @@ function syncHeader(): void {
function showToast(message: string, ok: boolean, durationMs = 3200): void {
const toast = document.getElementById('quickPhraseToast');
- const table = document.getElementById('quickPhraseTableWrap');
if (!toast) return;
- if (table) { const rect = table.getBoundingClientRect(); toast.style.left = `${rect.left + rect.width / 2}px`; }
document.getElementById('quickPhraseToastMessage')!.textContent = message;
document.getElementById('quickPhraseToastIcon')!.textContent = ok ? '' : '!';
toast.className = `dict-toast visible ${ok ? 'success' : 'error'}`;
@@ -62,7 +62,7 @@ function renderRows(rows: QuickPhraseRow[]): void {
const actions = document.createElement('td');
const edit = document.createElement('button'); edit.className = 'dict-row-action'; edit.textContent = '编辑'; edit.addEventListener('click', () => openDialog(row));
const remove = document.createElement('button'); remove.className = 'dict-row-action danger'; remove.textContent = '删除';
- remove.addEventListener('click', () => { if (window.confirm(`确定删除“${row.word}”吗?`)) post('delete', { oldCode: row.code, oldWord: row.word, code: row.code, word: row.word, weight: row.weight }); });
+ remove.addEventListener('click', async () => { if (await confirmDialog(`确定删除“${row.word}”吗?`)) post('delete', { oldCode: row.code, oldWord: row.word, code: row.code, word: row.word, weight: row.weight }); });
actions.append(edit, remove); tr.appendChild(actions); return tr;
}));
syncHeader();
@@ -102,6 +102,8 @@ function downloadExport(content: string, filename: string): void {
}
export function setupToolsSettings(): void {
+ hoistOverlay(document.getElementById('quickPhraseToast'));
+ hoistOverlay(document.getElementById('quickPhraseModal'));
const table = document.getElementById('quickPhraseTableWrap');
if (table) pager = createDictionaryPager(table, offset => query(offset));
setupToggleButton('clipboardHistoryToggleBtn', (active) => {
diff --git a/ui-html/webview2/settings/ime-settings/src/styles/layout.css b/ui-html/webview2/settings/ime-settings/src/styles/layout.css
index a162e6cd..4b2bdcfa 100644
--- a/ui-html/webview2/settings/ime-settings/src/styles/layout.css
+++ b/ui-html/webview2/settings/ime-settings/src/styles/layout.css
@@ -1,3 +1,8 @@
+:root {
+ /* 侧边栏宽度;挂在 下的浮层要靠它算出右侧内容区的水平中心 */
+ --sidebar-width: 200px;
+}
+
.main {
display: flex;
flex: 1;
diff --git a/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css b/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css
index 054f00cb..f98e1a73 100644
--- a/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css
+++ b/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css
@@ -226,11 +226,15 @@
color: var(--danger-hover);
}
+/* 同样挂在 body 下(见 hoistOverlay)。遮罩让开标题栏,弹窗期间窗口按钮仍可点;
+ 左侧留出侧边栏宽度的内边距,对话框因此居中于右侧内容区,而不是整个窗口。 */
.dict-modal {
display: none;
position: fixed;
- inset: 0;
+ inset: var(--titlebar-height) 0 0;
z-index: 20;
+ box-sizing: border-box;
+ padding-left: var(--sidebar-width);
align-items: center;
justify-content: center;
background: var(--overlay-bg);
@@ -272,10 +276,36 @@
margin-top: 24px;
}
+/* 自绘的删除确认框,见 utils/confirm-dialog.ts */
+.dict-confirm-dialog {
+ width: 360px;
+}
+
+.dict-confirm-message {
+ color: var(--text-secondary);
+ font-size: 14px;
+ line-height: 1.6;
+ overflow-wrap: anywhere;
+}
+
+.dict-button.danger {
+ color: var(--danger-text);
+ background: transparent;
+ border: 1px solid color-mix(in srgb, var(--danger-text) 55%, transparent);
+}
+
+.dict-button.danger:hover {
+ color: var(--danger-hover);
+ background: color-mix(in srgb, var(--danger-text) 12%, transparent);
+}
+
+/* 浮层由 hoistOverlay() 挂到 下,这里的 fixed 相对窗口解析:水平取右侧内容区的中心
+ (侧边栏右边缘到窗口右边的中点),垂直离窗口底边两行正文的高度(2 × 1.45 × 14px)。
+ 两个值都由布局算出,窗口缩放和内容滚动都不会让它跑位。 */
.dict-toast {
position: fixed;
- left: 50%;
- bottom: 34px;
+ left: calc(50% + var(--sidebar-width) / 2);
+ bottom: 41px;
z-index: 30;
box-sizing: border-box;
display: grid;
@@ -401,5 +431,7 @@ html[data-theme="dark"] .dict-toast.error .dict-toast-icon {
outline: none;
}
-.dict-pagination { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
+/* 左右内边距与 .dict-table 单元格的 18px 对齐,按钮不贴卡片边框 */
+.dict-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin: 12px 0; padding: 0 18px; }
+.dict-pagination-status { margin-right: auto; }
.dict-pagination button:disabled { opacity: .45; cursor: default; }
diff --git a/ui-html/webview2/settings/ime-settings/src/styles/sidebar.css b/ui-html/webview2/settings/ime-settings/src/styles/sidebar.css
index 6b72da73..c5ab6f74 100644
--- a/ui-html/webview2/settings/ime-settings/src/styles/sidebar.css
+++ b/ui-html/webview2/settings/ime-settings/src/styles/sidebar.css
@@ -3,7 +3,7 @@
/**/
.sidebar {
- width: 200px;
+ width: var(--sidebar-width);
display: flex;
flex-direction: column;
padding: 12px 0;
diff --git a/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts b/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts
index 6de628d9..989d451c 100644
--- a/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts
+++ b/ui-html/webview2/settings/ime-settings/src/utils/common-utils.ts
@@ -1,3 +1,5 @@
+import { dismissHoistedOverlays } from './overlay-host';
+
// HTML 加载工具
// Prefer bundling partials so they work in WebView2/file://
const partials = import.meta.glob('/src/partials/**/*.html', {
@@ -68,4 +70,6 @@ export function showOnlyCurrentModule(moduleName: string): void {
next.style.display = 'block';
activeModuleName = moduleName;
+ // 词库 / 实用功能的浮层挂在 body 下(见 hoistOverlay),不随模块容器一起隐藏,切页时收起
+ dismissHoistedOverlays();
}
diff --git a/ui-html/webview2/settings/ime-settings/src/utils/confirm-dialog.ts b/ui-html/webview2/settings/ime-settings/src/utils/confirm-dialog.ts
new file mode 100644
index 00000000..b3465a2b
--- /dev/null
+++ b/ui-html/webview2/settings/ime-settings/src/utils/confirm-dialog.ts
@@ -0,0 +1,53 @@
+type ConfirmOptions = { title?: string; confirmText?: string; cancelText?: string };
+
+// 自绘确认框,替代 window.confirm():系统对话框不随设置页主题,在 WebView2 里还是宿主
+// 模态窗口,弹出期间页面自己的键盘和焦点处理全被挂起。样式复用词条编辑弹窗的 .dict-modal。
+export function confirmDialog(message: string, options: ConfirmOptions = {}): Promise {
+ const { title = '确认删除', confirmText = '删除', cancelText = '取消' } = options;
+ return new Promise((resolve) => {
+ const previousFocus = document.activeElement as HTMLElement | null;
+ const modal = document.createElement('div');
+ modal.className = 'dict-modal open';
+ const dialog = document.createElement('div');
+ dialog.className = 'dict-dialog dict-confirm-dialog';
+ dialog.setAttribute('role', 'alertdialog');
+ dialog.setAttribute('aria-modal', 'true');
+ dialog.setAttribute('aria-label', title);
+ const titleElement = document.createElement('div');
+ titleElement.className = 'dict-dialog-title';
+ titleElement.textContent = title;
+ const messageElement = document.createElement('div');
+ messageElement.className = 'dict-confirm-message';
+ messageElement.textContent = message;
+ const actions = document.createElement('div');
+ actions.className = 'dict-dialog-actions';
+ const cancel = document.createElement('button');
+ cancel.type = 'button'; cancel.className = 'dict-button secondary'; cancel.textContent = cancelText;
+ const confirm = document.createElement('button');
+ confirm.type = 'button'; confirm.className = 'dict-button danger'; confirm.textContent = confirmText;
+ actions.append(cancel, confirm);
+ dialog.append(titleElement, messageElement, actions);
+ modal.appendChild(dialog);
+ // 必须挂在 body 下,理由见 hoistOverlay()
+ document.body.appendChild(modal);
+
+ const close = (result: boolean): void => {
+ document.removeEventListener('keydown', onKeydown, true);
+ modal.remove();
+ previousFocus?.focus?.();
+ resolve(result);
+ };
+ // 捕获阶段处理,免得 Esc 被页面上别的弹窗的全局监听先吃掉
+ function onKeydown(event: KeyboardEvent): void {
+ if (event.key !== 'Escape' && event.key !== 'Enter') return;
+ event.preventDefault();
+ event.stopPropagation();
+ close(event.key === 'Enter');
+ }
+ document.addEventListener('keydown', onKeydown, true);
+ cancel.addEventListener('click', () => close(false));
+ confirm.addEventListener('click', () => close(true));
+ modal.addEventListener('mousedown', (event) => { if (event.target === modal) close(false); });
+ confirm.focus();
+ });
+}
diff --git a/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts b/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts
index 3bbcb457..25dae833 100644
--- a/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts
+++ b/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts
@@ -12,14 +12,19 @@ export function createDictionaryPager(table: HTMLElement, load: (offset: number)
previous.type = next.type = 'button';
previous.className = next.className = 'dict-button secondary';
previous.textContent = '上一页'; next.textContent = '下一页';
+ status.className = 'dict-pagination-status';
status.setAttribute('aria-live', 'polite');
- footer.append(previous, status, next); table.after(footer);
+ // 状态文本在左、两个翻页按钮并排靠右;状态为空时按钮仍靠右(见 dict.css 的 margin-right: auto)。
+ footer.append(status, previous, 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));
+ // 翻页后表格滚动条回到最上方:新一页的第一条应该是可见的,而不是停在上一页的滚动位置。
+ // 结果是异步回来的,但此刻内容还没换,先置 0 不会被后面的 replaceChildren 顶回去。
+ const turnTo = (target: number) => { table.scrollTop = 0; load(target); };
+ previous.addEventListener('click', () => turnTo(Math.max(0, offset - DICTIONARY_PAGE_SIZE)));
+ next.addEventListener('click', () => turnTo(offset + DICTIONARY_PAGE_SIZE));
sync();
return {
get offset() { return offset; },
diff --git a/ui-html/webview2/settings/ime-settings/src/utils/overlay-host.ts b/ui-html/webview2/settings/ime-settings/src/utils/overlay-host.ts
new file mode 100644
index 00000000..cecddeba
--- /dev/null
+++ b/ui-html/webview2/settings/ime-settings/src/utils/overlay-host.ts
@@ -0,0 +1,16 @@
+// #content-container 上有 contain: layout paint,它因此成了 position: fixed 的包含块:
+// 写在页面片段里的遮罩和提示条留在它内部就等同于绝对定位,会跟着内容一起滚——内容区滚下去
+// 之后,弹窗离窗口底边的距离凭空多出一个 scrollTop。挂到 下,fixed 才真的相对窗口。
+export function hoistOverlay(element: HTMLElement | null): void {
+ if (element && element.parentElement !== document.body) document.body.appendChild(element);
+}
+
+// 代价是这些浮层不再随所属模块容器一起隐藏,切换页面时得自己收起。
+export function dismissHoistedOverlays(): void {
+ document.querySelectorAll('body > .dict-toast.visible')
+ .forEach((toast) => toast.classList.remove('visible'));
+ document.querySelectorAll('body > .dict-modal.open').forEach((modal) => {
+ modal.classList.remove('open');
+ modal.setAttribute('aria-hidden', 'true');
+ });
+}