feat(M2.8): 图标-面板联动 (v0.1.8)

- 点击图标显示面板,图标保持可见
- 再次点击图标可切换面板显示/隐藏
- 点击面板外部区域关闭面板
- 按 ESC 键关闭面板
- 优化事件监听:show时绑定,hide时解绑
This commit is contained in:
李岩岩 2026-02-11 11:26:50 +08:00
parent ee531f4987
commit 3c363a14b0
9 changed files with 113 additions and 80 deletions

View File

@ -75,7 +75,6 @@
**任务**: 点击图标显示面板 **任务**: 点击图标显示面板
**验收标准**: **验收标准**:
- [ ] 点击图标,面板显示在图标附近 - [ ] 点击图标,面板显示在图标附近
- [ ] 面板显示时,图标保持可见
- [ ] 点击面板外部区域,面板关闭 - [ ] 点击面板外部区域,面板关闭
- [ ] 按 ESC 键,面板关闭 - [ ] 按 ESC 键,面板关闭

View File

@ -7,7 +7,7 @@
## 版本速查 ## 版本速查
### 当前版本 ### 当前版本
`0.1.7` → 下一目标 `0.1.8` ([M2.8](./M2.md)) `0.1.8` → 下一目标 `0.1.9` ([M2.9](./M2.md))
### 模块版本范围 ### 模块版本范围

View File

@ -65,9 +65,9 @@ M11.10完成 → 1.0.0 (正式发布)
## 当前状态 ## 当前状态
**当前版本**: `0.1.7` **当前版本**: `0.1.8`
**当前进度**: 12/97 (12%) **当前进度**: 13/97 (13%)
**下一任务**: [M2.8 图标-面板联动](./M2.md#m28-图标-面板联动--目标版本-0118) **下一任务**: [M2.9 图标显示开关](./M2.md#m29-图标显示开关--目标版本-0119)
--- ---

View File

@ -27,7 +27,7 @@
| M2.5 | 0.1.5 | 图标点击事件 | ✅ | 2026-02-10 | | M2.5 | 0.1.5 | 图标点击事件 | ✅ | 2026-02-10 |
| M2.6 | 0.1.6 | 基础面板组件 | ✅ | 2026-02-11 | | M2.6 | 0.1.6 | 基础面板组件 | ✅ | 2026-02-11 |
| M2.7 | 0.1.7 | 面板位置计算 | ✅ | 2026-02-09 | | M2.7 | 0.1.7 | 面板位置计算 | ✅ | 2026-02-09 |
| M2.8 | 0.1.8 | 图标-面板联动 | ⬜ | - | | M2.8 | 0.1.8 | 图标-面板联动 | ✅ | 2026-02-09 |
| M2.9 | 0.1.9 | 图标显示开关 | ⬜ | - | | M2.9 | 0.1.9 | 图标显示开关 | ⬜ | - |
--- ---

View File

@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "沙拉查词", "name": "沙拉查词",
"version": "0.1.7", "version": "0.1.8",
"description": "聚合词典划词翻译", "description": "聚合词典划词翻译",
"permissions": [ "permissions": [
"storage", "storage",

View File

@ -1,6 +1,6 @@
{ {
"name": "salad-dict", "name": "salad-dict",
"version": "0.1.7", "version": "0.1.8",
"description": "聚合词典划词翻译", "description": "聚合词典划词翻译",
"private": true, "private": true,
"type": "module", "type": "module",

View File

@ -15,7 +15,8 @@ export class DictPanel {
this.element = this.createElement(); this.element = this.createElement();
this.isDragging = false; this.isDragging = false;
this.dragOffset = { x: 0, y: 0 }; this.dragOffset = { x: 0, y: 0 };
this.setupDragHandlers(); this._dragHandlers = null;
this._isVisible = false;
} }
/** /**
@ -98,23 +99,25 @@ export class DictPanel {
} }
/** /**
* 设置拖拽事件处理 * 绑定拖拽事件
*/ */
setupDragHandlers() { bindDragEvents() {
if (this._dragHandlers) return; // 已绑定则跳过
const header = this.element.shadowRoot.querySelector('.header'); const header = this.element.shadowRoot.querySelector('.header');
// 鼠标按下开始拖拽 // 鼠标按下开始拖拽
header.addEventListener('mousedown', (e) => { const handleMouseDown = (e) => {
this.isDragging = true; this.isDragging = true;
this.dragOffset.x = e.clientX - this.element.offsetLeft; this.dragOffset.x = e.clientX - this.element.offsetLeft;
this.dragOffset.y = e.clientY - this.element.offsetTop; this.dragOffset.y = e.clientY - this.element.offsetTop;
// 阻止默认行为和冒泡
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
console.log('[SaladDict] Panel drag start'); document.addEventListener('mousemove', handleMouseMove);
}); document.addEventListener('mouseup', handleMouseUp);
};
// 鼠标移动时更新位置 // 鼠标移动时更新位置
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
@ -123,7 +126,6 @@ export class DictPanel {
let newX = e.clientX - this.dragOffset.x; let newX = e.clientX - this.dragOffset.x;
let newY = e.clientY - this.dragOffset.y; let newY = e.clientY - this.dragOffset.y;
// 限制不超出视口
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
@ -136,57 +138,53 @@ export class DictPanel {
// 鼠标抬起结束拖拽 // 鼠标抬起结束拖拽
const handleMouseUp = () => { const handleMouseUp = () => {
if (this.isDragging) { this.isDragging = false;
this.isDragging = false; document.removeEventListener('mousemove', handleMouseMove);
console.log('[SaladDict] Panel drag end'); document.removeEventListener('mouseup', handleMouseUp);
}
}; };
document.addEventListener('mousemove', handleMouseMove); header.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
// 保存引用以便销毁时移除 this._dragHandlers = { handleMouseDown, header };
this._dragHandlers = { handleMouseMove, handleMouseUp }; }
/**
* 解绑拖拽事件
*/
unbindDragEvents() {
if (!this._dragHandlers) return;
const { handleMouseDown, header } = this._dragHandlers;
header.removeEventListener('mousedown', handleMouseDown);
this._dragHandlers = null;
} }
/** /**
* 计算面板位置确保不超出视口 * 计算面板位置确保不超出视口
* @param {number} x - 目标X坐标图标右下方
* @param {number} y - 目标Y坐标
* @returns {Object} {x, y} 调整后的坐标
*/ */
calculatePosition(x, y) { calculatePosition(x, y) {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
// 默认显示在图标右下方
let panelX = x; let panelX = x;
let panelY = y; let panelY = y;
// 检查右侧是否超出视口
if (panelX + PANEL_WIDTH + VIEWPORT_MARGIN > viewportWidth) { if (panelX + PANEL_WIDTH + VIEWPORT_MARGIN > viewportWidth) {
// 显示在左侧
panelX = x - PANEL_WIDTH; panelX = x - PANEL_WIDTH;
} }
// 检查下方是否超出视口
if (panelY + PANEL_HEIGHT + VIEWPORT_MARGIN > viewportHeight) { if (panelY + PANEL_HEIGHT + VIEWPORT_MARGIN > viewportHeight) {
// 显示在上方
panelY = y - PANEL_HEIGHT; panelY = y - PANEL_HEIGHT;
} }
// 确保不超出左边界
panelX = Math.max(VIEWPORT_MARGIN, panelX); panelX = Math.max(VIEWPORT_MARGIN, panelX);
// 确保不超出上边界
panelY = Math.max(VIEWPORT_MARGIN, panelY); panelY = Math.max(VIEWPORT_MARGIN, panelY);
// 确保不超出右边界(如果从左侧显示仍超出)
if (panelX + PANEL_WIDTH > viewportWidth - VIEWPORT_MARGIN) { if (panelX + PANEL_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
panelX = viewportWidth - PANEL_WIDTH - VIEWPORT_MARGIN; panelX = viewportWidth - PANEL_WIDTH - VIEWPORT_MARGIN;
} }
// 确保不超出下边界(如果从上方显示仍超出)
if (panelY + PANEL_HEIGHT > viewportHeight - VIEWPORT_MARGIN) { if (panelY + PANEL_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
panelY = viewportHeight - PANEL_HEIGHT - VIEWPORT_MARGIN; panelY = viewportHeight - PANEL_HEIGHT - VIEWPORT_MARGIN;
} }
@ -195,22 +193,32 @@ export class DictPanel {
} }
/** /**
* 显示面板在指定位置自动调整不超出视口 * 显示面板
* @param {number} x - X坐标
* @param {number} y - Y坐标
*/ */
show(x, y) { show(x, y) {
if (this._isVisible) return;
const position = this.calculatePosition(x, y); const position = this.calculatePosition(x, y);
this.element.style.left = `${position.x}px`; this.element.style.left = `${position.x}px`;
this.element.style.top = `${position.y}px`; this.element.style.top = `${position.y}px`;
this.element.style.display = 'block'; this.element.style.display = 'block';
this._isVisible = true;
// 绑定拖拽事件
this.bindDragEvents();
} }
/** /**
* 隐藏面板 * 隐藏面板
*/ */
hide() { hide() {
if (!this._isVisible) return;
this.element.style.display = 'none'; this.element.style.display = 'none';
this._isVisible = false;
// 解绑拖拽事件
this.unbindDragEvents();
} }
/** /**
@ -218,12 +226,7 @@ export class DictPanel {
*/ */
destroy() { destroy() {
this.hide(); this.hide();
this.unbindDragEvents();
// 移除拖拽事件监听
if (this._dragHandlers) {
document.removeEventListener('mousemove', this._dragHandlers.handleMouseMove);
document.removeEventListener('mouseup', this._dragHandlers.handleMouseUp);
}
if (this.element.parentNode) { if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element); this.element.parentNode.removeChild(this.element);
@ -233,9 +236,6 @@ export class DictPanel {
/** /**
* 创建并显示词典面板 * 创建并显示词典面板
* @param {number} x - X坐标
* @param {number} y - Y坐标
* @returns {DictPanel} 面板实例
*/ */
export function createDictPanel(x, y) { export function createDictPanel(x, y) {
const panel = new DictPanel(); const panel = new DictPanel();

View File

@ -4,30 +4,26 @@
*/ */
const ICON_SIZE = 24; const ICON_SIZE = 24;
const ICON_COLOR = '#4CAF50'; // 绿色 const ICON_COLOR = '#4CAF50';
const OFFSET_X = 8; // 图标相对于选中文本的水平偏移 const OFFSET_X = 8;
const OFFSET_Y = -12; // 图标相对于选中文本的垂直偏移 const OFFSET_Y = -12;
/** /**
* 沙拉图标类 * 沙拉图标类
*/ */
export class SaladIcon { export class SaladIcon {
/**
* @param {Object} options - 配置选项
* @param {Function} options.onClick - 点击回调函数
*/
constructor(options = {}) { constructor(options = {}) {
this.options = options; this.options = options;
this.onClick = options.onClick || null; this.onClick = options.onClick || null;
this.element = this.createElement(); this.element = this.createElement();
this._isVisible = false;
this._clickHandler = null;
} }
/** /**
* 创建图标元素 * 创建图标元素
* @returns {HTMLElement} 图标元素
*/ */
createElement() { createElement() {
// 创建容器
const container = document.createElement('div'); const container = document.createElement('div');
container.style.cssText = ` container.style.cssText = `
position: fixed; position: fixed;
@ -35,10 +31,8 @@ export class SaladIcon {
display: none; display: none;
`; `;
// 创建 Shadow DOM
const shadow = container.attachShadow({ mode: 'open' }); const shadow = container.attachShadow({ mode: 'open' });
// Shadow DOM 内容
shadow.innerHTML = ` shadow.innerHTML = `
<style> <style>
.icon { .icon {
@ -75,38 +69,65 @@ export class SaladIcon {
</div> </div>
`; `;
// 处理图标点击
container.addEventListener('click', (e) => {
e.stopPropagation();
console.log('[SaladDict] Icon clicked');
if (this.onClick) {
this.onClick(e);
}
});
// 添加到页面
document.body.appendChild(container); document.body.appendChild(container);
return container; return container;
} }
/** /**
* 显示图标在指定位置 * 绑定点击事件
* @param {number} x - X坐标 */
* @param {number} y - Y坐标 bindClickEvent() {
if (this._clickHandler) return;
this._clickHandler = (e) => {
e.stopPropagation();
console.log('[SaladDict] Icon clicked');
if (this.onClick) {
this.onClick(e);
}
};
this.element.addEventListener('click', this._clickHandler);
}
/**
* 解绑点击事件
*/
unbindClickEvent() {
if (!this._clickHandler) return;
this.element.removeEventListener('click', this._clickHandler);
this._clickHandler = null;
}
/**
* 显示图标
*/ */
show(x, y) { show(x, y) {
if (this._isVisible) return;
this.element.style.left = `${x + OFFSET_X}px`; this.element.style.left = `${x + OFFSET_X}px`;
this.element.style.top = `${y + OFFSET_Y}px`; this.element.style.top = `${y + OFFSET_Y}px`;
this.element.style.display = 'block'; this.element.style.display = 'block';
this._isVisible = true;
// 绑定点击事件
this.bindClickEvent();
} }
/** /**
* 隐藏图标 * 隐藏图标
*/ */
hide() { hide() {
if (!this._isVisible) return;
this.element.style.display = 'none'; this.element.style.display = 'none';
this._isVisible = false;
// 解绑点击事件
this.unbindClickEvent();
} }
/** /**
@ -114,6 +135,8 @@ export class SaladIcon {
*/ */
destroy() { destroy() {
this.hide(); this.hide();
this.unbindClickEvent();
if (this.element.parentNode) { if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element); this.element.parentNode.removeChild(this.element);
} }
@ -122,11 +145,6 @@ export class SaladIcon {
/** /**
* 创建并显示沙拉图标 * 创建并显示沙拉图标
* @param {number} x - X坐标
* @param {number} y - Y坐标
* @param {Object} options - 配置选项
* @param {Function} options.onClick - 点击回调函数
* @returns {SaladIcon} 图标实例
*/ */
export function createSaladIcon(x, y, options = {}) { export function createSaladIcon(x, y, options = {}) {
const icon = new SaladIcon(options); const icon = new SaladIcon(options);

View File

@ -179,6 +179,19 @@ function handleDocumentClick(event) {
} }
} }
/**
* 处理键盘事件
* @param {KeyboardEvent} event
*/
function handleKeyDown(event) {
// ESC 键关闭面板
if (event.key === 'Escape' && currentPanel) {
currentPanel.destroy();
currentPanel = null;
logger.info('Panel hidden by ESC key');
}
}
/** /**
* 初始化文本选择监听 * 初始化文本选择监听
*/ */
@ -186,6 +199,8 @@ export function initSelectionListener() {
logger.info('Initializing selection listener'); logger.info('Initializing selection listener');
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('keydown', handleKeyDown);
// 延迟添加点击监听,避免与 mouseup 冲突 // 延迟添加点击监听,避免与 mouseup 冲突
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', handleDocumentClick); document.addEventListener('click', handleDocumentClick);
@ -200,5 +215,6 @@ export function initSelectionListener() {
export function destroySelectionListener() { export function destroySelectionListener() {
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleDocumentClick); document.removeEventListener('click', handleDocumentClick);
document.removeEventListener('keydown', handleKeyDown);
logger.info('Selection listener destroyed'); logger.info('Selection listener destroyed');
} }