@midscene/recorder-ui 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ import { RECORDER_INPUT_BATCH_DELAY_MS, RECORDER_SCROLL_BATCH_DELAY_MS } from "@midscene/shared/constants";
2
+ import { getElementXpath, isNotContainerElement } from "@midscene/shared/extractor";
3
+ const DEBUG = 'undefined' != typeof localStorage && 'true' === localStorage.getItem('DEBUG');
4
+ function debugLog(...args) {
5
+ if (DEBUG) console.log('[EventRecorder]', ...args);
6
+ }
7
+ function generateHashId(type, elementRect) {
8
+ const rectStr = elementRect ? `${elementRect.left}_${elementRect.top}_${elementRect.width}_${elementRect.height}${void 0 !== elementRect.x ? `_${elementRect.x}` : ''}${void 0 !== elementRect.y ? `_${elementRect.y}` : ''}` : 'no_rect';
9
+ const combined = `${type}_${rectStr}_${Date.now()}`;
10
+ let hash = 0;
11
+ for(let i = 0; i < combined.length; i++){
12
+ const char = combined.charCodeAt(i);
13
+ hash = (hash << 5) - hash + char;
14
+ hash &= hash;
15
+ }
16
+ return Math.abs(hash).toString(36);
17
+ }
18
+ const isSameInputTarget = (event1, event2)=>event1.element === event2.element;
19
+ const isSameScrollTarget = (event1, event2)=>event1.element === event2.element;
20
+ const getLastLabelClick = (events)=>{
21
+ for(let i = events.length - 1; i >= 0; i--){
22
+ const event = events[i];
23
+ if ('click' === event.type && event.isLabelClick) return event;
24
+ }
25
+ };
26
+ class EventRecorder {
27
+ isRecording = false;
28
+ eventCallback;
29
+ scrollThrottleTimer = null;
30
+ scrollThrottleDelay = RECORDER_SCROLL_BATCH_DELAY_MS;
31
+ inputThrottleTimer = null;
32
+ inputThrottleDelay = RECORDER_INPUT_BATCH_DELAY_MS;
33
+ lastViewportScroll = null;
34
+ sessionId;
35
+ mutationObserver = null;
36
+ constructor(eventCallback, sessionId){
37
+ this.eventCallback = eventCallback;
38
+ this.sessionId = sessionId;
39
+ }
40
+ createNavigationEvent(url, title) {
41
+ return {
42
+ type: 'navigation',
43
+ url,
44
+ title,
45
+ pageInfo: {
46
+ width: window.innerWidth,
47
+ height: window.innerHeight
48
+ },
49
+ timestamp: Date.now(),
50
+ hashId: `navigation_${Date.now()}`
51
+ };
52
+ }
53
+ start() {
54
+ if (this.isRecording) return void debugLog('Recording already active, ignoring start request');
55
+ this.isRecording = true;
56
+ debugLog('Starting event recording');
57
+ setTimeout(()=>{
58
+ const navigationEvent = this.createNavigationEvent(window.location.href, document.title);
59
+ this.eventCallback(navigationEvent);
60
+ debugLog('Added final navigation event', navigationEvent);
61
+ }, 0);
62
+ document.addEventListener('click', this.handleClick, true);
63
+ document.addEventListener('input', this.handleInput);
64
+ document.addEventListener('scroll', this.handleScroll, {
65
+ capture: true,
66
+ passive: true
67
+ });
68
+ }
69
+ stop() {
70
+ if (!this.isRecording) return void debugLog('Recording not active, ignoring stop request');
71
+ this.isRecording = false;
72
+ debugLog('Stopping event recording');
73
+ if (this.scrollThrottleTimer) {
74
+ clearTimeout(this.scrollThrottleTimer);
75
+ this.scrollThrottleTimer = null;
76
+ }
77
+ if (this.inputThrottleTimer) {
78
+ clearTimeout(this.inputThrottleTimer);
79
+ this.inputThrottleTimer = null;
80
+ }
81
+ document.removeEventListener('click', this.handleClick, true);
82
+ document.removeEventListener('input', this.handleInput);
83
+ document.removeEventListener('scroll', this.handleScroll, true);
84
+ debugLog('Removed all event listeners');
85
+ }
86
+ handleClick = (event)=>{
87
+ if (!this.isRecording) return;
88
+ const target = event.target;
89
+ const { isLabelClick, labelInfo } = this.checkLabelClick(target);
90
+ const rect = target.getBoundingClientRect();
91
+ const elementRect = {
92
+ x: Number(event.clientX.toFixed(2)),
93
+ y: Number(event.clientY.toFixed(2))
94
+ };
95
+ console.log('isNotContainerElement', isNotContainerElement(target));
96
+ if (isNotContainerElement(target)) {
97
+ elementRect.left = Number(rect.left.toFixed(2));
98
+ elementRect.top = Number(rect.top.toFixed(2));
99
+ elementRect.width = Number(rect.width.toFixed(2));
100
+ elementRect.height = Number(rect.height.toFixed(2));
101
+ }
102
+ const clickEvent = {
103
+ type: 'click',
104
+ elementRect,
105
+ pageInfo: {
106
+ width: window.innerWidth,
107
+ height: window.innerHeight
108
+ },
109
+ value: '',
110
+ timestamp: Date.now(),
111
+ hashId: generateHashId('click', {
112
+ ...elementRect
113
+ }),
114
+ element: target,
115
+ isLabelClick,
116
+ labelInfo,
117
+ isTrusted: event.isTrusted,
118
+ detail: event.detail
119
+ };
120
+ this.eventCallback(clickEvent);
121
+ };
122
+ handleScroll = (event)=>{
123
+ if (!this.isRecording) return;
124
+ function isDocument(target) {
125
+ return target instanceof Document;
126
+ }
127
+ const target = event.target;
128
+ const scrollXTarget = isDocument(target) ? window.scrollX : target.scrollLeft;
129
+ const scrollYTarget = isDocument(target) ? window.scrollY : target.scrollTop;
130
+ const rect = isDocument(target) ? {
131
+ left: 0,
132
+ top: 0,
133
+ width: window.innerWidth,
134
+ height: window.innerHeight
135
+ } : target.getBoundingClientRect();
136
+ if (this.scrollThrottleTimer) clearTimeout(this.scrollThrottleTimer);
137
+ this.scrollThrottleTimer = window.setTimeout(()=>{
138
+ if (this.isRecording) {
139
+ const elementRect = {
140
+ left: isDocument(target) ? 0 : Number(rect.left.toFixed(2)),
141
+ top: isDocument(target) ? 0 : Number(rect.top.toFixed(2)),
142
+ width: isDocument(target) ? window.innerWidth : Number(rect.width.toFixed(2)),
143
+ height: isDocument(target) ? window.innerHeight : Number(rect.height.toFixed(2))
144
+ };
145
+ const scrollEvent = {
146
+ type: 'scroll',
147
+ elementRect,
148
+ pageInfo: {
149
+ width: window.innerWidth,
150
+ height: window.innerHeight
151
+ },
152
+ value: `${scrollXTarget.toFixed(2)},${scrollYTarget.toFixed(2)}`,
153
+ timestamp: Date.now(),
154
+ hashId: generateHashId('scroll', {
155
+ ...elementRect
156
+ }),
157
+ element: target
158
+ };
159
+ this.eventCallback(scrollEvent);
160
+ }
161
+ this.scrollThrottleTimer = null;
162
+ }, this.scrollThrottleDelay);
163
+ };
164
+ handleInput = (event)=>{
165
+ if (!this.isRecording) return;
166
+ const target = event.target;
167
+ if ('checkbox' === target.type) return;
168
+ const rect = target.getBoundingClientRect();
169
+ const elementRect = {
170
+ left: Number(rect.left.toFixed(2)),
171
+ top: Number(rect.top.toFixed(2)),
172
+ width: Number(rect.width.toFixed(2)),
173
+ height: Number(rect.height.toFixed(2))
174
+ };
175
+ if (this.inputThrottleTimer) clearTimeout(this.inputThrottleTimer);
176
+ this.inputThrottleTimer = window.setTimeout(()=>{
177
+ if (this.isRecording) {
178
+ const inputEvent = {
179
+ type: 'input',
180
+ value: 'password' !== target.type ? target.value : '*****',
181
+ timestamp: Date.now(),
182
+ hashId: generateHashId('input', {
183
+ ...elementRect
184
+ }),
185
+ element: target,
186
+ inputType: target.type || 'text',
187
+ elementRect,
188
+ pageInfo: {
189
+ width: window.innerWidth,
190
+ height: window.innerHeight
191
+ }
192
+ };
193
+ debugLog('Throttled input event:', {
194
+ value: inputEvent.value,
195
+ timestamp: inputEvent.timestamp,
196
+ target: target.tagName,
197
+ inputType: target.type
198
+ });
199
+ this.eventCallback(inputEvent);
200
+ }
201
+ this.inputThrottleTimer = null;
202
+ }, this.inputThrottleDelay);
203
+ };
204
+ checkLabelClick(target) {
205
+ let isLabelClick = false;
206
+ let labelInfo;
207
+ if (target) if ('LABEL' === target.tagName) {
208
+ isLabelClick = true;
209
+ labelInfo = {
210
+ htmlFor: target.htmlFor,
211
+ textContent: target.textContent?.trim(),
212
+ xpath: getElementXpath(target)
213
+ };
214
+ } else {
215
+ let parent = target.parentElement;
216
+ while(parent){
217
+ if ('LABEL' === parent.tagName) {
218
+ isLabelClick = true;
219
+ labelInfo = {
220
+ htmlFor: parent.htmlFor,
221
+ textContent: parent.textContent?.trim(),
222
+ xpath: getElementXpath(parent)
223
+ };
224
+ break;
225
+ }
226
+ parent = parent.parentElement;
227
+ }
228
+ }
229
+ return {
230
+ isLabelClick,
231
+ labelInfo
232
+ };
233
+ }
234
+ isActive() {
235
+ return this.isRecording;
236
+ }
237
+ optimizeEvent(event, events) {
238
+ const lastEvent = events[events.length - 1];
239
+ if ('click' === event.type) {
240
+ const lastEvent = getLastLabelClick(events);
241
+ if (event.element) {
242
+ const { isLabelClick, labelInfo } = this.checkLabelClick(event.element);
243
+ if (lastEvent && isLabelClick && 'click' === lastEvent.type && lastEvent.isLabelClick && (lastEvent.labelInfo?.htmlFor && event.element.id && lastEvent.labelInfo?.htmlFor === event.element.id || labelInfo?.xpath && lastEvent.labelInfo?.xpath && lastEvent.labelInfo?.xpath === labelInfo?.xpath)) {
244
+ debugLog('Skip input event triggered by label click:', event.element);
245
+ return events;
246
+ }
247
+ return [
248
+ ...events,
249
+ event
250
+ ];
251
+ }
252
+ }
253
+ if ('input' === event.type) {
254
+ if (lastEvent && 'click' === lastEvent.type && lastEvent.isLabelClick && lastEvent.labelInfo?.htmlFor === event.targetId) {
255
+ debugLog('Skipping input event - triggered by label click:', {
256
+ labelHtmlFor: getLastLabelClick(events)?.labelInfo?.htmlFor,
257
+ inputId: event.targetId,
258
+ element: event.element
259
+ });
260
+ return events;
261
+ }
262
+ if (lastEvent && 'input' === lastEvent.type && isSameInputTarget(lastEvent, event)) {
263
+ const oldInputEvent = events[events.length - 1];
264
+ const newEvents = [
265
+ ...events
266
+ ];
267
+ newEvents[events.length - 1] = {
268
+ value: event.element?.value,
269
+ ...event
270
+ };
271
+ debugLog('Merging input event:', {
272
+ oldValue: oldInputEvent.value,
273
+ newValue: event.value,
274
+ oldTimestamp: oldInputEvent.timestamp,
275
+ newTimestamp: event.timestamp,
276
+ target: event.targetTagName
277
+ });
278
+ return newEvents;
279
+ }
280
+ }
281
+ if ('scroll' === event.type) {
282
+ if (lastEvent && 'scroll' === lastEvent.type && isSameScrollTarget(lastEvent, event)) {
283
+ const oldScrollEvent = events[events.length - 1];
284
+ const newEvents = [
285
+ ...events
286
+ ];
287
+ newEvents[events.length - 1] = event;
288
+ debugLog('Replacing last scroll event with new scroll event:', {
289
+ oldPosition: `${oldScrollEvent.elementRect?.left},${oldScrollEvent.elementRect?.top}`,
290
+ newPosition: `${event.elementRect?.left},${event.elementRect?.top}`,
291
+ oldTimestamp: oldScrollEvent.timestamp,
292
+ newTimestamp: event.timestamp,
293
+ target: event.targetTagName
294
+ });
295
+ return newEvents;
296
+ }
297
+ }
298
+ return [
299
+ ...events,
300
+ event
301
+ ];
302
+ }
303
+ }
304
+ function convertToChromeEvent(event) {
305
+ return {
306
+ type: event.type,
307
+ url: event.url,
308
+ title: event.title,
309
+ value: event.value,
310
+ elementRect: event.elementRect,
311
+ pageInfo: event.pageInfo,
312
+ screenshotBefore: event.screenshotBefore,
313
+ screenshotAfter: event.screenshotAfter,
314
+ semantic: event.semantic,
315
+ screenshotWithBox: event.screenshotWithBox,
316
+ timestamp: event.timestamp,
317
+ hashId: event.hashId
318
+ };
319
+ }
320
+ function convertToChromeEvents(events) {
321
+ return events.map(convertToChromeEvent);
322
+ }
323
+ export { EventRecorder, convertToChromeEvent, convertToChromeEvents };
@@ -0,0 +1,10 @@
1
+ import './button.css';
2
+ interface ButtonProps {
3
+ primary?: boolean;
4
+ backgroundColor?: string;
5
+ size?: 'small' | 'medium' | 'large';
6
+ label: string;
7
+ onClick?: () => void;
8
+ }
9
+ export declare const Button: ({ primary, size, backgroundColor, label, ...props }: ButtonProps) => import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -0,0 +1,9 @@
1
+ import type { RecordedEvent } from './recorder';
2
+ import './RecordTimeline.css';
3
+ interface RecordTimelineProps {
4
+ events: RecordedEvent[];
5
+ onEventClick?: (event: RecordedEvent, index: number) => void;
6
+ variant?: 'default' | 'chrome-extension';
7
+ }
8
+ export declare const RecordTimeline: ({ events, onEventClick, variant, }: RecordTimelineProps) => import("react/jsx-runtime").JSX.Element;
9
+ export {};
@@ -0,0 +1,12 @@
1
+ import type React from 'react';
2
+ import './shiny-text.css';
3
+ type ColorTheme = 'blue' | 'purple' | 'green' | 'rainbow';
4
+ interface ShinyTextProps {
5
+ text: string;
6
+ disabled?: boolean;
7
+ speed?: number;
8
+ className?: string;
9
+ colorTheme?: ColorTheme;
10
+ }
11
+ export declare const ShinyText: React.FC<ShinyTextProps>;
12
+ export {};
@@ -0,0 +1,3 @@
1
+ export { Button } from './Button';
2
+ export { EventRecorder, type RecordedEvent, type ChromeRecordedEvent, convertToChromeEvent, convertToChromeEvents, } from './recorder';
3
+ export { RecordTimeline } from './RecordTimeline';
@@ -0,0 +1,6 @@
1
+ import { EventRecorder } from './recorder';
2
+ declare global {
3
+ interface Window {
4
+ EventRecorder: typeof EventRecorder;
5
+ }
6
+ }
@@ -0,0 +1,41 @@
1
+ import type { MidsceneRecorderEvent } from '@midscene/shared/recorder';
2
+ export type ChromeRecordedEvent = MidsceneRecorderEvent;
3
+ export interface RecordedEvent extends ChromeRecordedEvent {
4
+ element?: HTMLElement;
5
+ targetTagName?: string;
6
+ targetId?: string;
7
+ targetClassName?: string;
8
+ isLabelClick?: boolean;
9
+ labelInfo?: {
10
+ htmlFor?: string;
11
+ textContent?: string;
12
+ xpath?: string;
13
+ };
14
+ isTrusted?: boolean;
15
+ detail?: number;
16
+ inputType?: string;
17
+ }
18
+ export type EventCallback = (event: RecordedEvent) => void;
19
+ export declare class EventRecorder {
20
+ private isRecording;
21
+ private eventCallback;
22
+ private scrollThrottleTimer;
23
+ private scrollThrottleDelay;
24
+ private inputThrottleTimer;
25
+ private inputThrottleDelay;
26
+ private lastViewportScroll;
27
+ private sessionId;
28
+ private mutationObserver;
29
+ constructor(eventCallback: EventCallback, sessionId: string);
30
+ createNavigationEvent(url: string, title: string): ChromeRecordedEvent;
31
+ start(): void;
32
+ stop(): void;
33
+ private handleClick;
34
+ private handleScroll;
35
+ private handleInput;
36
+ private checkLabelClick;
37
+ isActive(): boolean;
38
+ optimizeEvent(event: RecordedEvent, events: RecordedEvent[]): RecordedEvent[];
39
+ }
40
+ export declare function convertToChromeEvent(event: RecordedEvent): ChromeRecordedEvent;
41
+ export declare function convertToChromeEvents(events: RecordedEvent[]): ChromeRecordedEvent[];
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@midscene/recorder-ui",
3
+ "version": "0.0.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/web-infra-dev/midscene.git",
7
+ "directory": "packages/recorder"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/src/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "types": "./dist/types/src/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "devDependencies": {
21
+ "@rsbuild/plugin-react": "^1.4.1",
22
+ "@rslib/core": "^0.18.3",
23
+ "@types/react": "^18.3.1",
24
+ "react": "18.3.1",
25
+ "typescript": "^5.8.3"
26
+ },
27
+ "dependencies": {
28
+ "@ant-design/icons": "^5.3.1",
29
+ "antd": "^5.21.6",
30
+ "dayjs": "^1.11.11",
31
+ "react-dom": "18.3.1",
32
+ "@midscene/shared": "1.10.11"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "18.3.1",
36
+ "react-dom": "18.3.1"
37
+ },
38
+ "scripts": {
39
+ "build": "rslib build",
40
+ "dev": "npm run build:watch",
41
+ "build:watch": "rslib build --watch --no-clean"
42
+ }
43
+ }