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:
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');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user