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'); + }); +}