/* eslint-disable */
// recorder.js - content script for recording user interactions into steps
(function () {
if (window.__RR_RECORDER_INSTALLED__) return;
window.__RR_RECORDER_INSTALLED__ = true;
// ================================================================
// 1) CONFIG + STATELESS HELPERS (namespaced)
// ================================================================
const CONFIG = {
// Increase debounce to improve step merging for slow/DOM-replacing inputs
INPUT_DEBOUNCE_MS: 800,
BATCH_SEND_MS: 100,
SCROLL_DEBOUNCE_MS: 350,
SENSITIVE_INPUT_TYPES: new Set(['password']),
UI_MAX_STEPS: 30,
// Maximum time to hold flush while user is typing (prevents unbounded batch accumulation)
MAX_TYPING_HOLD_MS: 1500,
};
// Cross-frame event channel
const FRAME_EVENT = 'rr_iframe_event';
// Memoization caches for selector computations during recording
const __cacheUnique = new WeakMap();
const __cachePath = new WeakMap();
const SelectorEngine = {
buildTarget(el) {
const candidates = [];
const attrNames = ['data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy'];
for (const an of attrNames) {
const v = el.getAttribute && el.getAttribute(an);
if (v) candidates.push({ type: 'attr', value: `[${an}="${CSS.escape(v)}"]` });
}
const classSel = this._uniqueClassSelector(el);
if (classSel) candidates.push({ type: 'css', value: classSel });
const css = this._generateSelector(el);
if (css) candidates.push({ type: 'css', value: css });
const name = el.getAttribute && el.getAttribute('name');
if (name) candidates.push({ type: 'attr', value: `[name="${CSS.escape(name)}"]` });
const title = el.getAttribute && el.getAttribute('title');
if (title) candidates.push({ type: 'attr', value: `[title="${CSS.escape(title)}"]` });
const alt = el.getAttribute && el.getAttribute('alt');
if (alt) candidates.push({ type: 'attr', value: `[alt="${CSS.escape(alt)}"]` });
const aria = el.getAttribute && el.getAttribute('aria-label');
const role = el.getAttribute && el.getAttribute('role');
if (aria) {
if (role) candidates.push({ type: 'aria', value: `${role}[name=${aria}]` });
else candidates.push({ type: 'aria', value: `textbox[name=${aria}]` });
}
const tag = el.tagName?.toLowerCase?.() || '';
if (['button', 'a', 'summary'].includes(tag)) {
const text = (el.textContent || '').trim();
if (text) candidates.push({ type: 'text', value: text.substring(0, 64) });
}
const selector = SelectorEngine._choosePrimary(el, candidates);
return { selector, candidates, tag };
},
_choosePrimary(el, candidates) {
if (el.id && document.querySelectorAll(`#${CSS.escape(el.id)}`).length === 1) {
return `#${CSS.escape(el.id)}`;
}
const priority = ['attr', 'css'];
for (const p of priority) {
const c = candidates.find((c) => c.type === p);
if (c) {
try {
const tag = el.tagName ? el.tagName.toLowerCase() : '';
if (p === 'attr' && (tag === 'input' || tag === 'textarea' || tag === 'select')) {
const val = String(c.value || '').trim();
if (val.startsWith('[')) return `${tag}${val}`;
}
} catch {}
return c.value;
}
}
if (candidates.length) return candidates[0].value;
return SelectorEngine._generateSelector(el) || '';
},
_uniqueClassSelector(el) {
if (__cacheUnique.has(el)) return __cacheUnique.get(el);
let result = '';
try {
const classes = Array.from(el.classList || []).filter(
(c) => c && /^[a-zA-Z0-9_-]+$/.test(c),
);
for (const cls of classes) {
const sel = `.${CSS.escape(cls)}`;
if (document.querySelectorAll(sel).length === 1) {
result = sel;
break;
}
}
if (!result) {
const tag = el.tagName ? el.tagName.toLowerCase() : '';
for (const cls of classes) {
const sel = `${tag}.${CSS.escape(cls)}`;
if (document.querySelectorAll(sel).length === 1) {
result = sel;
break;
}
}
}
if (!result) {
for (let i = 0; i < Math.min(classes.length, 3) && !result; i++) {
for (let j = i + 1; j < Math.min(classes.length, 3); j++) {
const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;
if (document.querySelectorAll(sel).length === 1) {
result = sel;
break;
}
}
}
}
} catch {}
__cacheUnique.set(el, result);
return result;
},
_generateSelector(el) {
if (!(el instanceof Element)) return '';
if (__cachePath.has(el)) return __cachePath.get(el);
if (el.id) {
const idSel = `#${CSS.escape(el.id)}`;
if (document.querySelectorAll(idSel).length === 1) return idSel;
}
for (const attr of ['data-testid', 'data-cy', 'name']) {
const attrValue = el.getAttribute(attr);
if (attrValue) {
const s = `[${attr}="${CSS.escape(attrValue)}"]`;
if (document.querySelectorAll(s).length === 1) return s;
}
}
let path = '';
let current = el;
while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') {
let selector = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(
(child) => child.tagName === current.tagName,
);
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1;
selector += `:nth-of-type(${index})`;
}
}
path = path ? `${selector} > ${path}` : selector;
current = parent;
}
const res = path ? `body > ${path}` : 'body';
__cachePath.set(el, res);
return res;
},
};
// Extend SelectorEngine with a shared ref helper (attached after declaration)
SelectorEngine._ensureGlobalRef = function (el) {
try {
if (!window.__claudeElementMap) window.__claudeElementMap = {};
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
for (const k in window.__claudeElementMap) {
const w = window.__claudeElementMap[k];
if (w && typeof w.deref === 'function' && w.deref() === el) return k;
}
const id = `ref_${++window.__claudeRefCounter}`;
window.__claudeElementMap[id] = new WeakRef(el);
return id;
} catch {
return null;
}
};
// ================================================================
// 2) UI CLASS (injected via constructor)
// ================================================================
class UI {
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;
if (window !== window.top) return;
let root = document.getElementById('__rr_rec_overlay');
if (root) return;
root = document.createElement('div');
root.id = '__rr_rec_overlay';
Object.assign(root.style, {
position: 'fixed',
top: '10px',
right: '10px',
zIndex: 2147483646,
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
});
root.innerHTML = `
<div id="__rr_rec_panel" style="background: rgba(220,38,38,0.95); color: #fff; padding:8px 10px; border-radius:8px; display:flex; align-items:center; gap:8px; box-shadow:0 4px 16px rgba(0,0,0,0.2);">
<span id="__rr_badge" style="font-weight:600;">录制中</span>
<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_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
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;
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;
highlightChk.addEventListener('change', () => {
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', () => {
chrome.runtime.sendMessage({ type: 'rr_stop_recording' });
});
this._box = document.createElement('div');
Object.assign(this._box.style, {
position: 'fixed',
border: '2px solid rgba(59,130,246,0.9)',
borderRadius: '4px',
background: 'rgba(59,130,246,0.15)',
pointerEvents: 'none',
zIndex: 2147483645,
});
document.documentElement.appendChild(this._box);
if (rec.highlightEnabled)
document.addEventListener('mousemove', rec._onMouseMove, { capture: true, passive: true });
this.updateStatus();
}
remove() {
if (window === window.top) {
const root = document.getElementById('__rr_rec_overlay');
if (root) root.remove();
if (this._box) this._box.remove();
this._timeline = null;
this._timelineBox = null;
}
}
updateStatus() {
const badge = document.getElementById('__rr_badge');
const pauseBtn = document.getElementById('__rr_pause');
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;
}
/**
* Apply a full timeline update from background.
* Steps can be upserted in place (same id, updated fields) during fill debouncing.
* Uses smart diffing to minimize DOM operations while ensuring fill values are accurate.
*/
applyTimelineUpdate(steps) {
try {
if (window !== window.top) return;
const list = Array.isArray(steps) ? steps : [];
const total = list.length;
// Ensure UI exists
if (!this._timeline) this.ensure();
if (!this._timeline) return;
if (total === 0) {
this.resetTimeline();
return;
}
// Calculate the window of steps to display (last N steps)
const windowStart = Math.max(0, total - CONFIG.UI_MAX_STEPS);
const windowSteps = list.slice(windowStart);
// Get current displayed step IDs
const currentItems = this._timeline.children;
const currentIds = [];
for (let i = 0; i < currentItems.length; i++) {
currentIds.push(currentItems[i].getAttribute('data-step-id') || '');
}
// Check if we need a full rebuild or can do incremental update
const newIds = windowSteps.map((s) => s.id || '');
const needsRebuild =
currentIds.length !== newIds.length || currentIds.some((id, i) => id !== newIds[i]);
if (needsRebuild) {
// Full rebuild: either structure changed or it's simpler to rebuild
this.resetTimeline();
for (let i = 0; i < windowSteps.length; i++) {
this._appendStepWithIndex(windowSteps[i], windowStart + i + 1);
}
} else {
// Incremental update: same steps, just update values
for (let i = 0; i < windowSteps.length; i++) {
const step = windowSteps[i];
const item = currentItems[i];
if (item) {
// Update the text content for this step
const textSpan = item.querySelector('span:last-child');
if (textSpan) {
const newText = this._formatStepText(step, windowStart + i + 1);
if (textSpan.textContent !== newText) {
textSpan.textContent = newText;
}
}
}
}
}
this._count = total;
} catch {}
}
/**
* Internal method to append a step with a specific display index.
* Used by applyTimelineUpdate for proper numbering.
*/
_appendStepWithIndex(step, displayIndex) {
const list = this._timeline || document.getElementById('__rr_rec_timeline_list') || null;
if (!list) return;
const item = document.createElement('li');
const text = this._formatStepText(step, displayIndex);
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;">${displayIndex}.</span>
<span style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:310px;">${text}</span>
`;
list.appendChild(item);
const container = list.parentElement;
if (container) container.scrollTop = container.scrollHeight;
}
// 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 '步骤';
}
}
}
// ================================================================
// 3) MAIN CLASS: ContentRecorder (stateful)
// ================================================================
class ContentRecorder {
constructor() {
// State
this.isRecording = false;
this.isPaused = false;
this.hideInputValues = false;
this.highlightEnabled = true;
this.hoverRAF = 0;
this.frameSwitchPushed = false;
this.batch = [];
this.batchTimer = null;
this.scrollTimer = null;
// Local, content-side buffer for batching/merging steps during recording.
// Not the authoritative Flow (background holds the real one).
this.sessionBuffer = this._createSessionBuffer();
// lastFill tracks the most recent fill step for debounce/merge
// el: DOM element reference for reading final value on finalize
this.lastFill = { step: null, ts: 0, el: null };
// Input activity tracking for flush gate (separate from merge state)
// Updated by both local input and iframe upsert messages
this._lastInputActivityTs = 0;
// Flush gate: tracks when a typing burst started to enforce MAX_TYPING_HOLD_MS
this._typingBurstStartTs = 0;
// Force flush timer: ensures MAX_TYPING_HOLD_MS is a hard upper bound
// This timer is NOT reset on each input, only cleared on actual flush
this._forceFlushTimer = null;
// Recording-time element identity map (not persisted)
this.el2ref = new WeakMap();
this.refCounter = 0;
// Bind handlers
this._onClick = this._onClick.bind(this);
this._onInput = this._onInput.bind(this);
this._onDocInput = this._onDocInput.bind(this);
this._onChange = this._onChange.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onScroll = this._onScroll.bind(this);
this._onFocusIn = this._onFocusIn.bind(this);
this._onFocusOut = this._onFocusOut.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
this._onKeyUp = this._onKeyUp.bind(this);
this._onWindowMessage = this._onWindowMessage.bind(this);
// Page lifecycle handlers for best-effort flush on navigation/close
this._onPageHide = this._onPageHide.bind(this);
this._onVisibilityChange = this._onVisibilityChange.bind(this);
this.ui = new UI(this);
this._scrollPending = null;
// Focus tracking for per-element input listening
this._focusedEl = null;
// Keyboard state for combo recording
this._pressed = new Set();
this._lastKeyTs = 0;
// Map to avoid duplicate switchFrame per iframe source (keyed by frame selector)
this._frameSwitchMap = new Set();
}
// Lifecycle
start(flowMeta) {
// Idempotent start: if already recording (and not paused), just ensure UI and listeners
if (this.isRecording && !this.isPaused) {
this.ui.ensure();
this._updateHoverListener();
return;
}
// If paused, treat start as resume to avoid resetting local buffer/UI timeline
if (this.isPaused) {
this.resume();
return;
}
this._reset(flowMeta || {});
this.isRecording = true;
this.isPaused = false;
this._attach();
this.ui.ensure();
this.ui.resetTimeline();
}
/**
* Stop recording and flush all pending data.
* This is the reliable stop that ensures no data is lost.
* Waits for background to acknowledge receipt of all data before returning.
* @returns {Promise<{ack: boolean, steps: number, variables: number}>}
*/
async stop() {
if (!this.isRecording) {
return { ack: true, steps: 0, variables: 0 };
}
this.isRecording = false;
// Stop should clear paused state so detach fully cleans up (and barrier works consistently)
this.isPaused = false;
// Step 1: Finalize pending click (dblclick detector)
this._finalizePendingClick();
// Step 2: Finalize any pending input (draft mode)
this._finalizePendingInput();
// Step 3: Finalize any pending scroll
this._finalizePendingScroll();
// Step 4: In iframes, ensure the top-frame aggregator has processed our final postMessages
// before we ACK the background stop (prevents missing iframe steps)
let topSyncOk = true;
if (window !== window.top) {
topSyncOk = await this._syncStopBarrierToTop();
}
// Step 5: Clear timers BEFORE flush (prevent race conditions)
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = null;
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = null;
if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);
this.hoverRAF = 0;
// Step 6: Flush any remaining batched steps and WAIT for ack
const stepsCount = this.batch.length;
let stepsAck = true;
if (stepsCount > 0) {
stepsAck = await this._flush();
}
// Step 7: Send all collected variables and WAIT for ack
const variablesCount = this.sessionBuffer.variables?.length || 0;
let variablesAck = true;
if (variablesCount > 0) {
variablesAck = await this._sendVariables();
}
// Step 8: Detach listeners and clean up UI
this._detach();
this.ui.remove();
// Step 9: Reset state
this.lastFill = { step: null, ts: 0, el: null };
this._lastInputActivityTs = 0;
this._typingBurstStartTs = 0;
if (this._forceFlushTimer) {
clearTimeout(this._forceFlushTimer);
this._forceFlushTimer = null;
}
this.sessionBuffer.steps = [];
// Return acknowledgment with stats
// ack is true only if all sends were acknowledged
return {
ack: stepsAck && variablesAck && topSyncOk,
steps: stepsCount,
variables: variablesCount,
};
}
/**
* Finalize a pending click that hasn't been emitted yet.
* The dblclick detector holds single clicks temporarily to detect double-clicks.
* This ensures stop/pause flush includes the last single click.
*/
_finalizePendingClick() {
try {
if (this._pendingClickTimer) clearTimeout(this._pendingClickTimer);
} catch {}
this._pendingClickTimer = null;
try {
if (this._pendingClick) this._pushStep(this._pendingClick);
} catch {}
this._pendingClick = null;
}
/**
* Finalize any pending input that hasn't been flushed yet.
* This ensures the last input value is captured before stop/pause/navigation.
* Uses lastFill.el (DOM reference) to read the current value.
*/
_finalizePendingInput() {
const last = this.lastFill;
if (!last || !last.step) return;
// Commit the latest value from the DOM element
try {
const el = last.el;
if (el) {
const freshValue = this._getElementValue(el, last.step.value);
if (freshValue !== last.step.value) {
last.step.value = freshValue;
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
}
}
} catch {
// Element may no longer exist, that's OK - we keep the last known value
}
// Enqueue for upsert to ensure background gets the final value
try {
this._enqueueForUpsert(last.step);
} catch {}
// Reset state
this.lastFill = { step: null, ts: 0, el: null };
this._typingBurstStartTs = 0;
}
/**
* Get the current value from an element, handling sensitive fields and contenteditable.
* @param {Element} el - The element to read from
* @param {string} existingValue - The existing recorded value (may be a variable placeholder)
* @returns {string} The value to record
*/
_getElementValue(el, existingValue) {
if (!el) return existingValue || '';
const isContentEditable =
el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;
// If existing value is already a variable placeholder, preserve it
// Use strict pattern to avoid false positives for user input like "{abc}"
const existing = typeof existingValue === 'string' ? existingValue : '';
const varPlaceholderPattern =
/^\{(?:var_[a-z0-9]{4}|file_[a-z0-9]{4}|[a-zA-Z_][a-zA-Z0-9_]*)\}$/;
if (varPlaceholderPattern.test(existing)) {
return existing;
}
// Check if this is a sensitive field
const isSensitive =
this.hideInputValues ||
(!isContentEditable &&
CONFIG.SENSITIVE_INPUT_TYPES.has(
((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),
));
if (isSensitive) {
// Return existing variable or create new one (should already exist from initial capture)
return existing;
}
// Read fresh value from DOM
try {
if (isContentEditable) {
return /** @type {HTMLElement} */ (el).innerText || '';
}
if (
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
) {
return el.value || '';
}
} catch {}
return existing || '';
}
/**
* Finalize any pending scroll that hasn't been committed yet.
* Converts the pending scroll data into a proper scroll step.
*/
_finalizePendingScroll() {
if (!this._scrollPending) return;
const pending = this._scrollPending;
this._scrollPending = null;
const { isDoc, target, top, left } = pending;
// Try merge with last step (same logic as _onScroll timer callback)
const steps = this.sessionBuffer.steps;
const last = steps.length ? steps[steps.length - 1] : null;
if (last && last.type === 'scroll') {
const sameDoc = isDoc && !last.target && last.mode === 'offset';
const sameEl =
!isDoc &&
last.target &&
last.target.selector &&
target &&
last.target.selector === target.selector &&
last.mode === 'container';
if (sameDoc || sameEl) {
last.offset = { y: top, x: left };
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
return;
}
}
// Create new scroll step
if (isDoc) {
this._pushStep({
type: 'scroll',
mode: 'offset',
offset: { y: top, x: left },
screenshotOnFail: false,
});
} else {
this._pushStep({
type: 'scroll',
mode: 'container',
target: target,
offset: { y: top, x: left },
screenshotOnFail: false,
});
}
}
/**
* Send all collected variables to background.
* @returns {Promise<boolean>} - Resolves when background acknowledges receipt
*/
async _sendVariables() {
if (!this.sessionBuffer.variables || this.sessionBuffer.variables.length === 0) {
return true;
}
return this._send({ kind: 'variables', variables: this.sessionBuffer.variables });
}
/**
* Pause recording. Flushes pending data before pausing.
*/
pause() {
if (!this.isRecording || this.isPaused) return;
// Finalize pending data before pausing
this._finalizePendingClick();
this._finalizePendingInput();
this._finalizePendingScroll();
// Flush batched steps
if (this.batch.length > 0) {
this._flush();
}
// Clear timers
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = null;
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = null;
this.isPaused = true;
this._detach();
this.ui.updateStatus();
}
/**
* Resume recording after pause.
*/
resume() {
if (!this.isPaused) return;
this.isRecording = true;
this.isPaused = false;
this._attach();
this.ui.ensure();
this.ui.updateStatus();
}
// DOM listeners
_attach() {
document.addEventListener('click', this._onClick, true);
// Use focusin/out to attach input listener only to focused element
document.addEventListener('focusin', this._onFocusIn, true);
document.addEventListener('focusout', this._onFocusOut, true);
// Document-level input capture to support Shadow DOM (custom elements)
// Use capture phase + composedPath to find inner editable control
document.addEventListener('input', this._onDocInput, true);
document.addEventListener('change', this._onChange, true);
// capture-phase scroll to catch non-bubbling events on any container (passive to avoid jank)
document.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
// Keyboard: record Enter and modifier combos
document.addEventListener('keydown', this._onKeyDown, true);
document.addEventListener('keyup', this._onKeyUp, true);
// Page lifecycle: best-effort flush on navigation/close
window.addEventListener('pagehide', this._onPageHide, true);
document.addEventListener('visibilitychange', this._onVisibilityChange, true);
// Cross-frame: top window aggregates iframe-recorded steps
if (window === window.top) window.addEventListener('message', this._onWindowMessage, true);
this._updateHoverListener();
}
_detach() {
document.removeEventListener('click', this._onClick, true);
document.removeEventListener('focusin', this._onFocusIn, true);
document.removeEventListener('focusout', this._onFocusOut, true);
document.removeEventListener('input', this._onDocInput, true);
document.removeEventListener('change', this._onChange, true);
document.removeEventListener('scroll', this._onScroll, { capture: true });
document.removeEventListener('keydown', this._onKeyDown, true);
document.removeEventListener('keyup', this._onKeyUp, true);
window.removeEventListener('pagehide', this._onPageHide, true);
document.removeEventListener('visibilitychange', this._onVisibilityChange, true);
document.removeEventListener('mousemove', this._onMouseMove, { capture: true });
// Keep top-frame aggregator alive during pause; stop() clears isPaused and will remove it
if (window === window.top && !this.isPaused)
window.removeEventListener('message', this._onWindowMessage, true);
// Detach per-element input listener if any
if (this._focusedEl) this._focusedEl.removeEventListener('input', this._onInput, true);
this._focusedEl = null;
// Best-effort cleanup for timers/raf when detaching
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = null;
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = null;
if (this.hoverRAF) cancelAnimationFrame(this.hoverRAF);
this.hoverRAF = 0;
// Clear pending click state (stop/pause flush it before detach)
if (this._pendingClickTimer) {
clearTimeout(this._pendingClickTimer);
}
this._pendingClickTimer = null;
this._pendingClick = null;
}
_updateHoverListener() {
if (window !== window.top) return;
document.removeEventListener('mousemove', this._onMouseMove, { capture: true });
if (this.isRecording && !this.isPaused && this.highlightEnabled) {
document.addEventListener('mousemove', this._onMouseMove, { capture: true, passive: true });
}
}
// Flow helpers (content-side buffer only)
_createSessionBuffer() {
const nowIso = new Date().toISOString();
return {
id: `flow_${Date.now()}`,
name: '未命名录制',
version: 1,
steps: [],
variables: [],
meta: { createdAt: nowIso, updatedAt: nowIso },
};
}
_reset(meta) {
this.sessionBuffer = this._createSessionBuffer();
try {
if (meta && typeof meta === 'object') {
if (meta.id) this.sessionBuffer.id = String(meta.id);
if (meta.name) this.sessionBuffer.name = String(meta.name);
if (meta.description) this.sessionBuffer.description = String(meta.description);
}
} catch {}
this.lastFill = { step: null, ts: 0, el: null };
this._lastInputActivityTs = 0;
this._typingBurstStartTs = 0;
if (this._forceFlushTimer) {
clearTimeout(this._forceFlushTimer);
this._forceFlushTimer = null;
}
this.frameSwitchPushed = false;
}
/**
* Update input activity timestamp (used for flush gate).
* Called on local input and iframe upsert messages.
*/
_updateInputActivity() {
const now = Date.now();
const prevActivityTs = this._lastInputActivityTs || 0;
this._lastInputActivityTs = now;
// Start a new burst if previous one expired (or this is first input)
if (!this._typingBurstStartTs || now - prevActivityTs > CONFIG.INPUT_DEBOUNCE_MS) {
this._typingBurstStartTs = now;
// Start force flush timer (hard upper bound for MAX_TYPING_HOLD_MS)
this._startForceFlushTimer();
}
}
/**
* Start the force flush timer.
* This timer ensures MAX_TYPING_HOLD_MS is a hard upper bound.
* Unlike batchTimer, this timer is NOT reset on each input.
*/
_startForceFlushTimer() {
// Don't restart if already running
if (this._forceFlushTimer) return;
this._forceFlushTimer = setTimeout(() => {
this._forceFlushTimer = null;
// Force flush regardless of current input state
if (this.batch.length > 0) {
this._flush();
}
}, CONFIG.MAX_TYPING_HOLD_MS);
}
/**
* Clear the force flush timer (called on actual flush).
*/
_clearForceFlushTimer() {
if (this._forceFlushTimer) {
clearTimeout(this._forceFlushTimer);
this._forceFlushTimer = null;
}
this._typingBurstStartTs = 0;
}
/**
* Unified commit and flush logic.
* Called at commit points: focusout, Enter key, pagehide, visibilitychange.
* @param {Object} options
* @param {boolean} [options.bestEffort=false] - If true, don't await (for unload events)
*/
_commitAndFlush(options = {}) {
if (!this.isRecording || this.isPaused) return;
try {
this._finalizePendingInput();
this._finalizePendingScroll();
} catch {}
// Reset flush gate to allow immediate flush
this._lastInputActivityTs = 0;
this._typingBurstStartTs = 0;
this._clearForceFlushTimer();
// Flush (best-effort for unload events)
try {
if (this.batch.length > 0) this._flush();
} catch {}
try {
const variablesCount = this.sessionBuffer.variables?.length || 0;
if (variablesCount > 0) this._sendVariables();
} catch {}
// If in iframe, ask top to flush too
this._requestTopFlush();
}
_pushStep(step) {
step.id = step.id || `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
// In iframes, forward to top for aggregation (compute frame selector there)
if (window !== window.top) {
try {
const payload = {
kind: 'iframeStep',
href: String(location && location.href ? location.href : ''),
step,
};
window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
return; // Do not push locally in subframe
} catch {}
}
// Top window: optionally insert a switchFrame if this step originated from an iframe message
this.sessionBuffer.steps.push(step);
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
this.batch.push(step);
// Track input activity for fill steps (to enforce flush gate)
if (step && step.type === 'fill') {
this._updateInputActivity();
}
this._scheduleFlush();
}
/**
* Calculate the appropriate flush delay based on typing activity.
* During active typing, delay flush to avoid sending incomplete values.
* Note: MAX_TYPING_HOLD_MS is enforced by _forceFlushTimer, not here.
* @returns {number} Delay in milliseconds before next flush
*/
_getFlushDelayMs() {
const now = Date.now();
const lastInputTs = this._lastInputActivityTs || 0;
// If no recent input activity, use default batch delay
if (!lastInputTs || now - lastInputTs >= CONFIG.INPUT_DEBOUNCE_MS) {
return CONFIG.BATCH_SEND_MS;
}
// Wait for input debounce to complete
const notBefore = lastInputTs + CONFIG.INPUT_DEBOUNCE_MS;
const delay = Math.max(CONFIG.BATCH_SEND_MS, notBefore - now);
return delay;
}
/**
* Schedule a batch flush with appropriate delay.
* Respects typing gate to avoid flushing incomplete fill values.
*/
_scheduleFlush() {
if (this.batchTimer) {
clearTimeout(this.batchTimer);
}
const delay = this._getFlushDelayMs();
this.batchTimer = setTimeout(() => {
this.batchTimer = null;
this._flush();
}, delay);
}
/**
* Request top frame to immediately flush its aggregated buffer.
* Used by iframes on commit points (focusout, navigation) to ensure
* their updates are sent to background promptly.
*/
_requestTopFlush() {
if (window === window.top) return;
try {
const payload = {
kind: 'iframeFlush',
href: String(location && location.href ? location.href : ''),
};
window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
} catch {}
}
/**
* Iframe -> top stop barrier sync.
* Ensures the top frame has processed all prior iframe postMessages (steps/upserts)
* before this iframe responds to background STOP.
* @returns {Promise<boolean>}
*/
_syncStopBarrierToTop() {
if (window === window.top) return Promise.resolve(true);
const id = `sb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const href = String(location && location.href ? location.href : '');
const timeoutMs = 400;
return new Promise((resolve) => {
let done = false;
const cleanup = (ok) => {
if (done) return;
done = true;
try {
window.removeEventListener('message', onMessage, true);
} catch {}
try {
clearTimeout(t);
} catch {}
resolve(!!ok);
};
const onMessage = (ev) => {
try {
if (ev.source !== window.top) return;
const d = ev && ev.data;
if (!d || d.type !== FRAME_EVENT || !d.payload) return;
const p = d.payload || {};
if (p.kind !== 'iframeStopBarrierAck' || p.id !== id) return;
cleanup(true);
} catch {}
};
const t = setTimeout(() => cleanup(false), timeoutMs);
try {
window.addEventListener('message', onMessage, true);
window.top.postMessage(
{ type: FRAME_EVENT, payload: { kind: 'iframeStopBarrier', id, href } },
'*',
);
} catch {
cleanup(false);
}
});
}
/**
* Best-effort drain and flush on page navigation/close.
* Called by pagehide/visibilitychange handlers.
* Does not await - unload events are time-constrained.
*/
_bestEffortDrainAndFlush() {
if (!this.isRecording || this.isPaused) return;
// Flush pending single click (dblclick detector) before we may be unloaded
this._finalizePendingClick();
// Cancel timers (unload may not wait for them)
try {
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = null;
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = null;
} catch {}
// Use unified commit and flush
this._commitAndFlush({ bestEffort: true });
}
/**
* Handle pagehide event - best-effort flush before navigation/close.
*/
_onPageHide() {
this._bestEffortDrainAndFlush();
}
/**
* Handle visibilitychange event - flush when page becomes hidden.
* This catches some cases that pagehide misses (e.g., tab switch before navigation).
*/
_onVisibilityChange() {
try {
if (document.visibilityState === 'hidden') {
this._bestEffortDrainAndFlush();
}
} catch {}
}
/**
* Flush batched steps to background.
* @returns {Promise<boolean>} - Resolves when background acknowledges receipt
*/
async _flush() {
if (!this.batch.length) return true;
// Clear force flush timer since we're flushing now
this._clearForceFlushTimer();
const steps = this.batch.map((s) => {
// sanitize internal fields before sending to background
const { _recordingRef, ...rest } = s || {};
return rest;
});
this.batch.length = 0;
return this._send({ kind: 'steps', steps });
}
/**
* Send payload to background and wait for acknowledgment.
* @param {Object} payload - The payload to send
* @returns {Promise<boolean>} - Resolves true if background acknowledged, false otherwise
*/
_send(payload) {
return new Promise((resolve) => {
try {
chrome.runtime.sendMessage({ type: 'rr_recorder_event', payload }, (response) => {
// Check for runtime error (e.g., no receiver)
if (chrome.runtime.lastError) {
console.warn('Recorder: send failed', chrome.runtime.lastError.message);
resolve(false);
return;
}
resolve(response && response.ok);
});
} catch (e) {
console.warn('Recorder: send exception', e);
resolve(false);
}
});
}
_addVariable(key, sensitive, defVal) {
if (!this.sessionBuffer.variables) this.sessionBuffer.variables = [];
if (this.sessionBuffer.variables.find((v) => v.key === key)) return;
this.sessionBuffer.variables.push({ key, sensitive: !!sensitive, default: defVal || '' });
}
// Handlers
// Pending click state for dblclick detection
_pendingClick = null;
_pendingClickTimer = null;
_DBLCLICK_THRESHOLD_MS = 300;
_onClick(e) {
if (!this.isRecording || this.isPaused) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
try {
if (el instanceof HTMLInputElement) {
const t = (el.getAttribute && el.getAttribute('type')) || '';
const tt = String(t).toLowerCase();
if (tt === 'checkbox' || tt === 'radio') return; // avoid duplicate with change
}
const overlay = document.getElementById('__rr_rec_overlay');
if (overlay && (el === overlay || (el.closest && el.closest('#__rr_rec_overlay')))) return;
const a = el.closest && el.closest('a[href]');
const href = a && a.getAttribute && a.getAttribute('href');
const tgt = a && a.getAttribute && a.getAttribute('target');
if (a && href && tgt && tgt.toLowerCase() === '_blank') {
try {
const abs = new URL(href, location.href).href;
this._pushStep({ type: 'openTab', url: abs });
this._pushStep({ type: 'switchTab', urlContains: abs });
return;
} catch (_) {
this._pushStep({ type: 'openTab', url: href });
this._pushStep({ type: 'switchTab', urlContains: href });
return;
}
}
} catch {}
const target = SelectorEngine.buildTarget(el);
try {
const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
if (gref) target.ref = gref;
} catch {}
// Double-click detection: if e.detail >= 2 means this is the second click of a dblclick
if (e.detail >= 2) {
// Cancel pending single click and record dblclick instead
if (this._pendingClickTimer) {
clearTimeout(this._pendingClickTimer);
this._pendingClickTimer = null;
}
this._pendingClick = null;
this._pushStep({
type: 'dblclick',
target,
screenshotOnFail: true,
});
return;
}
// Single click: wait briefly to see if it becomes a dblclick
// Cancel any previous pending click first
if (this._pendingClickTimer) {
clearTimeout(this._pendingClickTimer);
// Flush previous pending click before starting new one
if (this._pendingClick) {
this._pushStep(this._pendingClick);
}
}
this._pendingClick = {
type: 'click',
target,
screenshotOnFail: true,
};
this._pendingClickTimer = setTimeout(() => {
if (this._pendingClick) {
this._pushStep(this._pendingClick);
this._pendingClick = null;
}
this._pendingClickTimer = null;
}, this._DBLCLICK_THRESHOLD_MS);
}
// Per-element input handler (attached on focusin for native inputs/textarea/contenteditable)
_onInput(e) {
if (!this.isRecording || this.isPaused) return;
// Avoid mid-composition spam (IME): handle final committed value
try {
if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;
} catch {}
const target = e.target;
// Support input/textarea and contenteditable elements
const el =
target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
? target
: target &&
target.nodeType === 1 &&
/** @type {HTMLElement} */ (target).isContentEditable === true
? /** @type {HTMLElement} */ (target)
: null;
if (!el) return;
this._handleInputForElement(el);
}
// Document-level input handler: supports composed events from Shadow DOM (custom elements)
_onDocInput(e) {
if (!this.isRecording || this.isPaused) return;
try {
if (e && typeof e.isComposing === 'boolean' && e.isComposing) return;
} catch {}
// Avoid double handling when per-element listener already attached to same element
if (this._focusedEl && e.target === this._focusedEl) return;
// Find the innermost editable element from composedPath
const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
let el = null;
for (let i = 0; i < path.length; i++) {
const n = path[i];
if (n instanceof HTMLInputElement || n instanceof HTMLTextAreaElement) {
el = n;
break;
}
// Also check for contenteditable
if (n && n.nodeType === 1 && /** @type {HTMLElement} */ (n).isContentEditable === true) {
el = /** @type {HTMLElement} */ (n);
break;
}
}
// As a fallback, walk down activeElement chain (deep active element via shadow roots)
if (!el) {
try {
let ae = document.activeElement;
let guard = 0;
while (ae && guard++ < 10) {
if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement) {
el = ae;
break;
}
// Check contenteditable in shadow DOM traversal
if (
ae &&
ae.nodeType === 1 &&
/** @type {HTMLElement} */ (ae).isContentEditable === true
) {
el = /** @type {HTMLElement} */ (ae);
break;
}
const anyAe = ae;
if (anyAe && anyAe.shadowRoot && anyAe.shadowRoot.activeElement) {
ae = anyAe.shadowRoot.activeElement;
continue;
}
break;
}
} catch {}
}
if (!el) return;
this._handleInputForElement(el);
}
// Shared input processing logic (debounce/merge/sensitivity)
// Uses Draft/Upsert model: updates are re-enqueued to ensure background gets final value
_handleInputForElement(el) {
try {
const t = (el.getAttribute && el.getAttribute('type')) || '';
const tt = String(t).toLowerCase();
if (tt === 'checkbox' || tt === 'radio' || tt === 'file') return;
} catch {}
const elRef = this._getElRef(el);
const target = SelectorEngine.buildTarget(el);
// Check if element is contenteditable
const isContentEditable =
el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true;
const isSensitive =
this.hideInputValues ||
(!isContentEditable &&
CONFIG.SENSITIVE_INPUT_TYPES.has(
((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(),
));
// Get value: use .value for input/textarea, .innerText for contenteditable
let value = isContentEditable
? /** @type {HTMLElement} */ (el).innerText || ''
: el.value || '';
if (isSensitive) {
const varKey = el.name ? el.name : `var_${Math.random().toString(36).slice(2, 6)}`;
this._addVariable(varKey, true, '');
value = `{${varKey}}`;
}
const nowTs = Date.now();
const last = this.lastFill.step;
const sameRef = !!(last && last._recordingRef === elRef);
const sameSelector = !!(
last &&
last.target &&
last.target.selector &&
target &&
target.selector &&
last.target.selector === target.selector
);
const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;
if ((sameRef || sameSelector) && within) {
// Update existing step's value
this.lastFill.step.value = value;
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
this.lastFill.ts = nowTs;
this.lastFill.el = el; // Keep DOM reference updated for finalize
// Keep flush gate aligned to the latest keystroke
this._updateInputActivity();
// Re-enqueue the updated step for upsert (ensures background gets final value)
this._enqueueForUpsert(this.lastFill.step);
return;
}
const newStep = { type: 'fill', target, value, screenshotOnFail: true };
newStep._recordingRef = elRef;
this._pushStep(newStep);
this.lastFill = { step: newStep, ts: nowTs, el: el };
}
/**
* Enqueue a step for upsert - if step with same id exists in batch, update it.
* This ensures the background receives the final value for fill steps.
* In iframes, forwards to top window to maintain selector composition consistency.
*/
_enqueueForUpsert(step) {
if (!step || !step.id) return;
// In iframes, forward upsert updates to top so we don't lose composed selectors.
// The top window aggregates iframe steps and computes "frame |> inner" selectors.
// If iframe sends directly to background, it would overwrite the composed selector.
if (window !== window.top) {
try {
const payload = {
kind: 'iframeStepUpsert',
href: String(location && location.href ? location.href : ''),
step,
};
window.top.postMessage({ type: FRAME_EVENT, payload }, '*');
} catch {}
return;
}
// Check if step already in batch
const existingIdx = this.batch.findIndex((s) => s.id === step.id);
if (existingIdx >= 0) {
// Update existing entry in batch
this.batch[existingIdx] = step;
} else {
// Add to batch (step was already flushed, so we need to send update)
this.batch.push(step);
}
// Schedule flush with appropriate delay (respects typing gate)
this._scheduleFlush();
}
_onChange(e) {
if (!this.isRecording || this.isPaused) return;
const el = e.target;
if (el instanceof HTMLSelectElement) {
const val = el.value;
const nowTs = Date.now();
const elRef = this._getElRef(el);
const sameRef = !!(this.lastFill.step && this.lastFill.step._recordingRef === elRef);
const within = nowTs - this.lastFill.ts <= CONFIG.INPUT_DEBOUNCE_MS;
if (sameRef && within) {
this.lastFill.step.value = val;
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
this.lastFill.ts = nowTs;
this.lastFill.el = el; // Keep DOM reference updated
// Re-enqueue for upsert
this._enqueueForUpsert(this.lastFill.step);
return;
}
const target = SelectorEngine.buildTarget(el);
try {
const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
if (gref) target.ref = gref;
} catch {}
const st = { type: 'fill', target, value: val, screenshotOnFail: true };
st._recordingRef = elRef;
this._pushStep(st);
this.lastFill = { step: st, ts: nowTs, el: el };
return;
}
if (el instanceof HTMLInputElement) {
const t = (el.getAttribute && el.getAttribute('type')) || '';
const tt = String(t).toLowerCase();
const target = SelectorEngine.buildTarget(el);
try {
const gref = SelectorEngine._ensureGlobalRef && SelectorEngine._ensureGlobalRef(el);
if (gref) target.ref = gref;
} catch {}
const elRef = this._getElRef(el);
if (tt === 'checkbox') {
const st = { type: 'fill', target, value: !!el.checked, screenshotOnFail: true };
st._recordingRef = elRef;
this._pushStep(st);
return;
}
if (tt === 'radio') {
const st = { type: 'fill', target, value: true, screenshotOnFail: true };
st._recordingRef = elRef;
this._pushStep(st);
return;
}
if (tt === 'file') {
const varKey = el.name ? el.name : `file_${Math.random().toString(36).slice(2, 6)}`;
this._addVariable(varKey, false, '');
this._pushStep({ type: 'fill', target, value: `{${varKey}}`, screenshotOnFail: true });
return;
}
}
}
_getElRef(el) {
try {
let ref = this.el2ref.get(el);
if (ref) return ref;
ref = `ref_${++this.refCounter}`;
this.el2ref.set(el, ref);
return ref;
} catch {
// Fallback to timestamp-based ref if WeakMap fails (should not happen)
return `ref_${Date.now()}`;
}
}
// UI handled by injected UI class
_onFocusIn(e) {
if (!this.isRecording || this.isPaused) return;
const el = e.target;
const isEditable =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
(el && el.nodeType === 1 && /** @type {HTMLElement} */ (el).isContentEditable === true);
if (!isEditable) return;
if (this._focusedEl && this._focusedEl !== el)
this._focusedEl.removeEventListener('input', this._onInput, true);
el.addEventListener('input', this._onInput, true);
this._focusedEl = el;
}
_onFocusOut(e) {
const el = e.target;
if (!el) return;
if (this._focusedEl === el) {
// Commit point: leaving an input field - finalize and flush pending input
// This ensures we don't lose values when user tabs away or clicks elsewhere
this._commitAndFlush();
el.removeEventListener('input', this._onInput, true);
this._focusedEl = null;
}
}
_onMouseMove(e) {
if (!this.highlightEnabled || !this.ui._box || !this.isRecording || this.isPaused) return;
if (this.hoverRAF) return;
const el = e.target instanceof Element ? e.target : null;
if (!el) return;
this.hoverRAF = requestAnimationFrame(() => {
try {
const r = el.getBoundingClientRect();
Object.assign(this.ui._box.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 {}
this.hoverRAF = 0;
});
}
_onScroll(e) {
if (!this.isRecording || this.isPaused) return;
try {
const overlay = document.getElementById('__rr_rec_overlay');
if (overlay) {
// Use composedPath for shadow DOM compatibility, fallback to target
const path = typeof e.composedPath === 'function' ? e.composedPath() : [e.target];
for (const element of path) {
// If the event path contains our overlay, ignore this scroll event
if (element === overlay) {
return;
}
}
}
} catch {
// ignore
}
// Determine scroll source and positions
const isDoc = e.target === document;
const el = isDoc ? document.documentElement : e.target instanceof Element ? e.target : null;
if (!el) return;
let top = 0,
left = 0;
try {
if (isDoc) {
top =
typeof window.scrollY === 'number'
? window.scrollY
: document.documentElement.scrollTop || 0;
left =
typeof window.scrollX === 'number'
? window.scrollX
: document.documentElement.scrollLeft || 0;
} else {
top = el.scrollTop || 0;
left = el.scrollLeft || 0;
}
} catch {}
const target = isDoc ? null : SelectorEngine.buildTarget(el);
// Debounce/coalesce
this._scrollPending = { isDoc, target, top, left };
if (this.scrollTimer) {
clearTimeout(this.scrollTimer);
}
this.scrollTimer = setTimeout(() => {
this.scrollTimer = null;
const pending = this._scrollPending;
this._scrollPending = null;
if (!pending) return;
const { isDoc: pDoc, target: pTarget, top: pTop, left: pLeft } = pending;
// Try merge with last step
const steps = this.sessionBuffer.steps;
const last = steps.length ? steps[steps.length - 1] : null;
if (last && last.type === 'scroll') {
const sameDoc = pDoc && !last.target && last.mode === 'offset';
const sameEl =
!pDoc &&
last.target &&
last.target.selector &&
pTarget &&
last.target.selector === pTarget.selector &&
last.mode === 'container';
if (sameDoc || sameEl) {
last.offset = { y: pTop, x: pLeft };
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
return;
}
}
// New scroll step
if (pDoc) {
this._pushStep({
type: 'scroll',
mode: 'offset',
offset: { y: pTop, x: pLeft },
screenshotOnFail: false,
});
} else {
this._pushStep({
type: 'scroll',
mode: 'container',
target: pTarget,
offset: { y: pTop, x: pLeft },
screenshotOnFail: false,
});
}
}, CONFIG.SCROLL_DEBOUNCE_MS);
}
// Minimal key recorder: record Enter and modifier combos; avoid plain typing
_onKeyDown(e) {
if (!this.isRecording || this.isPaused) return;
try {
// Ignore autorepeat to prevent spam
if (e.repeat) return;
const key = String(e.key || '').toLowerCase();
const isModifier = key === 'shift' || key === 'control' || key === 'meta' || key === 'alt';
const isEditable =
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target &&
e.target.nodeType === 1 &&
/** @type {HTMLElement} */ (e.target).isContentEditable === true);
const enterKey = key === 'enter';
// Track pressed modifiers
if (isModifier) this._pressed.add(key);
// Handle Enter in editable contexts (including contenteditable)
if (isEditable && enterKey) {
// Commit point: Enter may trigger form submission/navigation
// Record explicit key action with target first
const target = SelectorEngine.buildTarget(/** @type {Element} */ (e.target));
const combo = this._formatKeysCombo(e, 'Enter');
this._pushStep({ type: 'key', keys: combo, target, screenshotOnFail: false });
// Then commit and flush (form submit may navigate away)
this._commitAndFlush();
this._lastKeyTs = Date.now();
return;
}
// For non-text fields: record modifier combos and special keys
const special = enterKey || key === 'escape' || key === 'tab';
if (special || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
const comboName = this._formatKeysCombo(e, e.key);
this._pushStep({ type: 'key', keys: comboName, screenshotOnFail: false });
this._lastKeyTs = Date.now();
}
} catch {}
}
_onKeyUp(e) {
const key = String(e.key || '').toLowerCase();
if (key === 'shift' || key === 'control' || key === 'meta' || key === 'alt')
this._pressed.delete(key);
}
_formatKeysCombo(e, mainKey) {
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Meta');
const mk = String(mainKey || '').trim();
// Normalize common names to match keyboard-helper parsing
const norm = (s) => {
const k = s.toLowerCase();
if (k === 'escape') return 'Esc';
if (k === ' ') return 'Space';
if (k.length === 1) return k.toUpperCase();
return s;
};
parts.push(norm(mk));
return parts.join('+');
}
// Top-level aggregator: receives iframe events and merges into session
_onWindowMessage(ev) {
try {
const d = ev && ev.data;
if (!d || d.type !== FRAME_EVENT || !d.payload) return;
// Security: validate message source is from a known iframe in our page
// ev.source must match contentWindow of an iframe element we control
let frameEl = null;
try {
const frames = document.querySelectorAll('iframe,frame');
for (let i = 0; i < frames.length; i++) {
const f = frames[i];
if (f && f.contentWindow === ev.source) {
frameEl = f;
break;
}
}
} catch {}
// Reject messages not from a recognized iframe in our document
if (!frameEl) {
// Message source is not from a child iframe we control - ignore
return;
}
// Additional origin check: only accept from same origin or about:blank iframes
// (cross-origin iframes legitimately send from their origin)
try {
const selfOrigin = window.location.origin;
const msgOrigin = ev.origin;
// Allow same-origin, null (for sandboxed iframes), or if iframe src is same-origin
const frameSrc = frameEl.getAttribute('src') || '';
let iframeSameOrigin = false;
try {
if (!frameSrc || frameSrc === 'about:blank') {
iframeSameOrigin = true;
} else {
const frameUrl = new URL(frameSrc, selfOrigin);
iframeSameOrigin = frameUrl.origin === selfOrigin;
}
} catch {
// Invalid URL - assume cross-origin
}
// If iframe is same-origin, message origin should match
if (iframeSameOrigin && msgOrigin !== selfOrigin && msgOrigin !== 'null') {
return; // Origin mismatch for same-origin iframe - suspicious
}
} catch {}
const payload = d.payload || {};
const kind = payload.kind;
// Stop barrier sync: ACK back to the iframe so it can finish stop only after
// its final postMessages have been processed by the top aggregator
if (kind === 'iframeStopBarrier') {
try {
const id = payload.id;
if (id && ev.source && typeof ev.source.postMessage === 'function') {
ev.source.postMessage(
{ type: FRAME_EVENT, payload: { kind: 'iframeStopBarrierAck', id } },
'*',
);
}
} catch {}
return;
}
// Handle iframe flush request: immediately flush top's aggregated buffer
if (kind === 'iframeFlush') {
this._lastInputActivityTs = 0;
this._typingBurstStartTs = 0;
this._clearForceFlushTimer();
if (this.batchTimer) clearTimeout(this.batchTimer);
this.batchTimer = null;
if (this.batch.length > 0) this._flush();
return;
}
const { step, href } = payload;
if (!step || typeof step !== 'object') return;
// Compose frame selector for iframe steps
const frameTarget = SelectorEngine.buildTarget(frameEl);
const frameSel = frameTarget?.selector || '';
// For upsert: find existing step in session and update it
if (kind === 'iframeStepUpsert') {
// Update input activity for iframe fills (enables flush gate for iframe input)
if (step.type === 'fill') {
this._updateInputActivity();
}
// Find step by id in session buffer and update its value
const existingIdx = this.sessionBuffer.steps.findIndex((s) => s.id === step.id);
if (existingIdx >= 0) {
// Update value but preserve the composed selector
this.sessionBuffer.steps[existingIdx].value = step.value;
this.sessionBuffer.meta.updatedAt = new Date().toISOString();
// Also update in batch if present
const batchIdx = this.batch.findIndex((s) => s.id === step.id);
if (batchIdx >= 0) {
this.batch[batchIdx].value = step.value;
} else {
// Step was already flushed, add updated version to batch
const updatedStep = { ...this.sessionBuffer.steps[existingIdx] };
this.batch.push(updatedStep);
}
this._scheduleFlush();
}
return;
}
// Regular iframe step: compose composite selector and push
if (step.target) {
const inner = String(step.target.selector || '').trim();
if (frameSel && inner) {
const composite = `${frameSel} |> ${inner}`;
step.target.selector = composite;
if (Array.isArray(step.target.candidates)) {
step.target.candidates.unshift({ type: 'css', value: composite });
}
}
}
this._pushStep(step);
} catch {}
}
}
// ================================================================
// 3) SINGLETON + MESSAGE HANDLERS
// ================================================================
let recorderInstance = null;
function getRecorder() {
if (!recorderInstance) recorderInstance = new ContentRecorder();
return recorderInstance;
}
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
try {
if (!request || !request.action) return false;
if (request.action === 'rr_timeline_update') {
const rec = getRecorder();
// Only respond to timeline updates when recording is active
if (!rec.isRecording) {
sendResponse({ ok: true, ignored: true });
return true;
}
// Replace entire timeline to avoid divergence across tabs
const steps = Array.isArray(request.steps) ? request.steps : [];
rec.ui.applyTimelineUpdate(steps);
sendResponse({ ok: true });
return true;
}
if (request.action === 'rr_recorder_control') {
const rec = getRecorder();
const cmd = request.cmd;
if (cmd === 'start') {
rec.start(request.meta || {});
sendResponse({ success: true });
return true;
}
if (cmd === 'pause') {
rec.pause();
sendResponse({ success: true });
return true;
}
if (cmd === 'resume') {
rec.resume();
sendResponse({ success: true });
return true;
}
if (cmd === 'stop') {
// Stop is now async - flush all data and wait for ack before responding
rec
.stop()
.then((result) => {
sendResponse({ success: true, ack: result.ack, stats: result });
})
.catch((err) => {
sendResponse({ success: false, ack: false, error: String(err) });
});
return true; // Keep channel open for async response
}
sendResponse({ success: false, error: 'Unknown command' });
return true;
}
// Handle direct stop message with ack (sent by recorder-manager)
if (request.action === 'stop' && request.requireAck) {
const rec = getRecorder();
rec
.stop()
.then((result) => {
sendResponse({ ack: result.ack, stats: result });
})
.catch(() => {
sendResponse({ ack: false });
});
return true;
}
if (request.action === 'rr_recorder_ping') {
sendResponse({ status: 'pong' });
return false;
}
} catch (e) {
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
return true;
}
return false;
});
console.log('Record & Replay recorder.js loaded');
})();