feat: refact recorder

This commit is contained in:
hangerye
2025-10-21 10:18:31 +08:00
parent efc366fe02
commit 3e186ef8ea
6 changed files with 179 additions and 12 deletions
@@ -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',
@@ -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);
}
@@ -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]);
}
@@ -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 };
}
@@ -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 {}
}
}
+142 -1
View File
@@ -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 @@
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input id="__rr_enable_highlight" type="checkbox" style="vertical-align:middle;" />高亮
</label>
<button id="__rr_toggle_timeline" style="background:transparent; color:#fff; border:1px solid rgba(255,255,255,0.5); border-radius:6px; padding:2px 6px; cursor:pointer; font-size:12px;">折叠</button>
<button id="__rr_pause" style="background:#fff; color:#111; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">暂停</button>
<button id="__rr_stop" style="background:#111; color:#fff; border:none; border-radius:6px; padding:4px 8px; cursor:pointer;">停止</button>
</div>`;
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 = `
<span style="min-width:20px; text-align:right; opacity:0.8;">${this._count}.</span>
<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;">${text}</span>
`;
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;