fix(ui-html): 修正词库浮层定位与翻页交互

#content-container 上有 contain: layout paint,它因此是 position: fixed 的
包含块。写在页面片段里的提示条和弹窗留在容器内部,fixed 就退化成相对该容器的
绝对定位,跟着内容一起滚:往下滚多少,浮层离窗口底边就多出多少;原先 showToast
每次还用 getBoundingClientRect() 的视口坐标去覆写 left,等于把容器偏移又加了
一遍,提示条因此偏向右侧,且只在弹出那一刻算一次,改窗口尺寸后就是旧值。

hoistOverlay() 在两个页面 setup 时把提示条和弹窗挂到 body 下,fixed 才真正
相对窗口解析,两处 JS 写 left 的代码删掉。提示条改由 CSS 定位:水平
calc(50% + var(--sidebar-width) / 2),即侧边栏右缘到窗口右缘的中点;垂直离
窗口底边两行正文高度。侧边栏宽度提成 --sidebar-width,sidebar.css 一并引用。
弹窗遮罩让开标题栏并左留侧边栏宽度的内边距,对话框居中于右侧内容区,弹窗期间
窗口按钮仍可点。浮层不再随模块容器隐藏,切页时由 dismissHoistedOverlays() 收起。

删除确认不再用 window.confirm:系统对话框不随设置页主题,在 WebView2 里是宿主
模态窗口,弹出期间页面自己的键盘和焦点处理全被挂起。confirmDialog() 复用
.dict-modal 样式自绘,支持 Enter 确认、Esc 取消、点遮罩取消和焦点还原。

顺带两处表格交互:翻页按钮改为靠右并排(状态文本移到左侧),与 .dict-table
单元格同样的 18px 内边距,不再贴着卡片边框;翻页时先把表格滚动条置顶,新一页
不再停在上一页的滚动位置。
This commit is contained in:
fanlusky committed 2026-09-20 15:29:46 +08:00
1 parent cecabcf69c
commit fcf594e244
9 files changed
+134 -18

No files matched your search

@@ -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<SettingsMessage, { type: 'dictionaryRequest' }>['data'];
@@ -30,11 +32,6 @@ function post(action: DictionaryRequest['action'], data: Partial<Omit<Dictionary
function showToast(message: string, ok: boolean, durationMs = 3200): void {
const toast = document.getElementById('dictToast');
if (!toast) return;
const table = document.querySelector<HTMLElement>('.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<HTMLElement>('.dict-table-wrap');
if (table) pager = createDictionaryPager(table, offset => query(offset));
document.querySelectorAll<HTMLButtonElement>('.dict-tab').forEach((tab) => tab.addEventListener('click', () => {
@@ -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<SettingsMessage, { type: 'dictionaryRequest' }>['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) => {
@@ -1,3 +1,8 @@
:root {
/* 侧边栏宽度;挂在 <body> 下的浮层要靠它算出右侧内容区的水平中心 */
--sidebar-width: 200px;
}
.main {
display: flex;
flex: 1;
@@ -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() 挂到 <body> 下,这里的 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; }
@@ -3,7 +3,7 @@
/**/
.sidebar {
width: 200px;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
padding: 12px 0;
@@ -1,3 +1,5 @@
import { dismissHoistedOverlays } from './overlay-host';
// HTML 加载工具
// Prefer bundling partials so they work in WebView2/file://
const partials = import.meta.glob<string>('/src/partials/**/*.html', {
@@ -68,4 +70,6 @@ export function showOnlyCurrentModule(moduleName: string): void {
next.style.display = 'block';
activeModuleName = moduleName;
// 词库 / 实用功能的浮层挂在 body 下(见 hoistOverlay),不随模块容器一起隐藏,切页时收起
dismissHoistedOverlays();
}
@@ -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<boolean> {
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();
});
}
@@ -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; },
@@ -0,0 +1,16 @@
// #content-container 上有 contain: layout paint,它因此成了 position: fixed 的包含块:
// 写在页面片段里的遮罩和提示条留在它内部就等同于绝对定位,会跟着内容一起滚——内容区滚下去
// 之后,弹窗离窗口底边的距离凭空多出一个 scrollTop。挂到 <body> 下,fixed 才真的相对窗口。
export function hoistOverlay(element: HTMLElement | null): void {
if (element && element.parentElement !== document.body) document.body.appendChild(element);
}
// 代价是这些浮层不再随所属模块容器一起隐藏,切换页面时得自己收起。
export function dismissHoistedOverlays(): void {
document.querySelectorAll<HTMLElement>('body > .dict-toast.visible')
.forEach((toast) => toast.classList.remove('visible'));
document.querySelectorAll<HTMLElement>('body > .dict-modal.open').forEach((modal) => {
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
});
}