diff --git a/.gemini/ops_changelog.md b/.gemini/ops_changelog.md index 2b4f0b3..da80bd7 100644 --- a/.gemini/ops_changelog.md +++ b/.gemini/ops_changelog.md @@ -60,6 +60,7 @@ | 2026-05-23 16:35:00 | Implement and Refine GitHub Gist Synchronization | src/logic/sync.ts, src/logic/storage.ts, src/options/Options.vue, src/background/main.ts, src/manifest.ts, docs/user-guide/github-sync.md | Add cross-device sync via GitHub Gist with automated background push/pull, timestamp merging, tombstone deletion, and exponential backoff retry (Issue #41, #42, #43) | HEAD | - | | 2026-06-02 08:55:00 | Refactor Sidepanel into modular Composables and Components | src/sidepanel/Sidepanel.vue, src/sidepanel/composables/*, src/sidepanel/components/*, src/tests/tagTree.spec.ts | Modularize monolithic Sidepanel.vue into 5 domain-specific composables and 5 focused UI components to improve maintainability and testability (Issue #46) | e14fd1d | git checkout main && git branch -D issue-46 | | 2026-06-02 09:30:00 | Fix Blocking CR issues and sync documents | src/sidepanel/Sidepanel.vue, src/sidepanel/composables/useTagActions.ts, src/sidepanel/composables/useMarkActions.ts, docs/superpowers/* | Resolve tag picker state management bugs, fix race conditions in tagging, improve error handling in deletion, and restore missing spec/plan docs (Issue #46) | HEAD | - | +| 2026-06-10 14:10:00 | Refactor highlight restoration with confidence-based branching | src/contentScripts/restorer.ts, src/contentScripts/index.ts, src/contentScripts/ui.ts, src/contentScripts/state.ts, src/contentScripts/views/Tooltip.vue, src/sidepanel/Sidepanel.vue, src/logic/search.ts, src/logic/config.ts, src/logic/storage.ts | Replace passive disambiguation modal with active recovery status management: high confidence auto-restore, medium confidence pending-confirm style, low confidence sidepanel recalibration list (Issue #47) | HEAD | git checkout main && git branch -D feat/recovery-status-management | --- diff --git a/docs/superpowers/plans/2026-06-10-recovery-status-management-plan.md b/docs/superpowers/plans/2026-06-10-recovery-status-management-plan.md new file mode 100644 index 0000000..04898e5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-recovery-status-management-plan.md @@ -0,0 +1,181 @@ +# 高亮恢复可信度分级与主动状态管理实施计划 + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将高亮恢复从"被动弹窗确认"改造为"按可信度分支处理 + 侧边栏主动管理",同时修复 L3 搜索的位置偏移问题。 + +**Architecture:** +1. **数据层**:Mark 接口扩展 `recoveryStatus` 字段。 +2. **恢复层**:`HighlightRestorer` 按 confidence (high/medium/low) 分支处理,不再触发弹窗。 +3. **表现层**:`Tooltip.vue` 支持 `pending-confirm` 模式;`Sidepanel.vue` 显示待恢复列表。 +4. **交互层**:Content Script 支持 `recalibrate-mark` 消息,进入重新选择模式。 + +**Tech Stack:** Vue 3, TypeScript, WebExtension API, Rangy, webext-bridge. + +--- + +### Task 1: 数据层扩展 + +**Files:** +- Modify: `src/logic/storage.ts` +- Modify: `src/logic/config.ts` + +- [x] **Step 1: Mark 接口添加 recoveryStatus** + ```typescript + recoveryStatus?: 'restored' | 'pending-confirm' | 'needs-recalibration' + ``` + +- [x] **Step 2: 新增 pending-confirm 样式常量** + ```typescript + export const highlightPendingConfirmStyle = (color: string) => + `box-shadow: inset 0 -5px 0 0 ${color}; cursor: pointer; border-bottom: 2px dashed ${color}; opacity: 0.85;` + ``` + +--- + +### Task 2: 恢复逻辑改造(核心) + +**Files:** +- Modify: `src/contentScripts/restorer.ts` +- Modify: `src/contentScripts/state.ts` + +- [x] **Step 1: 扩展 SearchRestoreResult 接口** + 添加 `confidence?: 'high' | 'medium' | 'low'` + +- [x] **Step 2: 改造 restoreBySearch 分支逻辑** + - similarity ≥ 95% → `confidence: 'high'`,应用默认样式 + - similarity 85%-95% → `confidence: 'medium'`,应用 pending-confirm 样式 + - similarity < 85% → `confidence: 'low'`,不应用高亮 + - multiple candidates / no candidates → `confidence: 'low'` + +- [x] **Step 3: 改造 applyMarksTwoPhases** + - L1 成功 → `recoveryStatus: 'restored'` + - high → `recoveryStatus: 'restored'` + - medium → `recoveryStatus: 'pending-confirm'` + - low → `recoveryStatus: 'needs-recalibration'` + +- [x] **Step 4: 添加 persistRecoveryStatus 辅助方法** + 调用 `update-mark-details` 持久化 recoveryStatus,避免重复写入。 + +- [x] **Step 5: 移除弹窗触发逻辑** + `restoreHighlights` 返回 `void`,不再处理 `ambiguousMarksQueue`。 + +- [x] **Step 6: state.ts 添加 recalibration 状态** + ```typescript + isRecalibrationMode = false + recalibrationMarkId: string | null = null + ``` + +--- + +### Task 3: 待确认标记交互 + +**Files:** +- Modify: `src/contentScripts/views/Tooltip.vue` +- Modify: `src/contentScripts/ui.ts` +- Modify: `src/contentScripts/index.ts` + +- [x] **Step 1: Tooltip.vue 支持 pending-confirm 模式** + - 新增 `mode` ref 和 `currentMarkId` ref + - `show` 方法接收 `mode` 和 `markId` 参数 + - pending-confirm 模式下显示琥珀色提示条 + "位置正确"/"重新选择"按钮 + +- [x] **Step 2: ui.ts 添加 confirmPosition 和 recalibrate 处理** + - `handleConfirmPosition`:恢复默认样式,更新 recoveryStatus + - `handleRecalibrate`:进入重新选择模式 + +- [x] **Step 3: index.ts 点击现有高亮时传递 mode** + - 检查 mark.recoveryStatus,若为 pending-confirm 则传递 mode='pending-confirm' + +--- + +### Task 4: 重新选择模式 + +**Files:** +- Modify: `src/contentScripts/ui.ts` +- Modify: `src/contentScripts/index.ts` + +- [x] **Step 1: ui.ts 实现 recalibration 模式** + - `enterRecalibrationMode`:设置状态,显示页面顶部浮动提示 + - `exitRecalibrationMode`:清理状态,移除提示 + - `updateMarkFromRecalibration`:移除旧高亮,应用新高亮,更新 mark 数据 + +- [x] **Step 2: index.ts 处理 recalibrate-mark 消息** + - 新增 `onMessage('recalibrate-mark', ...)` 处理器 + - `processSelection` 中 isRecalibrationMode 为 true 时,Alt+点击触发重新标记而非新建 + +--- + +### Task 5: 侧边栏待恢复列表 + +**Files:** +- Modify: `src/sidepanel/Sidepanel.vue` + +- [x] **Step 1: 添加 pendingRecalibrationMarks 计算属性** + 从 `marksByUrl` 过滤所有 `recoveryStatus === 'needs-recalibration'` 的 mark。 + +- [x] **Step 2: 模板中添加待恢复区域** + - 可折叠的琥珀色卡片,显示标记数量 + - 每个标记:原文片段 + "重新选择" + "丢弃"按钮 + +- [x] **Step 3: 实现 startRecalibration 方法** + 激活对应标签页,发送 `recalibrate-mark` 消息到 content script。 + +- [x] **Step 4: 实现 discardPendingMark 方法** + 调用 `remove-mark-by-id` 彻底删除标记。 + +--- + +### Task 6: L3 搜索修复(配套) + +**Files:** +- Modify: `src/logic/search.ts` + +- [x] **Step 1: 移除 div 从 structureBoundaries 查询** + 减少大容器 div 对搜索空间的过度过滤。 + +- [x] **Step 2: 添加回退机制** + 当 structureBoundaries 过滤死所有组合时,回退到纯相似度搜索。 + +- [x] **Step 3: 修复溢出 bug** + - `suggestRange` 结果 clamp 到 `fullText` 长度 + - `LocalAligner` 的 `endMin` 防溢出 + +--- + +### Task 7: 测试与验证 + +**Files:** +- Modify: `src/tests/restorer.spec.ts` + +- [x] **Step 1: 更新 restorer.spec.ts** + `restoreHighlights` 返回 `void`,更新断言。 + +- [x] **Step 2: 运行全部测试** + 验证所有相关测试通过。 + +--- + +### Task 8: 文档与审计 + +**Files:** +- Modify: `.gemini/ops_changelog.md` +- Create: `docs/superpowers/specs/2026-06-10-recovery-status-management-design.md` +- Create: `docs/superpowers/plans/2026-06-10-recovery-status-management-plan.md` + +- [x] **Step 1: 更新审计日志** +- [x] **Step 2: 创建 Spec 文档** +- [x] **Step 3: 创建 Plan 文档** + +--- + +### Task 9: 分支与 PR + +- [ ] **Step 1: 切换分支** + `git checkout -b feat/recovery-status-management` + +- [ ] **Step 2: 提交代码** + 按照 conventional commit 规范提交。 + +- [ ] **Step 3: 创建 PR** + 使用 PR Template,填写变更摘要和质量验证。 diff --git a/docs/superpowers/specs/2026-06-10-recovery-status-management-design.md b/docs/superpowers/specs/2026-06-10-recovery-status-management-design.md new file mode 100644 index 0000000..cfc0a83 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-recovery-status-management-design.md @@ -0,0 +1,120 @@ +# 设计规约:高亮恢复可信度分级与主动状态管理 + +- **状态**:已评审 +- **日期**:2026-06-10 +- **关联 Issue**:[#47](https://github.com/catx1726/MarkFlow/issues/47) + +## 1. 目标 (Goals) + +解决当前恢复机制的两大核心问题: + +1. **被动弹窗体验差**:L4 歧义消除弹窗让用户在模糊的候选列表中被动选择,仿佛机器已帮用户"预选"了错误位置。 +2. **位置偏移不可信**:L3 共识搜索在复杂 DOM 场景下返回的 range 可能偏移,直接应用会导致高亮飘到错误位置。 + +通过引入**可信度分级**和**恢复状态持久化**,将恢复从"机器猜测 → 弹窗确认"改为"按可信度分支处理 + 侧边栏主动管理"。 + +## 2. 方案详述 (Detailed Design) + +### 2.1 可信度分级策略 + +| 等级 | 条件 | 处理方式 | 视觉表现 | +|------|------|----------|----------| +| **High** (≥95%) | L1 路径还原成功 或 L3 相似度 ≥95% | 自动恢复,静默成功 | 默认样式(box-shadow 下划线) | +| **Medium** (85%-95%) | L3 相似度 85%-95% | 自动恢复,但标记为"待确认" | 虚线边框 + 半透明 + 提示图标 | +| **Low** (<85%) | L3 相似度 <85% / 无候选 / 多候选 | 不自动恢复,进入待恢复列表 | 无高亮,侧边栏显示"待恢复" | + +### 2.2 恢复状态持久化 + +在 `Mark` 数据结构中新增 `recoveryStatus` 字段: + +```typescript +recoveryStatus?: 'restored' | 'pending-confirm' | 'needs-recalibration' +``` + +- 状态自描述:mark 数据本身携带恢复状态,无需 content script 维护额外队列。 +- 状态流转: + - 新建标记 → 默认为 `undefined`(向后兼容,视为 `'restored'`) + - High 恢复成功 → `'restored'` + - Medium 恢复成功 → `'pending-confirm'` + - Low 恢复失败 → `'needs-recalibration'` + - 用户确认 pending-confirm → `'restored'` + - 用户重新选择 → `'restored'` + +### 2.3 侧边栏"待恢复"列表 + +在侧边栏 TagFolder 列表**上方**新增可折叠区域: + +- 标题:⚠️ 待恢复标记 (N) +- 每个标记显示:原文片段(截断)+ "重新选择"按钮 + "丢弃"按钮 +- 点击"重新选择":激活对应标签页,发送 `recalibrate-mark` 消息到 content script + +### 2.4 重新选择模式 (Recalibration Mode) + +当用户从侧边栏触发重新选择时,content script 进入特殊模式: + +1. 页面顶部显示浮动提示:"请选中原标记「xxx」对应的文本,然后按 Alt+点击确认" +2. 用户选中文本后按 Alt+点击(与新建标记相同手势) +3. 系统不创建新 mark,而是更新现有 mark 的 text/rangySerialized/html/context/recoveryStatus +4. 退出重新选择模式 + +### 2.5 待确认标记的确认交互 + +当用户点击 `pending-confirm` 样式的高亮时: + +1. Tooltip 显示琥珀色提示条:"此标记位置可能已变化,请确认是否准确" +2. 提供两个按钮: + - **位置正确**:将样式恢复为默认,更新 `recoveryStatus` 为 `'restored'` + - **重新选择**:进入重新选择模式 + +### 2.6 L3 搜索修复(配套改进) + +本次改造同时修复了导致位置偏移的根因: + +- `structureBoundaries` 过滤过度:从查询列表中移除 `div`,减少不必要的边界干扰 +- 回退机制:当 structureBoundaries 过滤死所有搜索路径时,自动回退到纯相似度搜索 +- `suggestRange` 和 `LocalAligner` 的溢出保护 + +## 3. 技术实现 (Technical Implementation) + +### 3.1 关键代码改动 + +| 文件 | 改动 | +|------|------| +| `src/logic/storage.ts` | Mark 接口添加 `recoveryStatus` | +| `src/logic/config.ts` | 新增 `highlightPendingConfirmStyle` | +| `src/contentScripts/restorer.ts` | 恢复逻辑按 confidence 分支;移除弹窗触发 | +| `src/contentScripts/state.ts` | 添加 `isRecalibrationMode` / `recalibrationMarkId` | +| `src/contentScripts/ui.ts` | 添加 `enterRecalibrationMode` / `updateMarkFromRecalibration` / `handleConfirmPosition` | +| `src/contentScripts/index.ts` | `processSelection` 支持 recalibration;tooltip 传递 mode | +| `src/contentScripts/views/Tooltip.vue` | 新增 `pending-confirm` 模式 UI | +| `src/sidepanel/Sidepanel.vue` | 新增"待恢复标记"区域 | + +### 3.2 消息流 + +``` +Sidepanel --recalibrate-mark--> Content Script --update-mark-details--> Background + | + v + Storage (recoveryStatus) +``` + +### 3.3 向后兼容 + +- 旧数据没有 `recoveryStatus` 字段 → 恢复时按现有逻辑处理,成功后写入 `'restored'` +- `DisambiguationModal.vue` 保留文件但不再被调用(Phase 6 可彻底移除) + +## 4. 测试策略 + +| 测试类型 | 内容 | +|----------|------| +| 单元测试 | restorer.spec.ts 更新:验证 confidence 返回值和 recoveryStatus 更新 | +| 集成测试 | cross-element.spec.ts, li_deletion.spec.ts:验证各场景下的恢复行为 | +| 手动测试 | 删除 li → 观察待恢复列表;修改少量文字 → 观察 pending-confirm 样式 | + +## 5. 风险与对策 + +| 风险 | 对策 | +|------|------| +| 旧用户数据无 recoveryStatus | 视为 undefined,恢复成功后自动写入 | +| 重新选择模式与新建标记手势冲突 | Recalibration 模式下优先处理重新标记 | +| 待确认样式与页面背景冲突 | 使用虚线边框 + 透明度 + 琥珀色提示,区分度足够 | diff --git a/src/contentScripts/index.ts b/src/contentScripts/index.ts index a4e6d76..cedd2ac 100644 --- a/src/contentScripts/index.ts +++ b/src/contentScripts/index.ts @@ -66,14 +66,7 @@ async function initialize() { ui.ensureMounted() window.addEventListener('keydown', handleKeyDown) attachListenersToShadowRoots(document) - const ambiguous = await restorer.restoreHighlights() - if (ambiguous.length > 0 && !state.modalState.visible) { - setTimeout(() => { - if (state.ambiguousMarksQueue.value.length > 0 && !state.modalState.visible) { - state.disambiguationModalApp?.show(state.ambiguousMarksQueue.value) - } - }, 1000) - } + await restorer.restoreHighlights() { const hash = window.location.hash if (hash.startsWith('#__highlight-mark__')) { @@ -163,7 +156,7 @@ function findContainingBlock(node: Node): HTMLElement { return node as HTMLElement } -function processSelection(event: { +async function processSelection(event: { target: EventTarget | null path: EventTarget[] clientX: number @@ -192,6 +185,24 @@ function processSelection(event: { const isNewSelectionAction = event.altKey && !initialSelection.isCollapsed if (isNewSelectionAction) { + // 重新选择模式:用当前选区更新已有 mark + if (state.isRecalibrationMode && state.recalibrationMarkId) { + const freshSelection = rangy.getSelection() + if (freshSelection.rangeCount > 0 && !freshSelection.isCollapsed) { + const range = freshSelection.getRangeAt(0) + const capturedText = range.toString().trim() + if (capturedText) { + try { + await ui.updateMarkFromRecalibration(state.recalibrationMarkId, range, capturedText) + ui.exitRecalibrationMode() + } catch (e) { + console.error('[WebMarker] Error during recalibration:', e) + } + } + } + return + } + ui.clearPreviewHighlight() let range: rangy.RangyRange | null = null if (event.detail >= 3) { @@ -274,7 +285,9 @@ async function showTooltipForExistingMark(markId: string, x: number, y: number) const color = mark ? mark.color : settings.value.defaultHighlightColor const tags = mark ? mark.tags : undefined ui.setOriginalColorForChange(color) - state.tooltipApp?.show(x, y, true, note, color, mark?.text ?? '', tags) + // 根据 recoveryStatus 决定 tooltip 模式 + const mode = mark?.recoveryStatus === 'pending-confirm' ? 'pending-confirm' : 'edit' + ui.showTooltip(x, y, true, note, color, mark?.text ?? '', tags || [], mode, markId) } // #endregion @@ -313,4 +326,15 @@ onMessage('goto-chapter', ({ data }) => { } } }) +onMessage('recalibrate-mark', async ({ data }) => { + const { markId, originalText, contextSelector } = data + // 先尝试滚动到原标记上下文附近 + if (contextSelector) { + const element = querySelectorDeep(contextSelector) + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + ui.enterRecalibrationMode(markId, originalText) +}) // #endregion diff --git a/src/contentScripts/monitor.ts b/src/contentScripts/monitor.ts index 283f0a7..c6c418e 100644 --- a/src/contentScripts/monitor.ts +++ b/src/contentScripts/monitor.ts @@ -21,8 +21,10 @@ export class ContentChangeMonitor { setupBodyObserver() { this.observer = new MutationObserver((mutations) => { - const hasAddedNodes = mutations.some((m) => m.addedNodes.length > 0) - if (!hasAddedNodes) return + const hasStructuralChange = mutations.some((m) => + m.addedNodes.length > 0 || m.removedNodes.length > 0, + ) + if (!hasStructuralChange) return this.debouncedRestore() }) this.observer.observe(document.body, { childList: true, subtree: true }) diff --git a/src/contentScripts/restorer.ts b/src/contentScripts/restorer.ts index 205a4ee..7e8a7f3 100644 --- a/src/contentScripts/restorer.ts +++ b/src/contentScripts/restorer.ts @@ -1,7 +1,7 @@ import { sendMessage } from 'webext-bridge/content-script' import rangy from 'rangy/lib/rangy-core' import type { Mark } from '~/logic/storage' -import { highlightDefaultStyle } from '~/logic/config' +import { highlightDefaultStyle, highlightPendingConfirmStyle } from '~/logic/config' import { settings } from '~/logic/settings' import { applyPreciseHighlight, @@ -39,6 +39,7 @@ function reportRestoreFailure(mark: Mark, reason: string, detail?: any) { interface SearchRestoreResult { success: boolean + confidence?: 'high' | 'medium' | 'low' candidates?: Candidate[] } @@ -47,13 +48,16 @@ export class HighlightRestorer { private state: HighlightStateManager, ) {} - async restoreHighlights(): Promise { - if (this.state.isRestoring) return this.state.ambiguousMarksQueue.value + async restoreHighlights(): Promise { + if (this.state.isRestoring) return this.state.isRestoring = true try { const canonicalUrl = getCanonicalUrlForMark() const marks = await sendMessage('get-marks-for-url', { url: canonicalUrl }, 'background') - if (!marks || marks.length === 0) return this.state.ambiguousMarksQueue.value + if (!marks || marks.length === 0) return + + // 健康检查:清理已损坏的高亮,允许重新恢复 + this.sanitizeRestoredHighlights(marks) const now = Date.now() const marksToRestore = marks.filter((mark) => { @@ -75,8 +79,6 @@ export class HighlightRestorer { }) if (marksToRestore.length > 0) await this.applyMarksTwoPhases(marksToRestore) - - return this.state.ambiguousMarksQueue.value } finally { this.state.isRestoring = false } @@ -103,7 +105,8 @@ export class HighlightRestorer { applier.applyToRange(range) this.state.restoredMarkIds.add(mark.id) this.state.failedRestoreCooldowns.delete(mark.id) - this.state.removeFromAmbiguousQueue(mark.id) + // L1 路径还原成功 → 高可信 + await this.persistRecoveryStatus(mark, 'restored') continue } } catch { @@ -124,8 +127,23 @@ export class HighlightRestorer { if (this.state.restoredMarkIds.has(mark.id)) continue const result = await this.restoreBySearch(mark) - if (!result.success && result.candidates) { - this.state.addToAmbiguousQueue(result.candidates) + + if (result.success) { + if (result.confidence === 'high') { + this.state.restoredMarkIds.add(mark.id) + this.state.failedRestoreCooldowns.delete(mark.id) + await this.persistRecoveryStatus(mark, 'restored') + } else if (result.confidence === 'medium') { + this.state.restoredMarkIds.add(mark.id) + this.state.failedRestoreCooldowns.delete(mark.id) + await this.persistRecoveryStatus(mark, 'pending-confirm') + } + } else { + // low confidence / no candidates / multiple candidates → 需要重新校准 + await this.persistRecoveryStatus(mark, 'needs-recalibration') + if (result.candidates && result.candidates.length === 0) { + this.state.failedRestoreCooldowns.set(mark.id, Date.now() + 3000) + } } // 每处理两个标记让出一次主线程,确保页面交互流畅 @@ -133,6 +151,19 @@ export class HighlightRestorer { } } + private async persistRecoveryStatus(mark: Mark, status: Mark['recoveryStatus']) { + if (mark.recoveryStatus === status) return + try { + await sendMessage('update-mark-details', { + id: mark.id, + url: mark.url, + recoveryStatus: status, + } as any, 'background') + } catch (e) { + console.warn(`[HighlightRestorer] Failed to persist recoveryStatus for ${mark.id}:`, e) + } + } + private validateRange(range: rangy.RangyRange, mark: Mark): boolean { const rangeText = range.toString().trim() const markText = mark.text.trim() @@ -166,15 +197,11 @@ export class HighlightRestorer { } private async restoreBySearch(mark: Mark): Promise { - const applier = rangy.createClassApplier(`webext-highlight-${mark.id}`, { - elementTagName: 'span', - elementAttributes: { style: highlightDefaultStyle(mark.color) }, - }) const deserializationRoot = this.getDeserializationRoot(mark) if (mark.shadowHostSelector && !deserializationRoot) { console.warn(`[HighlightRestorer] Shadow host not found for ${mark.id}, skipping search fallback.`) reportRestoreFailure(mark, 'Shadow host missing', { selector: mark.shadowHostSelector }) - return { success: false } + return { success: false, confidence: 'low' } } const root = deserializationRoot || document.documentElement @@ -193,7 +220,12 @@ export class HighlightRestorer { ? calculateSimilarity(candidate.surroundingSnippet, mark.surroundingSnippet) : 100 - if (similarity >= L3_SIMILARITY_THRESHOLD) { + if (similarity >= 95) { + // 高可信:应用默认样式 + const applier = rangy.createClassApplier(`webext-highlight-${mark.id}`, { + elementTagName: 'span', + elementAttributes: { style: highlightDefaultStyle(mark.color) }, + }) const rangeResult = applyPreciseHighlight( candidate.candidateElement, candidate.displayTextSnippet, @@ -201,59 +233,76 @@ export class HighlightRestorer { candidate.matchIndex, ) if (rangeResult) { - // ... 成功逻辑 ... - const { range } = rangeResult - this.state.restoredMarkIds.add(mark.id) - this.state.failedRestoreCooldowns.delete(mark.id) - this.state.removeFromAmbiguousQueue(mark.id) - - const root = candidate.candidateElement.getRootNode() - const newSerialized = rangy.serializeRange(range, true, root instanceof ShadowRoot ? root : undefined) - const { contextTitle, contextSelector, contextLevel, contextOrder, surroundingSnippet } = getHighlightContext(range) - let shadowHostSelector: string | undefined - if (root instanceof ShadowRoot) { - const chain: string[] = [] - let currRoot: Node = root - while (currRoot instanceof ShadowRoot) { - chain.unshift(getElementSelector(currRoot.host)) - currRoot = currRoot.host.getRootNode() - } - shadowHostSelector = chain.join('|>>>|') - } - const content = range.cloneContents() - stripHighlights(content) - const tempDiv = document.createElement('div') - tempDiv.appendChild(content) - const actualHtml = content.constructor === DocumentFragment ? tempDiv.innerHTML : range.toString() - - if (similarity >= 90) { - const newDomIndex = DOMScanner.calculatePreciseOffset(range, root instanceof ShadowRoot ? root : document.body) - await sendMessage('update-mark-details', { - id: mark.id, url: mark.url, text: candidate.displayTextSnippet, - html: actualHtml, rangySerialized: newSerialized, - shadowHostSelector: shadowHostSelector || null, - contextTitle, contextSelector, contextLevel, contextOrder, surroundingSnippet, - domIndex: newDomIndex, - } as any, 'background') - } - return { success: true } + await this.persistSearchSuccess(mark, candidate, rangeResult.range, similarity) + return { success: true, confidence: 'high' } } - // applyPreciseHighlight 失败 reportRestoreFailure(mark, 'Apply highlight failed', { similarity }) - return { success: false, candidates: [candidate] } + return { success: false, confidence: 'low', candidates: [candidate] } + } else if (similarity >= 85) { + // 中可信:应用 pending-confirm 样式 + const applier = rangy.createClassApplier(`webext-highlight-${mark.id}`, { + elementTagName: 'span', + elementAttributes: { style: highlightPendingConfirmStyle(mark.color) }, + }) + const rangeResult = applyPreciseHighlight( + candidate.candidateElement, + candidate.displayTextSnippet, + applier, + candidate.matchIndex, + ) + if (rangeResult) { + await this.persistSearchSuccess(mark, candidate, rangeResult.range, similarity) + return { success: true, confidence: 'medium' } + } + reportRestoreFailure(mark, 'Apply highlight failed (pending-confirm)', { similarity }) + return { success: false, confidence: 'low', candidates: [candidate] } } - // similarity 不足 - reportRestoreFailure(mark, 'Similarity too low', { similarity, threshold: L3_SIMILARITY_THRESHOLD }) - return { success: false, candidates: [candidate] } + // similarity < 85,低可信 + reportRestoreFailure(mark, 'Similarity too low', { similarity, threshold: 85 }) + return { success: false, confidence: 'low', candidates: [candidate] } } else if (ambiguityLevel === 'multiple') { - return { success: false, candidates } + return { success: false, confidence: 'low', candidates } } else { reportRestoreFailure(mark, 'No candidates found') - this.state.failedRestoreCooldowns.set(mark.id, Date.now() + 3000) - return { success: false } + return { success: false, confidence: 'low' } } } + private async persistSearchSuccess( + mark: Mark, + candidate: Candidate, + range: rangy.RangyRange, + similarity: number, + ) { + const root = candidate.candidateElement.getRootNode() + const newSerialized = rangy.serializeRange(range, true, root instanceof ShadowRoot ? root : undefined) + const { contextTitle, contextSelector, contextLevel, contextOrder, surroundingSnippet } = getHighlightContext(range) + let shadowHostSelector: string | undefined + if (root instanceof ShadowRoot) { + const chain: string[] = [] + let currRoot: Node = root + while (currRoot instanceof ShadowRoot) { + chain.unshift(getElementSelector(currRoot.host)) + currRoot = currRoot.host.getRootNode() + } + shadowHostSelector = chain.join('|>>>|') + } + const content = range.cloneContents() + stripHighlights(content) + const tempDiv = document.createElement('div') + tempDiv.appendChild(content) + const actualHtml = content.constructor === DocumentFragment ? tempDiv.innerHTML : range.toString() + + const newDomIndex = DOMScanner.calculatePreciseOffset(range, root instanceof ShadowRoot ? root : document.body) + await sendMessage('update-mark-details', { + id: mark.id, url: mark.url, text: candidate.displayTextSnippet, + html: actualHtml, rangySerialized: newSerialized, + shadowHostSelector: shadowHostSelector || null, + contextTitle, contextSelector, contextLevel, contextOrder, surroundingSnippet, + domIndex: newDomIndex, + } as any, 'background') + } + /** * @deprecated Use applyMarksTwoPhases instead. */ @@ -262,6 +311,37 @@ export class HighlightRestorer { await this.applyMarksTwoPhases(marks) } + /** + * 健康检查:遍历已恢复的标记,若高亮元素残缺(文本相似度 < 90%),则清理旧高亮并允许重新恢复。 + */ + private sanitizeRestoredHighlights(marks: Mark[]) { + for (const mark of marks) { + if (!this.state.restoredMarkIds.has(mark.id)) continue + const existingHighlights = querySelectorAllDeep(`.webext-highlight-${mark.id}`) + if (existingHighlights.length === 0) { + this.state.restoredMarkIds.delete(mark.id) + continue + } + const currentText = existingHighlights.map(el => el.textContent || '').join('').trim() + const markText = mark.text.trim() + const similarity = calculateSimilarity(currentText, markText) + if (similarity < 90) { + const parentsToNormalize = new Set() + existingHighlights.forEach((el) => { + if (el.classList.contains('webext-highlight-preview')) return + const parent = el.parentNode + if (parent) { + parentsToNormalize.add(parent) + while (el.firstChild) parent.insertBefore(el.firstChild, el) + parent.removeChild(el) + } + }) + parentsToNormalize.forEach((parent) => parent.normalize()) + this.state.restoredMarkIds.delete(mark.id) + } + } + } + async refreshHighlights() { const highlights = querySelectorAllDeep('span[class*="webext-highlight-"]') const parentsToNormalize = new Set() diff --git a/src/contentScripts/state.ts b/src/contentScripts/state.ts index 25f134f..087ae31 100644 --- a/src/contentScripts/state.ts +++ b/src/contentScripts/state.ts @@ -26,6 +26,8 @@ export class HighlightStateManager { currentMarkIdForColorChange: string | null = null originalColorForChange: string | null = null previewApplier: any = null + isRecalibrationMode = false + recalibrationMarkId: string | null = null isRestored(id: string): boolean { return this.restoredMarkIds.has(id) diff --git a/src/contentScripts/ui.ts b/src/contentScripts/ui.ts index 6f0bab8..e61d4f0 100644 --- a/src/contentScripts/ui.ts +++ b/src/contentScripts/ui.ts @@ -52,6 +52,8 @@ export class UIManager { onDelete: () => this.handleDelete(), onColorChange: (color: string, isExisting: boolean) => this.handleColorChange(color, isExisting), onClearPreview: () => this.clearPreviewWithColorRestore(), + onConfirmPosition: (markId: string) => this.handleConfirmPosition(markId), + onRecalibrate: (markId: string) => this.handleRecalibrate(markId), }).mount(tooltipRoot) const modalRoot = document.createElement('div') @@ -114,6 +116,131 @@ export class UIManager { parentsToNormalize.forEach((parent) => parent.normalize()) } + async updateMarkFromRecalibration(markId: string, range: rangy.RangyRange, capturedText: string): Promise { + const mark = await sendMessage('get-mark-by-id', { id: markId, url: getCanonicalUrlForMark() }, 'background') + if (!mark) return + + // 先移除旧的高亮 + await this.removeMarkById(markId) + + // 应用新的高亮 + const applier = rangy.createClassApplier(`webext-highlight-${markId}`, { + elementTagName: 'span', + elementAttributes: { style: highlightDefaultStyle(mark.color) }, + }) + applier.applyToRange(range) + + // 序列化新的路径 + const root = range.commonAncestorContainer.getRootNode() + const newSerialized = rangy.serializeRange(range, true, root instanceof ShadowRoot ? root : undefined) + const { contextTitle, contextSelector, contextLevel, contextOrder, surroundingSnippet } = getHighlightContext(range) + const domIndex = DOMScanner.calculatePreciseOffset(range, root instanceof ShadowRoot ? root : document.body) + + let shadowHostSelector: string | undefined + if (root instanceof ShadowRoot) { + const chain: string[] = [] + let currRoot: Node = root + while (currRoot instanceof ShadowRoot) { + chain.unshift(getElementSelector(currRoot.host)) + currRoot = currRoot.host.getRootNode() + } + shadowHostSelector = chain.join('|>>>|') + } + + const content = range.cloneContents() + stripHighlights(content) + const tempDiv = document.createElement('div') + tempDiv.appendChild(content) + const selectedHtml = content.constructor === DocumentFragment ? tempDiv.innerHTML : range.toString() + + await sendMessage('update-mark-details', { + id: markId, + url: mark.url, + text: capturedText, + html: selectedHtml, + rangySerialized: newSerialized, + shadowHostSelector: shadowHostSelector || null, + domIndex, + contextTitle, + contextSelector, + contextLevel, + contextOrder, + surroundingSnippet, + recoveryStatus: 'restored', + } as any, 'background') + } + + private async handleConfirmPosition(markId: string) { + // 用户确认 pending-confirm 标记位置正确 → 恢复为默认样式并更新状态 + const mark = await sendMessage('get-mark-by-id', { id: markId, url: getCanonicalUrlForMark() }, 'background') + if (!mark) return + // 更新样式为默认样式 + querySelectorAllDeep(`.webext-highlight-${markId}`).forEach((el) => { + if (el instanceof HTMLElement) { + el.style.boxShadow = `inset 0 -5px 0 0 ${mark.color}` + el.style.borderBottom = '' + el.style.opacity = '' + } + }) + await sendMessage('update-mark-details', { + id: markId, + url: mark.url, + recoveryStatus: 'restored', + } as any, 'background') + } + + private async handleRecalibrate(markId: string) { + const mark = await sendMessage('get-mark-by-id', { id: markId, url: getCanonicalUrlForMark() }, 'background') + if (!mark) return + // 进入重新选择模式 + this.enterRecalibrationMode(markId, mark.text) + } + + enterRecalibrationMode(markId: string, originalText: string): void { + this.state.isRecalibrationMode = true + this.state.recalibrationMarkId = markId + // 显示浮动提示 + this.showRecalibrationPrompt(originalText) + } + + exitRecalibrationMode(): void { + this.state.isRecalibrationMode = false + this.state.recalibrationMarkId = null + this.hideRecalibrationPrompt() + } + + private recalibrationPromptEl: HTMLElement | null = null + + private showRecalibrationPrompt(originalText: string): void { + this.hideRecalibrationPrompt() + const el = document.createElement('div') + el.className = 'webext-recalibration-prompt' + el.style.cssText = ` + position: fixed; top: 16px; left: 50%; transform: translateX(-50%); + z-index: 2147483647; background: #1e40af; color: white; + padding: 10px 20px; border-radius: 8px; font-size: 14px; + font-family: sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.3); + display: flex; align-items: center; gap: 12px; max-width: 80%; + ` + const snippet = originalText.length > 40 ? originalText.substring(0, 40) + '...' : originalText + el.innerHTML = ` + 📝 请选中原标记「${snippet}」对应的文本,然后按 Alt+点击确认 + + ` + el.querySelector('.webext-recalibration-cancel')?.addEventListener('click', () => { + this.exitRecalibrationMode() + }) + document.body.appendChild(el) + this.recalibrationPromptEl = el + } + + private hideRecalibrationPrompt(): void { + if (this.recalibrationPromptEl && this.recalibrationPromptEl.parentNode) { + this.recalibrationPromptEl.parentNode.removeChild(this.recalibrationPromptEl) + } + this.recalibrationPromptEl = null + } + private async handleDiscardMark(markId: string) { if (confirm('确定要彻底丢弃此标记吗?')) { await this.removeMarkById(markId) @@ -246,11 +373,11 @@ export class UIManager { parentsToNormalize.forEach((parent) => parent.normalize()) } - showTooltip(x: number, y: number, isHighlighted: boolean, note: string, color: string, text: string, tags: string[] = []): void { + showTooltip(x: number, y: number, isHighlighted: boolean, note: string, color: string, text: string, tags: string[] = [], mode: 'create' | 'edit' | 'pending-confirm' = 'create', markId = ''): void { clearTimeout(this._tooltipDebounceTimer) this._tooltipDebounceTimer = window.setTimeout(() => { this.ensureMounted() - this.state.tooltipApp?.show(x, y, isHighlighted, note, color, text, tags) + this.state.tooltipApp?.show(x, y, isHighlighted, note, color, text, tags, mode, markId) }, 50) } diff --git a/src/contentScripts/views/Tooltip.vue b/src/contentScripts/views/Tooltip.vue index 6a6aa96..b0599b0 100644 --- a/src/contentScripts/views/Tooltip.vue +++ b/src/contentScripts/views/Tooltip.vue @@ -11,7 +11,13 @@ const emit = defineEmits<{ (e: 'delete'): void (e: 'colorChange', color: string, isExisting: boolean): void (e: 'clearPreview'): void + (e: 'confirmPosition', markId: string): void + (e: 'recalibrate', markId: string): void }>() + +type TooltipMode = 'create' | 'edit' | 'pending-confirm' +const mode = ref('create') +const currentMarkId = ref('') const visible = ref(false) const position = reactive({ x: 0, y: 0 }) const isHighlighted = ref(false) @@ -169,7 +175,11 @@ async function show( initialColor: string | undefined, initialTextToCopy = '', initialTags: string[] = [], + initialMode: TooltipMode = 'create', + markId = '', ) { + mode.value = initialMode + currentMarkId.value = markId // 异步获取最新标签,遵循 SSOT,不使用本地缓存 try { const tags = await sendMessage('get-all-tags', {}, 'background') @@ -200,6 +210,13 @@ async function show( selectedColor.value = initialColor || defaultHighlightColor.value textToCopy.value = initialTextToCopy visible.value = true + if (mode.value === 'pending-confirm') { + // pending-confirm 模式下不需要聚焦 textarea + nextTick(() => { + // 不聚焦任何输入框 + }) + return + } nextTick(() => { textareaRef.value?.focus() }) @@ -231,7 +248,7 @@ defineExpose({ show, hide }) @mousedown.stop >
-
+
+ +
+

+ + + + 此标记位置可能已变化,请确认是否准确 +

+
+