From 3e186ef8eac8ceff52aa06a8bba4c712c0601b06 Mon Sep 17 00:00:00 2001 From: hangerye Date: Tue, 21 Oct 2025 10:18:31 +0800 Subject: [PATCH] feat: refact recorder --- app/chrome-extension/common/message-types.ts | 2 + .../recording/browser-event-listener.ts | 4 +- .../record-replay/recording/flow-builder.ts | 11 +- .../recording/recorder-manager.ts | 8 +- .../recording/session-manager.ts | 23 ++- .../inject-scripts/recorder.js | 143 +++++++++++++++++- 6 files changed, 179 insertions(+), 12 deletions(-) diff --git a/app/chrome-extension/common/message-types.ts b/app/chrome-extension/common/message-types.ts index cf93fce..fb6cd99 100644 --- a/app/chrome-extension/common/message-types.ts +++ b/app/chrome-extension/common/message-types.ts @@ -107,6 +107,8 @@ export const TOOL_MESSAGE_TYPES = { // Record & Replay content script bridge RR_RECORDER_CONTROL: 'rr_recorder_control', RR_RECORDER_EVENT: 'rr_recorder_event', + // Record & Replay timeline feed (background -> content overlay) + RR_TIMELINE_UPDATE: 'rr_timeline_update', // DOM observer trigger bridge SET_DOM_TRIGGERS: 'set_dom_triggers', DOM_TRIGGER_FIRED: 'dom_trigger_fired', diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts index 60a26c9..d478ac1 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/browser-event-listener.ts @@ -1,4 +1,4 @@ -import { appendSteps, addNavigationStep } from './flow-builder'; +import { addNavigationStep } from './flow-builder'; import { STEP_TYPES } from '@/common/step-types'; import { ensureRecorderInjected, broadcastControlToTab, REC_CMD } from './content-injection'; import type { RecordingSessionManager } from './session-manager'; @@ -23,7 +23,7 @@ export function initBrowserEventListeners(session: RecordingSessionManager): voi type: STEP_TYPES.SWITCH_TAB, ...(url ? { urlContains: url } : {}), }; - appendSteps(flow, [step]); + session.appendSteps([step]); } catch (e) { console.warn('onActivated handler failed', e); } diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts index 370dc93..80f4315 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/flow-builder.ts @@ -1,5 +1,6 @@ import type { Flow, Step } from '../types'; import { STEP_TYPES } from '@/common/step-types'; +import { recordingSession } from './session-manager'; const WORKFLOW_VERSION = 1; @@ -47,6 +48,14 @@ export function appendSteps(flow: Flow, steps: Step[]): void { } export function addNavigationStep(flow: Flow, url: string): void { - const step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url }; + const step: Step = { id: generateStepId(), type: STEP_TYPES.NAVIGATE, url } as Step; + try { + // Prefer centralized session append (single broadcast path) when active and matching flow + const sessFlow = recordingSession.getFlow?.(); + if (recordingSession.getStatus?.() === 'recording' && sessFlow === flow) { + recordingSession.appendSteps([step]); + return; + } + } catch {} appendSteps(flow, [step]); } diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts index d7b2018..0593f47 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/recorder-manager.ts @@ -54,12 +54,14 @@ class RecorderManagerImpl { async stop(): Promise<{ success: boolean; error?: string; flow?: Flow }> { if (session.getStatus() === 'idle' || !session.getFlow()) return { success: false, error: 'No active recording' }; - // Best-effort STOP for all tabs that participated in this session + // Capture tabs first, then synchronously mark session as stopped + const tabs = session.getActiveTabs(); + const flow = await session.stopSession(); + // After state becomes idle, no further START should be triggered by listeners + // Best-effort STOP broadcast to remove overlays in all participating tabs try { - const tabs = session.getActiveTabs(); await Promise.all(tabs.map(async (id) => broadcastControlToTab(id, REC_CMD.STOP))); } catch {} - const flow = await session.stopSession(); if (flow) await saveFlow(flow); return flow ? { success: true, flow } : { success: true }; } diff --git a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts index 6fa59ac..2fec580 100644 --- a/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts +++ b/app/chrome-extension/entrypoints/background/record-replay/recording/session-manager.ts @@ -1,4 +1,6 @@ import type { Flow, Step } from '../types'; +import { TOOL_MESSAGE_TYPES } from '@/common/message-types'; +import { appendSteps as appendFlowSteps } from './flow-builder'; export type RecordingStatus = 'idle' | 'recording' | 'paused'; @@ -81,12 +83,23 @@ export class RecordingSessionManager { appendSteps(steps: Step[]): void { const f = this.state.flow; if (!f || !Array.isArray(steps) || steps.length === 0) return; - for (const st of steps) { - // id should be ensured by builder; push directly - f.steps.push(st); - } + // Reuse builder implementation (ids + meta update) for consistency + appendFlowSteps(f, steps); + this.broadcastTimelineUpdate(steps); + } + + // Broadcast timeline update to origin tab top-frame + broadcastTimelineUpdate(steps: Step[]): void { try { - (f.meta as any).updatedAt = new Date().toISOString(); + if (!steps || steps.length === 0) return; + const origin = this.state.originTabId; + if (origin != null) { + chrome.tabs.sendMessage( + origin, + { action: TOOL_MESSAGE_TYPES.RR_TIMELINE_UPDATE, steps }, + { frameId: 0 }, + ); + } } catch {} } } diff --git a/app/chrome-extension/inject-scripts/recorder.js b/app/chrome-extension/inject-scripts/recorder.js index 3210ac9..49be188 100644 --- a/app/chrome-extension/inject-scripts/recorder.js +++ b/app/chrome-extension/inject-scripts/recorder.js @@ -13,6 +13,7 @@ BATCH_SEND_MS: 80, SCROLL_DEBOUNCE_MS: 350, SENSITIVE_INPUT_TYPES: new Set(['password']), + UI_MAX_STEPS: 30, }; // Memoization caches for selector computations during recording @@ -151,6 +152,11 @@ constructor(recorder) { this.recorder = recorder; this._box = null; + // Timeline elements state + this._timeline = null; + this._count = 0; + this._timelineBox = null; + this._collapsed = false; } ensure() { const rec = this.recorder; @@ -175,14 +181,52 @@ + `; document.documentElement.appendChild(root); + // Build timeline container just below the panel + try { + const timeline = document.createElement('div'); + timeline.id = '__rr_rec_timeline'; + Object.assign(timeline.style, { + marginTop: '8px', + width: '360px', + maxHeight: '220px', + overflow: 'auto', + background: 'rgba(17,24,39,0.85)', + color: '#F9FAFB', + border: '1px solid rgba(255,255,255,0.2)', + borderRadius: '8px', + boxShadow: '0 4px 16px rgba(0,0,0,0.18)', + padding: '8px 10px', + fontSize: '12px', + lineHeight: '1.4', + }); + const header = document.createElement('div'); + header.textContent = '已录制步骤'; + header.style.opacity = '0.8'; + header.style.marginBottom = '4px'; + const list = document.createElement('ol'); + list.id = '__rr_rec_timeline_list'; + list.style.listStyle = 'none'; + list.style.margin = '0'; + list.style.padding = '0'; + list.style.display = 'flex'; + list.style.flexDirection = 'column'; + list.style.gap = '4px'; + timeline.appendChild(header); + timeline.appendChild(list); + root.appendChild(timeline); + this._timeline = list; + this._timelineBox = timeline; + } catch {} const btnPause = root.querySelector('#__rr_pause'); const btnStop = root.querySelector('#__rr_stop'); const hideChk = root.querySelector('#__rr_hide_values'); const highlightChk = root.querySelector('#__rr_enable_highlight'); + const btnToggle = root.querySelector('#__rr_toggle_timeline'); hideChk.checked = !!rec.hideInputValues; hideChk.addEventListener('change', () => (rec.hideInputValues = hideChk.checked)); highlightChk.checked = !!rec.highlightEnabled; @@ -190,11 +234,22 @@ rec.highlightEnabled = !!highlightChk.checked; rec._updateHoverListener(); }); + if (btnToggle) { + btnToggle.addEventListener('click', () => { + this._collapsed = !this._collapsed; + if (this._timelineBox) + this._timelineBox.style.display = this._collapsed ? 'none' : 'block'; + btnToggle.textContent = this._collapsed ? '展开' : '折叠'; + }); + } btnPause.addEventListener('click', () => { if (!rec.isPaused) rec.pause(); else rec.resume(); }); btnStop.addEventListener('click', () => { + try { + chrome.runtime.sendMessage({ type: 'rr_stop_recording' }); + } catch {} rec.stop(); }); this._box = document.createElement('div'); @@ -216,6 +271,8 @@ const root = document.getElementById('__rr_rec_overlay'); if (root) root.remove(); if (this._box) this._box.remove(); + this._timeline = null; + this._timelineBox = null; } } catch {} } @@ -225,6 +282,77 @@ if (badge) badge.textContent = this.recorder.isPaused ? '已暂停' : '录制中'; if (pauseBtn) pauseBtn.textContent = this.recorder.isPaused ? '继续' : '暂停'; } + + // Reset the timeline list content + resetTimeline() { + this._count = 0; + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (list) list.innerHTML = ''; + } + + // Append a new recorded step into the timeline UI + appendStep(step) { + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (!list) return; + this._count += 1; + const item = document.createElement('li'); + const text = this._formatStepText(step, this._count); + item.setAttribute('data-step-id', step.id || ''); + item.style.display = 'flex'; + item.style.alignItems = 'flex-start'; + item.style.gap = '6px'; + item.innerHTML = ` + ${this._count}. + ${text} + `; + list.appendChild(item); + while (list.children.length > CONFIG.UI_MAX_STEPS) { + list.removeChild(list.firstChild); + } + const container = list.parentElement; + if (container) container.scrollTop = container.scrollHeight; + } + + // Update the last timeline entry when a step is coalesced/modified + updateLastStep(step) { + const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null; + if (!list) return; + const last = list.lastElementChild; + if (!last) return; + const text = this._formatStepText(step, this._count); + const spans = last.querySelectorAll('span'); + if (spans && spans[1]) spans[1].textContent = text; + } + + // Create a short, human-readable text for a recorded step + _formatStepText(step, _idx) { + try { + if (!step || typeof step !== 'object') return '未知步骤'; + const t = step.type; + const sel = step.target && step.target.selector ? step.target.selector : ''; + if (t === 'click' || t === 'dblclick') { + return `${t === 'dblclick' ? '双击' : '点击'}: ${sel || '(document)'}`; + } + if (t === 'fill') { + const val = step.value; + const shown = typeof val === 'string' && val.length > 0 ? val : String(val); + return `输入: ${sel} = ${shown}`; + } + if (t === 'scroll') { + const mode = step.mode === 'container' ? '容器' : '页面'; + const off = step.offset || {}; + return `滚动(${mode}): y=${off.y ?? 0}, x=${off.x ?? 0}`; + } + if (t === 'openTab') return `打开标签页: ${step.url || ''}`; + if (t === 'switchTab') return `切换标签页: 包含 ${step.urlContains || ''}`; + if (t === 'switchFrame') + return `切换Frame: 包含 ${step.frame && step.frame.urlContains ? step.frame.urlContains : ''}`; + if (t === 'waitFor') return `等待: ${sel || step.until || ''}`; + return `${t}`; + } catch (_) { + return '步骤'; + } + } } // ================================================================ @@ -266,6 +394,7 @@ this.isPaused = false; this._attach(); this.ui.ensure(); + this.ui.resetTimeline(); this._send({ kind: 'start', flow: this.pendingFlow }); } @@ -363,7 +492,8 @@ try { if (window !== window.top && !this.frameSwitchPushed) { const href = String(location && location.href ? location.href : ''); - this.pendingFlow.steps.push({ type: 'switchFrame', frame: { urlContains: href } }); + const frameStep = { type: 'switchFrame', frame: { urlContains: href } }; + this.pendingFlow.steps.push(frameStep); this.frameSwitchPushed = true; } } catch {} @@ -661,6 +791,17 @@ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { try { if (!request || !request.action) return false; + if (request.action === 'rr_timeline_update') { + const rec = getRecorder(); + rec.ui.ensure(); + if (rec.isRecording && rec.pendingFlow && (!rec.ui._timeline || rec.ui._count === 0)) { + // no-op; rely purely on updates per design + } + const steps = Array.isArray(request.steps) ? request.steps : []; + for (const st of steps) rec.ui.appendStep(st); + sendResponse({ ok: true }); + return true; + } if (request.action === 'rr_recorder_control') { const rec = getRecorder(); const cmd = request.cmd;