feat: 卡顿问题修复

This commit is contained in:
hangerye
2025-10-16 10:10:06 +08:00
parent 0d3181ea29
commit 4436996c99
16 changed files with 688 additions and 115 deletions
@@ -97,17 +97,57 @@ async function rescheduleAlarms() {
}
async function ensureRecorderInjected(tabId: number): Promise<void> {
// Inject helper and recorder scripts into all frames to aggregate same-origin iframes
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
await chrome.scripting.executeScript({
target: { tabId, allFrames: true },
files: ['inject-scripts/recorder.js'],
world: 'ISOLATED',
} as any);
// Determine frames and selectively inject only where missing to minimize overhead.
let frames: Array<{ frameId: number } & Record<string, any>> = [];
try {
frames = await chrome.webNavigation.getAllFrames({ tabId });
} catch {}
if (!Array.isArray(frames) || frames.length === 0) frames = [{ frameId: 0 } as any];
const needHelper: number[] = [];
const needRecorder: number[] = [];
await Promise.all(
frames.map(async (f) => {
const frameId = (f as any).frameId ?? 0;
// ping helper
try {
const res = await chrome.tabs.sendMessage(
tabId,
{ action: 'chrome_read_page_ping' } as any,
{ frameId } as any,
);
if (!res) needHelper.push(frameId);
} catch {
needHelper.push(frameId);
}
// ping recorder
try {
const res = await chrome.tabs.sendMessage(
tabId,
{ action: 'rr_recorder_ping' } as any,
{ frameId } as any,
);
if (!res) needRecorder.push(frameId);
} catch {
needRecorder.push(frameId);
}
}),
);
if (needHelper.length > 0) {
await chrome.scripting.executeScript({
target: { tabId, frameIds: needHelper },
files: ['inject-scripts/accessibility-tree-helper.js'],
world: 'ISOLATED',
} as any);
}
if (needRecorder.length > 0) {
await chrome.scripting.executeScript({
target: { tabId, frameIds: needRecorder },
files: ['inject-scripts/recorder.js'],
world: 'ISOLATED',
} as any);
}
}
async function broadcastRecorderCommandToAllTabs(cmd: 'start' | 'stop' | 'resume' | 'pause') {
@@ -286,43 +326,47 @@ export function initRecordReplayListeners() {
}
} catch {}
}
} else if (message.payload?.kind === 'step') {
} else if (message.payload?.kind === 'step' || message.payload?.kind === 'steps') {
// Guard: only aggregate when an active recording session is ongoing
if (currentRecording && recordingActive) {
// 记录最近一次 click/dblclick 的索引和时间,用于后续 tabs.onUpdated 导航富化
const step = message.payload.step as any;
if (step && (step.type === 'click' || step.type === 'dblclick')) {
const stepsArr: any[] = Array.isArray(message.payload.steps)
? message.payload.steps
: [message.payload.step];
for (const step of stepsArr) {
// 记录最近一次 click/dblclick 的索引和时间,用于后续 tabs.onUpdated 导航富化
if (step && (step.type === 'click' || step.type === 'dblclick')) {
try {
const idx = currentRecording.flow?.steps?.length ?? 0;
lastClickIdx = idx;
lastClickTime = Date.now();
} catch {
// ignore
}
}
// 统一在后台累积步骤,便于跨标签页聚合
try {
const idx = currentRecording.flow?.steps?.length ?? 0;
lastClickIdx = idx;
lastClickTime = Date.now();
if (!currentRecording.flow) {
currentRecording.flow = {
id: `flow_${Date.now()}`,
name: '未命名录制',
version: 1,
steps: [],
variables: [],
meta: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
} as Flow;
}
currentRecording.flow.steps.push(step);
currentRecording.flow.meta = {
...(currentRecording.flow.meta || ({} as any)),
updatedAt: new Date().toISOString(),
} as any;
} catch {
// ignore
}
}
// 统一在后台累积步骤,便于跨标签页聚合
try {
if (!currentRecording.flow) {
currentRecording.flow = {
id: `flow_${Date.now()}`,
name: '未命名录制',
version: 1,
steps: [],
variables: [],
meta: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
} as Flow;
}
currentRecording.flow.steps.push(step);
currentRecording.flow.meta = {
...(currentRecording.flow.meta || ({} as any)),
updatedAt: new Date().toISOString(),
} as any;
} catch {
// ignore
}
}
} else if (message.payload?.kind === 'stop') {
// User clicked stop inside page overlay: finalize and persist
@@ -56,21 +56,46 @@ export async function ensureTab(options: {
tabTarget?: 'current' | 'new';
startUrl?: string;
refresh?: boolean;
}) {
}): Promise<{ tabId: number; url?: string }> {
const target = options.tabTarget || 'current';
const startUrl = options.startUrl;
const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
const isWebUrl = (u?: string | null) => !!u && /^(https?:|file:)/i.test(u);
const tabs = await chrome.tabs.query({ currentWindow: true });
const [active] = tabs.filter((t) => t.active);
if (target === 'new') {
let urlToOpen = startUrl;
if (!urlToOpen) urlToOpen = active?.url || 'about:blank';
await chrome.tabs.create({ url: urlToOpen, active: true });
await new Promise((r) => setTimeout(r, 500));
} else {
if (startUrl)
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } });
else if (options.refresh)
if (!urlToOpen) urlToOpen = isWebUrl(active?.url) ? active!.url! : 'about:blank';
const created = await chrome.tabs.create({ url: urlToOpen, active: true });
await new Promise((r) => setTimeout(r, 300));
return { tabId: created.id!, url: created.url };
}
// current tab target
if (startUrl) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { url: startUrl } });
} else if (options.refresh) {
// only refresh if current tab is a web page
if (isWebUrl(active?.url))
await handleCallTool({ name: TOOL_NAMES.BROWSER.NAVIGATE, args: { refresh: true } });
}
// Re-evaluate active after potential navigation
const cur = (await chrome.tabs.query({ active: true, currentWindow: true }))[0];
let tabId = cur?.id;
let url = cur?.url;
// If still on extension/internal page and no startUrl, try switch to an existing web tab
if (!isWebUrl(url) && !startUrl) {
const candidate = tabs.find((t) => isWebUrl(t.url));
if (candidate?.id) {
await chrome.tabs.update(candidate.id, { active: true });
tabId = candidate.id;
url = candidate.url;
}
}
return { tabId: tabId!, url };
}
export async function waitForNetworkIdle(totalTimeoutMs: number, idleThresholdMs: number) {
@@ -49,16 +49,33 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
for (const v of flow.variables || []) if (v.default !== undefined) vars[v.key] = v.default;
if (options.args) Object.assign(vars, options.args);
// ensure tab per options
await ensureTab({
// Derive a default startUrl when not provided: prefer first navigate step
let derivedStartUrl: string | undefined = undefined;
try {
// We haven't computed stepsToRun yet; compute minimal set from flow for derive
const hasDag0 = Array.isArray((flow as any).nodes) && (flow as any).nodes.length > 0;
const nodes0 = hasDag0 ? (((flow as any).nodes || []) as any[]) : [];
const edges0 = hasDag0 ? (((flow as any).edges || []) as any[]) : [];
const defaultEdges0 = hasDag0 ? defaultEdgesOnly(edges0 as any) : [];
const order0 = hasDag0 ? topoOrder(nodes0 as any, defaultEdges0 as any) : [];
const steps0: Step[] = hasDag0
? order0.map((n) => mapDagNodeToStep(n as any))
: ((flow.steps || []) as Step[]);
const nav = steps0.find((s: any) => s && (s as any).type === 'navigate') as any;
if (nav && typeof nav.url === 'string') derivedStartUrl = expandTemplatesDeep(nav.url, {});
} catch {}
const ensured = await ensureTab({
tabTarget: options.tabTarget,
startUrl: options.startUrl,
startUrl: options.startUrl || derivedStartUrl,
refresh: options.refresh,
});
// pre-load read_page to init bridges
// pre-load read_page to init bridges only when on a web page (avoid builder.html)
try {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
const u = ensured?.url || '';
if (/^(https?:|file:)/i.test(u)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
// collect required variables via overlay prompt
@@ -267,6 +284,12 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await appendOverlay(`${step.type} (${step.id})`);
@@ -309,6 +332,16 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
const waitForNavigationDone = async (prevUrl: string, timeoutMs?: number) => {
await waitForNavigation(timeoutMs, prevUrl);
};
const isWebUrl = (u?: string | null) => !!u && /^(https?:|file:)/i.test(String(u || ''));
const ensureReadPageIfWeb = async () => {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tabs?.[0]?.url || '';
if (isWebUrl(url)) {
await handleCallTool({ name: TOOL_NAMES.BROWSER.READ_PAGE, args: {} });
}
} catch {}
};
try {
if (!hasDag) {
@@ -346,6 +379,12 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged) {
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
}
@@ -477,6 +516,12 @@ export async function runFlow(flow: Flow, options: RunOptions = {}): Promise<Run
else if (after.waitForNetworkIdle)
await waitForNetworkIdle(Math.min((step as any).timeoutMs || 5000, 120000), 1200);
}
if (step.type === 'navigate' || step.type === 'openTab') {
await waitForNavigationDone(beforeInfo.url, (step as any).timeoutMs);
await ensureReadPageIfWeb();
} else if (step.type === 'switchTab') {
await ensureReadPageIfWeb();
}
if (!result?.alreadyLogged)
logs.push({ stepId: step.id, status: 'success', tookMs: Date.now() - t0 });
await appendOverlay(`${step.type} (${step.id})`);
@@ -17,25 +17,8 @@ export async function locateElement(
target: TargetLocator,
frameId?: number,
): Promise<LocatedElement | null> {
// Try ref first
if (target.ref) {
try {
const res = await chrome.tabs.sendMessage(
tabId,
{
action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
ref: target.ref,
} as any,
{ frameId } as any,
);
if (res && res.success && res.center) {
return { ref: target.ref, center: res.center, resolvedBy: 'ref' };
}
} catch (e) {
// ignore and fallback
}
}
// Try candidates in order
// Prefer stable selectors (css/attr/aria/text/xpath); only use ref as a last resort.
// Rationale: ref is ephemeral across navigations/renders and should not be prioritized in replay.
for (const c of target.candidates || []) {
try {
if (c.type === 'css' || c.type === 'attr') {
@@ -126,6 +109,24 @@ export async function locateElement(
// continue to next candidate
}
}
// Fallback: try ref (works when ref was produced in the same page lifecycle)
if (target.ref) {
try {
const res = await chrome.tabs.sendMessage(
tabId,
{
action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
ref: target.ref,
} as any,
{ frameId } as any,
);
if (res && res.success && res.center) {
return { ref: target.ref, center: res.center, resolvedBy: 'ref' };
}
} catch (e) {
// ignore
}
}
return null;
}
@@ -1377,11 +1377,28 @@ onMounted(async () => {
await checkSemanticEngineStatus();
setupServerStatusListener();
// Auto-refresh workflows list when storage rr_flows changes
try {
const onChanged = (changes: any, area: string) => {
try {
if (area !== 'local') return;
if (Object.prototype.hasOwnProperty.call(changes || {}, 'rr_flows')) loadFlows();
} catch {}
};
chrome.storage.onChanged.addListener(onChanged);
(window as any).__rr_popup_onChanged = onChanged;
} catch {}
});
onUnmounted(() => {
stopModelStatusMonitoring();
stopSemanticEngineStatusPolling();
try {
const fn = (window as any).__rr_popup_onChanged;
if (fn && chrome?.storage?.onChanged?.removeListener) {
chrome.storage.onChanged.removeListener(fn);
}
} catch {}
});
</script>
@@ -410,6 +410,12 @@ defineExpose({ zoomIn, zoomOut, fitAll });
:deep(.icon-dblclick) {
background: #fe5196;
}
:deep(.icon-drag) {
background: #f97316;
}
:deep(.icon-scroll) {
background: #0ea5e9;
}
:deep(.icon-openTab),
:deep(.icon-switchTab),
:deep(.icon-closeTab) {
@@ -302,6 +302,12 @@ const filtered = computed(() => {
background: #8ec5fc;
color: #111;
}
.icon-scroll {
background: #0ea5e9;
}
.icon-drag {
background: #f97316;
}
.icon-assert {
background: #16a34a;
}
@@ -24,6 +24,8 @@ import ILucideBell from '~icons/lucide/bell';
import ILucideWrench from '~icons/lucide/wrench';
import ILucideFrame from '~icons/lucide/frame';
import ILucideDownload from '~icons/lucide/download';
import ILucideArrowUpDown from '~icons/lucide/arrow-up-down';
import ILucideMoveVertical from '~icons/lucide/move-vertical';
export function iconComp(t?: string) {
switch (t) {
@@ -34,6 +36,10 @@ export function iconComp(t?: string) {
return ILucideMousePointerClick;
case 'fill':
return ILucideEdit3;
case 'drag':
return ILucideArrowUpDown;
case 'scroll':
return ILucideMoveVertical;
case 'key':
return ILucideKeyboard;
case 'navigate':
@@ -93,11 +99,13 @@ export function getTypeLabel(type?: string) {
foreach: '循环',
assert: '断言',
key: '键盘',
drag: '拖拽',
dblclick: '双击',
openTab: '打开标签',
switchTab: '切换标签',
closeTab: '关闭标签',
delay: '延迟',
scroll: '滚动',
while: '循环',
};
return labels[String(type || '')] || type || '';
@@ -0,0 +1,24 @@
<template>
<div>
<SelectorEditor :node="node" :allowPick="true" title="起点选择器" targetKey="start" />
<SelectorEditor :node="node" :allowPick="true" title="终点选择器" targetKey="end" />
<div class="hint">
<small>提示路径path通常在录制时自动生成手动创建时可留空</small>
</div>
</div>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
import SelectorEditor from './SelectorEditor.vue';
defineProps<{ node: NodeBase }>();
</script>
<style scoped>
.hint {
color: #64748b;
margin-top: 8px;
}
</style>
@@ -0,0 +1,99 @@
<template>
<div>
<div class="form-row">
<label class="form-label">模式</label>
<select v-model="cfg.mode" class="form-select-sm">
<option value="element">滚动到元素</option>
<option value="offset">窗口偏移</option>
<option value="container">容器偏移</option>
</select>
</div>
<div v-if="cfg.mode === 'element'" class="mt-2">
<SelectorEditor :node="node" :allowPick="true" title="目标元素" targetKey="target" />
</div>
<div v-if="cfg.mode !== 'element'" class="mt-2">
<div class="form-row">
<label class="form-label">偏移 X</label>
<input type="number" class="form-input-sm" v-model.number="cfg.offset.x" placeholder="0" />
</div>
<div class="form-row">
<label class="form-label">偏移 Y</label>
<input
type="number"
class="form-input-sm"
v-model.number="cfg.offset.y"
placeholder="300"
/>
</div>
<div v-if="cfg.mode === 'container'" class="mt-2">
<SelectorEditor :node="node" :allowPick="true" title="容器选择器" targetKey="target" />
<div class="hint"><small>容器需支持 scrollTo(top,left)</small></div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
/* eslint-disable vue/no-mutating-props */
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
import SelectorEditor from './SelectorEditor.vue';
const props = defineProps<{ node: NodeBase }>();
function ensure() {
const n: any = props.node;
n.config = n.config || {};
if (!n.config.mode) n.config.mode = 'offset';
if (!n.config.offset) n.config.offset = { x: 0, y: 300 };
if (!n.config.target) n.config.target = { candidates: [] };
}
const cfg = {
get mode() {
ensure();
return (props.node as any).config.mode;
},
set mode(v: any) {
ensure();
(props.node as any).config.mode = v;
},
get offset() {
ensure();
return (props.node as any).config.offset;
},
set offset(v: any) {
ensure();
(props.node as any).config.offset = v;
},
} as any;
</script>
<style scoped>
.hint {
color: #64748b;
margin-top: 8px;
}
.mt-2 {
margin-top: 8px;
}
.form-row {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0;
}
.form-label {
width: 80px;
color: #334155;
font-size: 12px;
}
.form-input-sm,
.form-select-sm {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--rr-border);
border-radius: 6px;
}
</style>
@@ -1,7 +1,7 @@
<template>
<div class="form-section">
<div class="section-header">
<span class="section-title">选择器</span>
<span class="section-title">{{ title || '选择器' }}</span>
<button v-if="allowPick" class="btn-sm btn-primary" @click="pickFromPage">从页面选择</button>
</div>
<div class="selector-list" data-field="target.candidates">
@@ -27,19 +27,25 @@
/* eslint-disable vue/no-mutating-props */
import type { NodeBase } from '@/entrypoints/background/record-replay/types';
const props = defineProps<{ node: NodeBase; allowPick?: boolean }>();
const props = defineProps<{
node: NodeBase;
allowPick?: boolean;
targetKey?: string;
title?: string;
}>();
const key = (props.targetKey || 'target') as string;
function ensureTarget() {
const n: any = props.node;
if (!n.config) n.config = {};
if (!n.config.target) n.config.target = { candidates: [] };
if (!Array.isArray(n.config.target.candidates)) n.config.target.candidates = [];
if (!n.config[key]) n.config[key] = { candidates: [] };
if (!Array.isArray(n.config[key].candidates)) n.config[key].candidates = [];
}
const list = {
get value() {
ensureTarget();
return ((props.node as any).config.target.candidates || []) as Array<{
return ((props.node as any).config[key].candidates || []) as Array<{
type: string;
value: string;
}>;
@@ -48,15 +54,15 @@ const list = {
function add() {
ensureTarget();
(props.node as any).config.target.candidates.push({ type: 'css', value: '' });
(props.node as any).config[key].candidates.push({ type: 'css', value: '' });
}
function remove(i: number) {
ensureTarget();
(props.node as any).config.target.candidates.splice(i, 1);
(props.node as any).config[key].candidates.splice(i, 1);
}
function move(i: number, d: number) {
ensureTarget();
const arr = (props.node as any).config.target.candidates as any[];
const arr = (props.node as any).config[key].candidates as any[];
const j = i + d;
if (j < 0 || j >= arr.length) return;
const t = arr[i];
@@ -101,7 +107,7 @@ async function pickFromPage() {
merged.push({ type: String(c.type), value: String(c.value) });
}
}
n.config.target.candidates = merged;
n.config[key].candidates = merged;
} catch (e) {
console.warn('pickFromPage failed:', e);
}
@@ -28,6 +28,9 @@ export function defaultConfigFor(t: NodeType): any {
if (t === 'http') return { method: 'GET', url: '', headers: {}, body: null, saveAs: '' };
if (t === 'extract') return { selector: '', attr: 'text', js: '', saveAs: '' };
if (t === 'screenshot') return { selector: '', fullPage: false, saveAs: 'shot' };
if (t === 'drag') return { start: { candidates: [] }, end: { candidates: [] }, path: [] };
if (t === 'scroll')
return { mode: 'offset', offset: { x: 0, y: 300 }, target: { candidates: [] } };
if (t === 'triggerEvent')
return { target: { candidates: [] }, event: 'input', bubbles: true, cancelable: false };
if (t === 'setAttribute') return { target: { candidates: [] }, name: '', value: '' };
@@ -108,6 +111,21 @@ export function summarizeNode(n?: NodeBase | null): string {
return `if/else 分支数 ${cnt}${n.config?.else === false ? '' : ' + else'}`;
}
if (n.type === 'script') return (n.config?.code || '').slice(0, 30);
if (n.type === 'drag') {
const a = n.config?.start?.candidates?.[0]?.value || '';
const b = n.config?.end?.candidates?.[0]?.value || '';
return a || b ? `${a} -> ${b}` : '拖拽';
}
if (n.type === 'scroll') {
const mode = n.config?.mode || 'offset';
if (mode === 'offset' || mode === 'container') {
const x = Number(n.config?.offset?.x ?? 0);
const y = Number(n.config?.offset?.y ?? 0);
return `${mode} (${x}, ${y})`;
}
const sel = n.config?.target?.candidates?.[0]?.value || '';
return sel ? `element ${sel}` : 'element';
}
if (n.type === 'executeFlow') return `exec ${n.config?.flowId || ''}`;
return '';
}
@@ -15,6 +15,8 @@ import PropClick from '@/entrypoints/popup/components/builder/components/propert
import PropFill from '@/entrypoints/popup/components/builder/components/properties/PropertyFill.vue';
import PropTriggerEvent from '@/entrypoints/popup/components/builder/components/properties/PropertyTriggerEvent.vue';
import PropSetAttribute from '@/entrypoints/popup/components/builder/components/properties/PropertySetAttribute.vue';
import PropDrag from '@/entrypoints/popup/components/builder/components/properties/PropertyDrag.vue';
import PropScroll from '@/entrypoints/popup/components/builder/components/properties/PropertyScroll.vue';
import PropNavigate from '@/entrypoints/popup/components/builder/components/properties/PropertyNavigate.vue';
import PropWait from '@/entrypoints/popup/components/builder/components/properties/PropertyWait.vue';
import PropAssert from '@/entrypoints/popup/components/builder/components/properties/PropertyAssert.vue';
@@ -102,6 +104,22 @@ export const NODE_UI_LIST: NodeUIConfig[] = [
canvas: baseCard,
property: PropClick,
},
{
type: 'drag',
label: '拖拽',
category: 'Actions',
iconClass: 'icon-drag',
canvas: baseCard,
property: PropDrag,
},
{
type: 'scroll',
label: '滚动',
category: 'Actions',
iconClass: 'icon-scroll',
canvas: baseCard,
property: PropScroll,
},
{
type: 'dblclick',
label: '双击',
@@ -27,6 +27,8 @@ export function useBuilderStore(initial?: FlowV2 | null) {
const paletteTypes = [
'trigger',
'click',
'drag',
'scroll',
'fill',
'if',
'foreach',
+256 -32
View File
@@ -7,13 +7,17 @@
window.__RR_RECORDER_INSTALLED__ = true;
const SENSITIVE_INPUT_TYPES = new Set(['password']);
const THROTTLE_SCROLL_MS = 200;
const THROTTLE_SCROLL_MS = 200; // legacy (kept for safety)
const SCROLL_DEBOUNCE_MS = 350; // record on scroll end; update last step during scroll
const sampledDrag = [];
let isRecording = false;
// Persistent guard synced with background to prevent stray resume after stop/refresh
let allowedByPersistentState = false;
let isPaused = false;
let hideInputValues = false;
let highlightBox = null;
let highlightEnabled = true;
let pendingFlow = {
id: `flow_${Date.now()}`,
name: '未命名录制',
@@ -23,6 +27,76 @@
meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
};
// Debounce and coalesce state for input recording
// Avoid generating one fill step per keystroke; update recent step instead
const INPUT_DEBOUNCE_MS = 500;
let lastFill = {
ref: null,
idx: -1,
ts: 0,
};
// Initialize persistent recording state from storage and keep it in sync
try {
chrome.storage.local
.get(['rr_recording_state'])
.then((res) => {
try {
allowedByPersistentState = !!(
res &&
res.rr_recording_state &&
res.rr_recording_state.active
);
// If state is inactive but a stray overlay is present, force remove
if (
!allowedByPersistentState &&
(document.getElementById('__rr_rec_overlay') || isRecording)
) {
isRecording = false;
detach();
removeOverlay();
}
} catch {}
})
.catch(() => {});
chrome.storage.onChanged.addListener((changes, area) => {
try {
if (area !== 'local') return;
if (Object.prototype.hasOwnProperty.call(changes || {}, 'rr_recording_state')) {
const nv = changes.rr_recording_state?.newValue;
const active = !!(nv && nv.active);
allowedByPersistentState = active;
if (!active && (document.getElementById('__rr_rec_overlay') || isRecording)) {
// Force stop any stray recorder UI and listeners
isRecording = false;
detach();
removeOverlay();
try {
if (scrollTimer) clearTimeout(scrollTimer);
} catch {}
scrollTimer = null;
lastScrollIdx = -1;
if (hoverRAF) {
try {
cancelAnimationFrame(hoverRAF);
} catch {}
hoverRAF = 0;
}
if (batchTimer) {
try {
clearTimeout(batchTimer);
} catch {}
batchTimer = null;
batch.length = 0;
}
sampledDrag.length = 0;
lastFill = { ref: null, idx: -1, ts: 0 };
}
}
} catch {}
});
} catch {}
function now() {
return Date.now();
}
@@ -122,20 +196,45 @@
pendingFlow.variables.push({ key, sensitive: !!sensitive, default: defaultValue || '' });
}
// batch send steps to reduce message overhead
let batch = [];
let batchTimer = null;
function flushBatch() {
if (!batch.length) return;
const steps = batch.slice();
batch.length = 0;
try {
chrome.runtime.sendMessage({
type: 'rr_recorder_event',
payload: { kind: 'steps', steps },
});
} catch {}
}
function pushStep(step) {
step.id = step.id || `step_${now()}_${Math.random().toString(36).slice(2, 6)}`;
pendingFlow.steps.push(step);
batch.push(step);
pendingFlow.meta.updatedAt = new Date().toISOString();
chrome.runtime.sendMessage({
type: 'rr_recorder_event',
payload: { kind: 'step', step },
});
if (batchTimer) {
try {
clearTimeout(batchTimer);
} catch {}
}
batchTimer = setTimeout(() => {
batchTimer = null;
flushBatch();
}, 80);
}
function onClick(e) {
if (!isRecording || isPaused) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
// Ignore clicks inside our own overlay UI
try {
const overlay = document.getElementById('__rr_rec_overlay');
if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return;
} catch {}
try {
// Special-case: clicking on <a target="_blank"> should record as openTab + switchTab
const a = el.closest && el.closest('a[href]');
@@ -178,21 +277,58 @@
addVariable(varKey, true, '');
value = `{${varKey}}`;
}
const nowTs = now();
// If recent fill for the same element within debounce window, update it instead of pushing
const sameRef = lastFill.ref && target && target.ref && lastFill.ref === target.ref;
const withinDebounce = nowTs - lastFill.ts <= INPUT_DEBOUNCE_MS;
if (sameRef && withinDebounce && lastFill.idx >= 0) {
try {
const st = pendingFlow.steps[lastFill.idx];
if (st && st.type === 'fill') {
st.value = value;
pendingFlow.meta.updatedAt = new Date().toISOString();
lastFill.ts = nowTs;
return;
}
} catch {}
}
pushStep({ type: 'fill', target, value, screenshotOnFail: true });
lastFill = {
ref: target && target.ref ? target.ref : null,
idx: pendingFlow.steps.length - 1,
ts: nowTs,
};
}
function onKeydown(e) {
if (!isRecording || isPaused) return;
// modifier+key or Enter/Backspace etc
// Only record special keys or chords. Ignore plain character typing (handled by fill).
const mods = [];
if (e.ctrlKey) mods.push('ctrl');
if (e.metaKey) mods.push('cmd');
if (e.altKey) mods.push('alt');
if (e.shiftKey) mods.push('shift');
let keyToken = e.key || '';
// normalize
keyToken = keyToken.length === 1 ? keyToken.toLowerCase() : keyToken.toLowerCase();
const keys = mods.length ? `${mods.join('+')}+${keyToken}` : keyToken;
const key = (e.key || '').toLowerCase();
const specialKeys = new Set([
'enter',
'escape',
'esc',
'tab',
'backspace',
'delete',
'home',
'end',
'pageup',
'pagedown',
'arrowleft',
'arrowright',
'arrowup',
'arrowdown',
]);
const isPlainChar = key.length === 1 && mods.length === 0;
const shouldRecord = mods.length > 0 || specialKeys.has(key);
if (!shouldRecord || isPlainChar) return;
const keys = mods.length ? `${mods.join('+')}+${key}` : key;
pushStep({ type: 'key', keys, screenshotOnFail: false });
}
@@ -221,15 +357,37 @@
}
let lastScrollAt = 0;
let scrollTimer = null;
let lastScrollIdx = -1;
function onScroll(e) {
if (!isRecording || isPaused) return;
const nowTs = now();
if (nowTs - lastScrollAt < THROTTLE_SCROLL_MS) return;
// Soft throttle
if (nowTs - lastScrollAt < Math.min(THROTTLE_SCROLL_MS, 100)) return;
lastScrollAt = nowTs;
const targetEl = e.target === document ? document.documentElement : e.target;
const target = targetEl instanceof Element ? buildTarget(targetEl) : undefined;
const top = window.scrollY || document.documentElement.scrollTop || 0;
pushStep({ type: 'scroll', mode: 'offset', offset: { x: 0, y: top }, target });
try {
if (lastScrollIdx >= 0 && pendingFlow.steps[lastScrollIdx]) {
const st = pendingFlow.steps[lastScrollIdx];
if (st && st.type === 'scroll' && st.mode === 'offset') {
st.offset = { x: 0, y: top };
pendingFlow.meta.updatedAt = new Date().toISOString();
}
} else {
// Reduce overhead: do not build element target for generic window scroll
pushStep({ type: 'scroll', mode: 'offset', offset: { x: 0, y: top } });
lastScrollIdx = pendingFlow.steps.length - 1;
}
} catch {}
if (scrollTimer) {
try {
clearTimeout(scrollTimer);
} catch {}
}
scrollTimer = setTimeout(() => {
lastScrollIdx = -1;
scrollTimer = null;
}, SCROLL_DEBOUNCE_MS);
}
let dragging = false;
@@ -242,9 +400,10 @@
function onMouseMove(e) {
if (!isRecording) return;
if (!dragging) return;
if (sampledDrag.length === 0 || now() - sampledDrag._lastTs > 50) {
if (sampledDrag.length === 0 || now() - sampledDrag._lastTs > 100) {
sampledDrag.push({ x: e.clientX, y: e.clientY });
sampledDrag._lastTs = now();
if (sampledDrag.length > 30) sampledDrag.splice(1, sampledDrag.length - 30);
}
}
function onMouseUp(e) {
@@ -285,7 +444,9 @@
// document.removeEventListener('keyup', onKeyup, true);
document.removeEventListener('compositionstart', onCompositionStart, true);
document.removeEventListener('compositionend', onCompositionEnd, true);
window.removeEventListener('scroll', onScroll, { passive: true });
try {
window.removeEventListener('scroll', onScroll, false);
} catch {}
document.removeEventListener('mousedown', onMouseDown, true);
document.removeEventListener('mousemove', onMouseMove, true);
document.removeEventListener('mouseup', onMouseUp, true);
@@ -317,17 +478,57 @@
type: 'rr_recorder_event',
payload: { kind: 'start', flow: pendingFlow },
});
try {
// Record current page URL as the first step when starting from an existing page
// Only add once from the top frame and only when this is a fresh flow
if (
window === window.top &&
Array.isArray(pendingFlow.steps) &&
pendingFlow.steps.length === 0
) {
const href = String(location && location.href ? location.href : '');
if (href) pushStep({ type: 'navigate', url: href });
}
} catch (_e) {
/* ignore */
}
}
function stop() {
isRecording = false;
detach();
removeOverlay();
// Clear timers and transient states to avoid post-stop overhead
try {
if (scrollTimer) clearTimeout(scrollTimer);
} catch {}
scrollTimer = null;
lastScrollIdx = -1;
if (hoverRAF) {
try {
cancelAnimationFrame(hoverRAF);
} catch {}
hoverRAF = 0;
}
if (batchTimer) {
try {
clearTimeout(batchTimer);
} catch {}
batchTimer = null;
batch.length = 0;
}
sampledDrag.length = 0;
lastFill = { ref: null, idx: -1, ts: 0 };
chrome.runtime.sendMessage({
type: 'rr_recorder_event',
payload: { kind: 'stop', flow: pendingFlow },
});
return pendingFlow;
// Release references to steps to reduce memory pressure after stop
const ret = pendingFlow;
try {
pendingFlow.steps = [];
} catch {}
return ret;
}
function pause() {
@@ -336,6 +537,8 @@
}
function resume() {
// Only resume when background indicates an active recording session
if (!allowedByPersistentState) return;
isRecording = true;
isPaused = false;
attach();
@@ -344,6 +547,8 @@
}
function ensureOverlay() {
// Only render overlay and highlight in top frame to reduce multi-frame overhead
if (window !== window.top) return;
let root = document.getElementById('__rr_rec_overlay');
if (root) return;
root = document.createElement('div');
@@ -361,6 +566,9 @@
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input id="__rr_hide_values" type="checkbox" style="vertical-align:middle;" />隐藏输入值
</label>
<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_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>
@@ -369,8 +577,17 @@
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');
hideChk.checked = hideInputValues;
hideChk.addEventListener('change', () => (hideInputValues = hideChk.checked));
highlightChk.checked = highlightEnabled;
highlightChk.addEventListener('change', () => {
highlightEnabled = !!highlightChk.checked;
try {
if (highlightEnabled) document.addEventListener('mousemove', onHoverMove, true);
else document.removeEventListener('mousemove', onHoverMove, true);
} catch {}
});
btnPause.addEventListener('click', () => {
if (!isPaused) pause();
else resume();
@@ -390,15 +607,17 @@
zIndex: 2147483645,
});
document.documentElement.appendChild(highlightBox);
document.addEventListener('mousemove', onHoverMove, true);
if (highlightEnabled) document.addEventListener('mousemove', onHoverMove, true);
}
function removeOverlay() {
try {
const root = document.getElementById('__rr_rec_overlay');
if (root) root.remove();
if (highlightBox) highlightBox.remove();
document.removeEventListener('mousemove', onHoverMove, true);
if (window === window.top) {
const root = document.getElementById('__rr_rec_overlay');
if (root) root.remove();
if (highlightBox) highlightBox.remove();
document.removeEventListener('mousemove', onHoverMove, true);
}
} catch {}
}
@@ -409,20 +628,25 @@
if (pauseBtn) pauseBtn.textContent = isPaused ? '继续' : '暂停';
}
let hoverRAF = 0;
function onHoverMove(e) {
if (!highlightBox || !isRecording || isPaused) return;
if (hoverRAF) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
try {
const r = el.getBoundingClientRect();
Object.assign(highlightBox.style, {
left: `${Math.round(r.left)}px`,
top: `${Math.round(r.top)}px`,
width: `${Math.round(Math.max(0, r.width))}px`,
height: `${Math.round(Math.max(0, r.height))}px`,
display: r.width > 0 && r.height > 0 ? 'block' : 'none',
});
} catch {}
hoverRAF = requestAnimationFrame(() => {
try {
const r = el.getBoundingClientRect();
Object.assign(highlightBox.style, {
left: `${Math.round(r.left)}px`,
top: `${Math.round(r.top)}px`,
width: `${Math.round(Math.max(0, r.width))}px`,
height: `${Math.round(Math.max(0, r.height))}px`,
display: r.width > 0 && r.height > 0 ? 'block' : 'none',
});
} catch {}
hoverRAF = 0;
});
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
+30
View File
@@ -56,6 +56,14 @@ export function mapNodeToStep(node: RRNode): any {
target: c.target || { candidates: [] },
value: c.value || '',
};
case 'drag':
return {
...base,
type: 'drag',
start: c.start || { candidates: [] },
end: c.end || { candidates: [] },
path: Array.isArray(c.path) ? c.path : undefined,
};
case 'key':
return { ...base, type: 'key', keys: c.keys || '' };
case 'wait':
@@ -125,6 +133,14 @@ export function mapNodeToStep(node: RRNode): any {
fullPage: !!c.fullPage,
saveAs: c.saveAs || '',
};
case 'scroll':
return {
...base,
type: 'scroll',
mode: c.mode || 'offset',
target: c.target || { candidates: [] },
offset: c.offset || { x: 0, y: 300 },
};
case 'triggerEvent':
return {
...base,
@@ -267,6 +283,20 @@ export function mapStepToNodeConfig(s: any): any {
};
if (t === 'screenshot')
return { ...base, selector: s.selector || '', fullPage: !!s.fullPage, saveAs: s.saveAs || '' };
if (t === 'scroll')
return {
...base,
mode: s.mode || 'offset',
target: s.target || { candidates: [] },
offset: s.offset || { x: 0, y: 300 },
};
if (t === 'drag')
return {
...base,
start: s.start || { candidates: [] },
end: s.end || { candidates: [] },
path: Array.isArray(s.path) ? s.path : [],
};
if (t === 'triggerEvent')
return {
...base,