mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat: 卡顿问题修复
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+8
@@ -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 || '';
|
||||
|
||||
+24
@@ -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>
|
||||
+99
@@ -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>
|
||||
+15
-9
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user