refactor: 优化 Popover 安全性和交互体验,修复 Context Menu 数据持久化问题并添加 i18n 支持
This commit is contained in:
@@ -22,13 +22,15 @@ function injectStyles(): void {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
opacity: 0;
|
||||
visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#${POPOVER_ID}.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -85,14 +87,11 @@ function injectStyles(): void {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-value:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-item {
|
||||
@@ -126,27 +125,36 @@ function getOrCreatePopover(): HTMLElement {
|
||||
return popover;
|
||||
}
|
||||
|
||||
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
||||
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
|
||||
const rect = popover.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = x;
|
||||
let top = y;
|
||||
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
|
||||
let top = y + 8;
|
||||
|
||||
if (left + rect.width > viewportWidth - 16) {
|
||||
left = viewportWidth - rect.width - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
if (left < 16) left = 16;
|
||||
|
||||
if (top + rect.height > viewportHeight - 16) {
|
||||
top = y - rect.height - 8;
|
||||
}
|
||||
if (top < 16) {
|
||||
top = 16;
|
||||
}
|
||||
if (top < 16) top = 16;
|
||||
|
||||
popover.style.left = `${left}px`;
|
||||
popover.style.top = `${top}px`;
|
||||
@@ -157,24 +165,41 @@ let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
export function showPopover(
|
||||
x: number,
|
||||
y: number,
|
||||
content: string,
|
||||
contentHtml: string,
|
||||
title?: string,
|
||||
duration: number = 5000,
|
||||
): void {
|
||||
const popover = getOrCreatePopover();
|
||||
|
||||
const titleHtml = title
|
||||
? `<div class="popover-header">
|
||||
<span class="popover-title">${title}</span>
|
||||
<button class="popover-close" onclick="this.closest('#${POPOVER_ID}').classList.remove('visible')">×</button>
|
||||
</div>`
|
||||
: '';
|
||||
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
|
||||
popover.innerHTML = '';
|
||||
|
||||
popover.innerHTML = `
|
||||
${titleHtml}
|
||||
<div class="popover-content">${content}</div>
|
||||
`;
|
||||
if (title) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'popover-header';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'popover-title';
|
||||
titleSpan.textContent = title; // ✅ 强安全性护航
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'popover-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
popover.classList.remove('visible');
|
||||
});
|
||||
|
||||
header.appendChild(titleSpan);
|
||||
header.appendChild(closeBtn);
|
||||
popover.appendChild(header);
|
||||
}
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'popover-content';
|
||||
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
|
||||
popover.appendChild(contentContainer);
|
||||
|
||||
// 提前移除激活类名,使 visibility: hidden 起效以供测量
|
||||
popover.classList.remove('visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -182,9 +207,7 @@ export function showPopover(
|
||||
popover.classList.add('visible');
|
||||
});
|
||||
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
}
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
|
||||
if (duration > 0) {
|
||||
hideTimeout = setTimeout(() => {
|
||||
@@ -205,11 +228,15 @@ export function hidePopover(): void {
|
||||
}
|
||||
|
||||
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
|
||||
const cleanTimestamp = escapeHtml(timestamp);
|
||||
const cleanResult = escapeHtml(result);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">输入时间戳</div>
|
||||
<div class="popover-value">${timestamp}</div>
|
||||
<div class="popover-value">${cleanTimestamp}</div>
|
||||
<div class="popover-label">转换结果</div>
|
||||
<div class="popover-value">${result}</div>
|
||||
<div class="popover-value">${cleanResult}</div>
|
||||
`;
|
||||
showPopover(x, y, content, '⏰ 时间戳转换');
|
||||
}
|
||||
@@ -221,9 +248,12 @@ export function showTextStatsResult(
|
||||
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||
): void {
|
||||
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
// 对选中的脏文本先进行严格转义
|
||||
const cleanText = escapeHtml(truncatedText);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">选中文本</div>
|
||||
<div class="popover-value">${truncatedText}</div>
|
||||
<div class="popover-value">${cleanText}</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字符</div>
|
||||
|
||||
Reference in New Issue
Block a user