angular-debug-recorder 1.0.3 → 1.1.1
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.
- package/README.md +57 -9
- package/esm2022/angular-debug-recorder.mjs +1 -1
- package/esm2022/lib/action-list/action-list.component.mjs +1 -1
- package/esm2022/lib/debug-panel/debug-panel.component.mjs +1 -1
- package/esm2022/lib/models/recorded-action.model.mjs +1 -1
- package/esm2022/lib/services/ai-generator.service.mjs +1 -1
- package/esm2022/lib/services/recorder.service.mjs +162 -71
- package/esm2022/lib/services/rrweb-recorder.service.mjs +1 -1
- package/esm2022/lib/session-replay/session-replay.component.mjs +83 -3
- package/esm2022/lib/settings-dialog/settings-dialog.component.mjs +1 -1
- package/esm2022/lib/test-preview/test-preview.component.mjs +1 -1
- package/esm2022/public-api.mjs +1 -1
- package/fesm2022/angular-debug-recorder.mjs +243 -72
- package/fesm2022/angular-debug-recorder.mjs.map +1 -1
- package/lib/services/recorder.service.d.ts +38 -2
- package/lib/session-replay/session-replay.component.d.ts +3 -0
- package/package.json +1 -1
|
@@ -5,9 +5,38 @@ import * as i1$1 from '@angular/forms';
|
|
|
5
5
|
import { FormsModule } from '@angular/forms';
|
|
6
6
|
import * as i1 from '@angular/common/http';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Performance notes
|
|
10
|
+
* ─────────────────
|
|
11
|
+
* All DOM event listeners are registered **outside Angular's NgZone**
|
|
12
|
+
* (zone.runOutsideAngular). This means a click, input, or scroll never
|
|
13
|
+
* triggers Angular Change Detection by itself.
|
|
14
|
+
*
|
|
15
|
+
* Recorded actions are batched in a plain array and flushed once per
|
|
16
|
+
* animation frame via requestAnimationFrame. Only the RAF callback runs
|
|
17
|
+
* inside zone.run() → at most one CD cycle per frame, regardless of how
|
|
18
|
+
* many events fired.
|
|
19
|
+
*
|
|
20
|
+
* Why this matters for OnPush host apps
|
|
21
|
+
* ──────────────────────────────────────
|
|
22
|
+
* When this recorder is embedded in a library used inside an OnPush app,
|
|
23
|
+
* the old zone.run()-per-event approach caused the host app's CD to run
|
|
24
|
+
* on every user interaction. With this implementation the host app is
|
|
25
|
+
* completely unaffected — only the recorder's own signals trigger CD.
|
|
26
|
+
*
|
|
27
|
+
* F5 / page-reload survival
|
|
28
|
+
* ─────────────────────────
|
|
29
|
+
* The active recording session is written to sessionStorage on
|
|
30
|
+
* beforeunload and restored in the constructor. If the user accidentally
|
|
31
|
+
* refreshes mid-recording, no actions are lost.
|
|
32
|
+
*/
|
|
8
33
|
class RecorderService {
|
|
34
|
+
// ─── SessionStorage keys ──────────────────────────────────────────────
|
|
35
|
+
static { this.SESSION_KEY = 'dr_active_session'; }
|
|
36
|
+
static { this.SESSIONS_KEY = 'dr_sessions'; }
|
|
9
37
|
constructor(zone) {
|
|
10
38
|
this.zone = zone;
|
|
39
|
+
// ─── Reactive state ───────────────────────────────────────────────────
|
|
11
40
|
this._isRecording = signal(false);
|
|
12
41
|
this._currentSession = signal(null);
|
|
13
42
|
this._sessions = signal([]);
|
|
@@ -17,9 +46,19 @@ class RecorderService {
|
|
|
17
46
|
this.currentSession = computed(() => this._currentSession());
|
|
18
47
|
this.sessions = computed(() => this._sessions());
|
|
19
48
|
this.actionCount = computed(() => this._currentSession()?.actions.length ?? 0);
|
|
49
|
+
// ─── RAF batch buffer ─────────────────────────────────────────────────
|
|
50
|
+
this.pendingActions = [];
|
|
51
|
+
// ─── Listener registry ────────────────────────────────────────────────
|
|
20
52
|
this.listeners = [];
|
|
21
53
|
this.lastScrollTime = 0;
|
|
54
|
+
this.restoreFromStorage();
|
|
55
|
+
// Register outside Angular zone — zone.js intercepts beforeunload
|
|
56
|
+
// inside the zone and may suppress it (dialog-return handling).
|
|
57
|
+
this.zone.runOutsideAngular(() => {
|
|
58
|
+
window.addEventListener('beforeunload', () => this.persistToStorage());
|
|
59
|
+
});
|
|
22
60
|
}
|
|
61
|
+
// ─── Public API ───────────────────────────────────────────────────────
|
|
23
62
|
startRecording(name, description) {
|
|
24
63
|
const session = {
|
|
25
64
|
id: this.generateId(),
|
|
@@ -34,9 +73,15 @@ class RecorderService {
|
|
|
34
73
|
this._isRecording.set(true);
|
|
35
74
|
this._isPaused.set(false);
|
|
36
75
|
this.attachListeners();
|
|
37
|
-
this.recordNavigation(
|
|
76
|
+
this.recordNavigation(window.location.href);
|
|
38
77
|
}
|
|
39
78
|
stopRecording() {
|
|
79
|
+
// Flush pending actions synchronously before tearing down
|
|
80
|
+
if (this.rafId !== undefined) {
|
|
81
|
+
cancelAnimationFrame(this.rafId);
|
|
82
|
+
this.rafId = undefined;
|
|
83
|
+
}
|
|
84
|
+
this.flushPending();
|
|
40
85
|
const session = this._currentSession();
|
|
41
86
|
if (!session)
|
|
42
87
|
return null;
|
|
@@ -46,6 +91,7 @@ class RecorderService {
|
|
|
46
91
|
this._isRecording.set(false);
|
|
47
92
|
this._isPaused.set(false);
|
|
48
93
|
this.detachListeners();
|
|
94
|
+
this.persistToStorage();
|
|
49
95
|
return completed;
|
|
50
96
|
}
|
|
51
97
|
pauseRecording() {
|
|
@@ -60,10 +106,7 @@ class RecorderService {
|
|
|
60
106
|
this._currentSession.update(s => s ? { ...s, actions: s.actions.filter(a => a.id !== actionId) } : null);
|
|
61
107
|
}
|
|
62
108
|
addNote(actionId, note) {
|
|
63
|
-
this._currentSession.update(s => s ? {
|
|
64
|
-
...s,
|
|
65
|
-
actions: s.actions.map(a => a.id === actionId ? { ...a, note } : a)
|
|
66
|
-
} : null);
|
|
109
|
+
this._currentSession.update(s => s ? { ...s, actions: s.actions.map(a => a.id === actionId ? { ...a, note } : a) } : null);
|
|
67
110
|
}
|
|
68
111
|
deleteSession(sessionId) {
|
|
69
112
|
this._sessions.update(s => s.filter(x => x.id !== sessionId));
|
|
@@ -71,23 +114,71 @@ class RecorderService {
|
|
|
71
114
|
loadSession(session) {
|
|
72
115
|
this._currentSession.set(session);
|
|
73
116
|
}
|
|
74
|
-
// ───
|
|
117
|
+
// ─── RAF batch flush ──────────────────────────────────────────────────
|
|
118
|
+
/**
|
|
119
|
+
* Schedule one signal update at the next animation frame.
|
|
120
|
+
* Multiple events within a single frame are coalesced → one CD cycle.
|
|
121
|
+
*/
|
|
122
|
+
scheduleFlush() {
|
|
123
|
+
if (this.rafId !== undefined)
|
|
124
|
+
return;
|
|
125
|
+
this.rafId = requestAnimationFrame(() => {
|
|
126
|
+
this.rafId = undefined;
|
|
127
|
+
this.flushPending();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
flushPending() {
|
|
131
|
+
if (!this.pendingActions.length)
|
|
132
|
+
return;
|
|
133
|
+
const batch = this.pendingActions.splice(0);
|
|
134
|
+
this.zone.run(() => {
|
|
135
|
+
this._currentSession.update(s => {
|
|
136
|
+
if (!s)
|
|
137
|
+
return s;
|
|
138
|
+
let actions = [...s.actions];
|
|
139
|
+
for (const action of batch) {
|
|
140
|
+
// Deduplicate consecutive inputs: keep only the latest value
|
|
141
|
+
if (action.type === 'input') {
|
|
142
|
+
const last = actions[actions.length - 1];
|
|
143
|
+
if (last?.type === 'input' && last.selector === action.selector) {
|
|
144
|
+
actions[actions.length - 1] = action;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
actions.push(action);
|
|
149
|
+
}
|
|
150
|
+
return { ...s, actions };
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// ─── Event listeners (registered outside NgZone) ──────────────────────
|
|
75
155
|
attachListeners() {
|
|
76
156
|
const opts = { capture: true, passive: true };
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
157
|
+
// Handlers run completely outside Angular Zone — zero CD impact per event.
|
|
158
|
+
// Only the RAF flush (above) re-enters the zone.
|
|
159
|
+
const onClick = (e) => { if (this.shouldRecord(e.target))
|
|
160
|
+
this.handleClick(e); };
|
|
161
|
+
const onDblClick = (e) => { if (this.shouldRecord(e.target))
|
|
162
|
+
this.handleDblClick(e); };
|
|
163
|
+
const onInput = (e) => { if (this.shouldRecord(e.target))
|
|
164
|
+
this.handleInput(e); };
|
|
165
|
+
const onChange = (e) => { if (this.shouldRecord(e.target))
|
|
166
|
+
this.handleChange(e); };
|
|
167
|
+
const onSubmit = (e) => { if (this.shouldRecord(e.target))
|
|
168
|
+
this.handleSubmit(e); };
|
|
169
|
+
const onKeydown = (e) => { if (this.shouldRecord(e.target))
|
|
170
|
+
this.handleKeydown(e); };
|
|
171
|
+
const onScroll = (e) => { if (this.shouldRecord(e.target))
|
|
172
|
+
this.handleScroll(e); };
|
|
173
|
+
this.zone.runOutsideAngular(() => {
|
|
174
|
+
document.addEventListener('click', onClick, opts);
|
|
175
|
+
document.addEventListener('dblclick', onDblClick, opts);
|
|
176
|
+
document.addEventListener('input', onInput, opts);
|
|
177
|
+
document.addEventListener('change', onChange, opts);
|
|
178
|
+
document.addEventListener('submit', onSubmit, opts);
|
|
179
|
+
document.addEventListener('keydown', onKeydown, opts);
|
|
180
|
+
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
|
181
|
+
});
|
|
91
182
|
this.listeners = [
|
|
92
183
|
{ type: 'click', fn: onClick },
|
|
93
184
|
{ type: 'dblclick', fn: onDblClick },
|
|
@@ -101,48 +192,38 @@ class RecorderService {
|
|
|
101
192
|
detachListeners() {
|
|
102
193
|
this.listeners.forEach(({ type, fn }) => document.removeEventListener(type, fn, true));
|
|
103
194
|
this.listeners = [];
|
|
104
|
-
this.mutationObserver?.disconnect();
|
|
105
195
|
}
|
|
106
|
-
// ─── Handlers
|
|
196
|
+
// ─── Handlers (run outside NgZone, call addAction which batches) ──────
|
|
107
197
|
handleClick(e) {
|
|
108
|
-
if (!this.shouldRecord(e.target))
|
|
109
|
-
return;
|
|
110
198
|
const el = e.target;
|
|
111
199
|
const info = this.getElementInfo(el);
|
|
112
|
-
const selector = this.buildSelector(el);
|
|
113
200
|
this.addAction({
|
|
114
201
|
type: 'click',
|
|
115
|
-
selector,
|
|
202
|
+
selector: this.buildSelector(el),
|
|
116
203
|
selectorStrategy: this.getSelectorStrategy(el),
|
|
117
204
|
element: info,
|
|
118
205
|
description: `Click on ${this.describeElement(info)}`,
|
|
119
206
|
});
|
|
120
207
|
}
|
|
121
208
|
handleDblClick(e) {
|
|
122
|
-
if (!this.shouldRecord(e.target))
|
|
123
|
-
return;
|
|
124
209
|
const el = e.target;
|
|
125
210
|
const info = this.getElementInfo(el);
|
|
126
|
-
const selector = this.buildSelector(el);
|
|
127
211
|
this.addAction({
|
|
128
212
|
type: 'dblclick',
|
|
129
|
-
selector,
|
|
213
|
+
selector: this.buildSelector(el),
|
|
130
214
|
selectorStrategy: this.getSelectorStrategy(el),
|
|
131
215
|
element: info,
|
|
132
216
|
description: `Double-click on ${this.describeElement(info)}`,
|
|
133
217
|
});
|
|
134
218
|
}
|
|
135
219
|
handleInput(e) {
|
|
136
|
-
if (!this.shouldRecord(e.target))
|
|
137
|
-
return;
|
|
138
220
|
const el = e.target;
|
|
139
221
|
if (['checkbox', 'radio'].includes(el.type))
|
|
140
222
|
return; // handled by change
|
|
141
223
|
const info = this.getElementInfo(el);
|
|
142
|
-
const selector = this.buildSelector(el);
|
|
143
224
|
this.addAction({
|
|
144
225
|
type: 'input',
|
|
145
|
-
selector,
|
|
226
|
+
selector: this.buildSelector(el),
|
|
146
227
|
selectorStrategy: this.getSelectorStrategy(el),
|
|
147
228
|
element: info,
|
|
148
229
|
value: el.value,
|
|
@@ -150,8 +231,6 @@ class RecorderService {
|
|
|
150
231
|
});
|
|
151
232
|
}
|
|
152
233
|
handleChange(e) {
|
|
153
|
-
if (!this.shouldRecord(e.target))
|
|
154
|
-
return;
|
|
155
234
|
const el = e.target;
|
|
156
235
|
const info = this.getElementInfo(el);
|
|
157
236
|
const selector = this.buildSelector(el);
|
|
@@ -177,31 +256,26 @@ class RecorderService {
|
|
|
177
256
|
}
|
|
178
257
|
}
|
|
179
258
|
handleSubmit(e) {
|
|
180
|
-
if (!this.shouldRecord(e.target))
|
|
181
|
-
return;
|
|
182
259
|
const form = e.target;
|
|
183
260
|
const info = this.getElementInfo(form);
|
|
184
|
-
const selector = this.buildSelector(form);
|
|
185
261
|
this.addAction({
|
|
186
262
|
type: 'submit',
|
|
187
|
-
selector,
|
|
263
|
+
selector: this.buildSelector(form),
|
|
188
264
|
selectorStrategy: this.getSelectorStrategy(form),
|
|
189
265
|
element: info,
|
|
190
|
-
description: `Submit form ${info.id ? '#' + info.id : info.name
|
|
266
|
+
description: `Submit form ${info.id ? '#' + info.id : info.name ?? ''}`.trim(),
|
|
191
267
|
});
|
|
192
268
|
}
|
|
193
269
|
handleScroll(e) {
|
|
194
|
-
if (!this.shouldRecord(e.target))
|
|
195
|
-
return;
|
|
196
270
|
const now = Date.now();
|
|
197
271
|
if (now - this.lastScrollTime < 1000)
|
|
198
|
-
return; // debounce
|
|
272
|
+
return; // debounce 1 s
|
|
199
273
|
this.lastScrollTime = now;
|
|
200
274
|
const el = e.target;
|
|
201
275
|
const isDoc = !el.tagName || el.tagName === 'HTML';
|
|
202
276
|
const scrollY = isDoc ? window.scrollY : el.scrollTop;
|
|
203
277
|
const scrollX = isDoc ? window.scrollX : el.scrollLeft;
|
|
204
|
-
const selector = el.tagName === 'HTML' || el.tagName === 'BODY'
|
|
278
|
+
const selector = (el.tagName === 'HTML' || el.tagName === 'BODY')
|
|
205
279
|
? 'window'
|
|
206
280
|
: this.buildSelector(el);
|
|
207
281
|
this.addAction({
|
|
@@ -216,33 +290,30 @@ class RecorderService {
|
|
|
216
290
|
const specialKeys = ['Enter', 'Escape', 'Tab', 'F5', 'F12'];
|
|
217
291
|
if (!specialKeys.includes(e.key))
|
|
218
292
|
return;
|
|
219
|
-
if (!this.shouldRecord(e.target))
|
|
220
|
-
return;
|
|
221
293
|
const el = e.target;
|
|
222
|
-
const selector = this.buildSelector(el);
|
|
223
294
|
this.addAction({
|
|
224
295
|
type: 'keypress',
|
|
225
|
-
selector,
|
|
296
|
+
selector: this.buildSelector(el),
|
|
226
297
|
selectorStrategy: this.getSelectorStrategy(el),
|
|
227
298
|
value: e.key,
|
|
228
299
|
description: `Press "${e.key}" on ${el.tagName.toLowerCase()}`,
|
|
229
300
|
});
|
|
230
301
|
}
|
|
231
|
-
recordNavigation(
|
|
302
|
+
recordNavigation(navUrl) {
|
|
303
|
+
// Navigation is always added synchronously (no pending batch needed)
|
|
232
304
|
const action = {
|
|
233
305
|
id: this.generateId(),
|
|
234
306
|
timestamp: Date.now(),
|
|
235
307
|
url: navUrl,
|
|
236
|
-
type,
|
|
308
|
+
type: 'navigation',
|
|
237
309
|
selector: 'window',
|
|
238
310
|
selectorStrategy: 'combined',
|
|
239
311
|
description: `Navigate to ${navUrl}`,
|
|
240
312
|
};
|
|
241
313
|
this._currentSession.update(s => s ? { ...s, actions: [...s.actions, action] } : s);
|
|
242
314
|
}
|
|
243
|
-
// ─── Selector
|
|
315
|
+
// ─── Selector building ────────────────────────────────────────────────
|
|
244
316
|
buildSelector(el) {
|
|
245
|
-
// Priority: data-testid > data-cy > id > name > aria-label > combined
|
|
246
317
|
const testId = el.getAttribute('data-testid');
|
|
247
318
|
if (testId)
|
|
248
319
|
return `[data-testid="${testId}"]`;
|
|
@@ -257,14 +328,12 @@ class RecorderService {
|
|
|
257
328
|
const ariaLabel = el.getAttribute('aria-label');
|
|
258
329
|
if (ariaLabel)
|
|
259
330
|
return `[aria-label="${ariaLabel}"]`;
|
|
260
|
-
// Class-based fallback
|
|
261
331
|
const relevantClasses = Array.from(el.classList)
|
|
262
332
|
.filter(c => !c.startsWith('ng-') && !c.startsWith('cdk-') && c.length > 0)
|
|
263
333
|
.slice(0, 3);
|
|
264
334
|
if (relevantClasses.length > 0) {
|
|
265
335
|
return `${el.tagName.toLowerCase()}.${relevantClasses.join('.')}`;
|
|
266
336
|
}
|
|
267
|
-
// Text content for buttons/links
|
|
268
337
|
if (['BUTTON', 'A'].includes(el.tagName)) {
|
|
269
338
|
const text = el.textContent?.trim().slice(0, 30);
|
|
270
339
|
if (text)
|
|
@@ -313,17 +382,17 @@ class RecorderService {
|
|
|
313
382
|
return `"${info.text}"`;
|
|
314
383
|
return info.tagName;
|
|
315
384
|
}
|
|
316
|
-
// ───
|
|
385
|
+
// ─── Guard ────────────────────────────────────────────────────────────
|
|
317
386
|
shouldRecord(target) {
|
|
318
387
|
if (!this._isRecording() || this._isPaused())
|
|
319
388
|
return false;
|
|
320
389
|
if (!target)
|
|
321
390
|
return false;
|
|
322
|
-
// Ignore the debug panel itself
|
|
323
391
|
if (target.closest('[data-debug-panel]'))
|
|
324
392
|
return false;
|
|
325
393
|
return true;
|
|
326
394
|
}
|
|
395
|
+
// ─── Batch buffer ─────────────────────────────────────────────────────
|
|
327
396
|
addAction(partial) {
|
|
328
397
|
const action = {
|
|
329
398
|
id: this.generateId(),
|
|
@@ -331,21 +400,43 @@ class RecorderService {
|
|
|
331
400
|
url: window.location.href,
|
|
332
401
|
...partial,
|
|
333
402
|
};
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
403
|
+
this.pendingActions.push(action);
|
|
404
|
+
this.scheduleFlush();
|
|
405
|
+
}
|
|
406
|
+
// ─── SessionStorage (F5 survival) ─────────────────────────────────────
|
|
407
|
+
persistToStorage() {
|
|
408
|
+
try {
|
|
409
|
+
const session = this._currentSession();
|
|
410
|
+
if (session && this._isRecording()) {
|
|
411
|
+
sessionStorage.setItem(RecorderService.SESSION_KEY, JSON.stringify({ session, isPaused: this._isPaused() }));
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
sessionStorage.removeItem(RecorderService.SESSION_KEY);
|
|
415
|
+
}
|
|
416
|
+
const sessions = this._sessions();
|
|
417
|
+
if (sessions.length > 0) {
|
|
418
|
+
sessionStorage.setItem(RecorderService.SESSIONS_KEY, JSON.stringify(sessions));
|
|
419
|
+
}
|
|
346
420
|
}
|
|
347
|
-
|
|
421
|
+
catch { /* storage quota exceeded or private browsing */ }
|
|
422
|
+
}
|
|
423
|
+
restoreFromStorage() {
|
|
424
|
+
try {
|
|
425
|
+
const savedSessions = sessionStorage.getItem(RecorderService.SESSIONS_KEY);
|
|
426
|
+
if (savedSessions)
|
|
427
|
+
this._sessions.set(JSON.parse(savedSessions));
|
|
428
|
+
const savedActive = sessionStorage.getItem(RecorderService.SESSION_KEY);
|
|
429
|
+
if (savedActive) {
|
|
430
|
+
const { session, isPaused } = JSON.parse(savedActive);
|
|
431
|
+
this._currentSession.set(session);
|
|
432
|
+
this._isRecording.set(true);
|
|
433
|
+
this._isPaused.set(isPaused);
|
|
434
|
+
this.attachListeners(); // resume recording after reload
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch { /* corrupt or missing storage */ }
|
|
348
438
|
}
|
|
439
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
349
440
|
generateId() {
|
|
350
441
|
return Math.random().toString(36).slice(2, 11);
|
|
351
442
|
}
|
|
@@ -968,6 +1059,8 @@ class SessionReplayComponent {
|
|
|
968
1059
|
this.rrweb = inject(RrwebRecorderService);
|
|
969
1060
|
this.overlayOpen = signal(false);
|
|
970
1061
|
this.isPlaying = signal(false);
|
|
1062
|
+
this.importError = signal(null);
|
|
1063
|
+
this.importedFilename = signal(null);
|
|
971
1064
|
}
|
|
972
1065
|
async openOverlay() {
|
|
973
1066
|
this.overlayOpen.set(true);
|
|
@@ -999,6 +1092,34 @@ class SessionReplayComponent {
|
|
|
999
1092
|
exportSession() {
|
|
1000
1093
|
this.rrweb.downloadEvents();
|
|
1001
1094
|
}
|
|
1095
|
+
onFileSelected(event) {
|
|
1096
|
+
const input = event.target;
|
|
1097
|
+
const file = input.files?.[0];
|
|
1098
|
+
if (!file)
|
|
1099
|
+
return;
|
|
1100
|
+
// Reset input so the same file can be re-selected after clearing
|
|
1101
|
+
input.value = '';
|
|
1102
|
+
this.importError.set(null);
|
|
1103
|
+
const reader = new FileReader();
|
|
1104
|
+
reader.onload = () => {
|
|
1105
|
+
try {
|
|
1106
|
+
const events = this.rrweb.importEvents(reader.result);
|
|
1107
|
+
if (events.length === 0) {
|
|
1108
|
+
this.importError.set('Keine Events in der Datei gefunden.');
|
|
1109
|
+
this.importedFilename.set(null);
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
this.importedFilename.set(file.name);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
catch {
|
|
1116
|
+
this.importError.set('Ungültiges JSON-Format.');
|
|
1117
|
+
this.importedFilename.set(null);
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
reader.onerror = () => this.importError.set('Datei konnte nicht gelesen werden.');
|
|
1121
|
+
reader.readAsText(file);
|
|
1122
|
+
}
|
|
1002
1123
|
async startPlay() {
|
|
1003
1124
|
if (!this.replayContainer)
|
|
1004
1125
|
return;
|
|
@@ -1033,15 +1154,34 @@ class SessionReplayComponent {
|
|
|
1033
1154
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SessionReplayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
1034
1155
|
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: SessionReplayComponent, isStandalone: true, selector: "app-session-replay", viewQueries: [{ propertyName: "replayContainer", first: true, predicate: ["replayContainer"], descendants: true }], ngImport: i0, template: `
|
|
1035
1156
|
<div class="replay-panel" data-debug-panel>
|
|
1157
|
+
<!-- Hidden file input for import -->
|
|
1158
|
+
<input
|
|
1159
|
+
#fileInput
|
|
1160
|
+
type="file"
|
|
1161
|
+
accept=".json"
|
|
1162
|
+
style="display:none"
|
|
1163
|
+
data-debug-panel
|
|
1164
|
+
(change)="onFileSelected($event)"
|
|
1165
|
+
/>
|
|
1166
|
+
|
|
1036
1167
|
@if (!rrweb.hasEvents()) {
|
|
1037
1168
|
<div class="replay-empty">
|
|
1038
1169
|
<div class="replay-icon">📽️</div>
|
|
1039
1170
|
<p>Kein Replay verfügbar.</p>
|
|
1040
1171
|
<p class="hint">Starte eine Aufnahme — rrweb zeichnet den DOM parallel mit.</p>
|
|
1172
|
+
<button class="replay-btn import" (click)="fileInput.click()">
|
|
1173
|
+
📂 JSON importieren
|
|
1174
|
+
</button>
|
|
1175
|
+
@if (importError()) {
|
|
1176
|
+
<p class="import-error">{{ importError() }}</p>
|
|
1177
|
+
}
|
|
1041
1178
|
</div>
|
|
1042
1179
|
} @else {
|
|
1043
1180
|
<div class="replay-info">
|
|
1044
1181
|
<span class="event-count">{{ rrweb.eventCount() }} Events aufgezeichnet</span>
|
|
1182
|
+
@if (importedFilename()) {
|
|
1183
|
+
<span class="imported-badge">📂 {{ importedFilename() }}</span>
|
|
1184
|
+
}
|
|
1045
1185
|
</div>
|
|
1046
1186
|
<div class="replay-actions">
|
|
1047
1187
|
<button class="replay-btn primary" (click)="openOverlay()">
|
|
@@ -1050,10 +1190,16 @@ class SessionReplayComponent {
|
|
|
1050
1190
|
<button class="replay-btn" (click)="exportSession()">
|
|
1051
1191
|
💾 JSON exportieren
|
|
1052
1192
|
</button>
|
|
1193
|
+
<button class="replay-btn import" (click)="fileInput.click()">
|
|
1194
|
+
📂 Importieren
|
|
1195
|
+
</button>
|
|
1053
1196
|
</div>
|
|
1054
1197
|
<p class="replay-hint">
|
|
1055
1198
|
Der Replay öffnet sich als Vollbild-Overlay über der aktuellen Seite.
|
|
1056
1199
|
</p>
|
|
1200
|
+
@if (importError()) {
|
|
1201
|
+
<p class="import-error">{{ importError() }}</p>
|
|
1202
|
+
}
|
|
1057
1203
|
}
|
|
1058
1204
|
</div>
|
|
1059
1205
|
|
|
@@ -1073,21 +1219,40 @@ class SessionReplayComponent {
|
|
|
1073
1219
|
<div #replayContainer class="overlay-stage" data-debug-panel></div>
|
|
1074
1220
|
</div>
|
|
1075
1221
|
}
|
|
1076
|
-
`, isInline: true, styles: [".replay-panel{padding:20px 16px;display:flex;flex-direction:column;gap:14px}.replay-empty{text-align:center;padding:20px 0;color:#64748b}.replay-icon{font-size:36px;margin-bottom:8px}.replay-empty p{margin:4px 0;font-size:13px}.hint{font-size:11px;color:#475569}.replay-info{background:#1e293b;border-radius:6px;padding:8px 12px}.event-count{font-size:12px;color:#6ee7b7;font-weight:600}.replay-actions{display:flex;gap:8px}.replay-btn{background:#334155;border:none;color:#cbd5e1;padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.replay-btn:hover{background:#475569}.replay-btn.primary{background:#1d4ed8;color:#fff}.replay-btn.primary:hover{background:#2563eb}.replay-hint{font-size:11px;color:#475569;margin:0}.replay-overlay{position:fixed;inset:0;z-index:99997;background:#000;display:flex;flex-direction:column}.overlay-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#0f172a;border-bottom:1px solid #1e293b;flex-shrink:0}.overlay-title{font-size:14px;font-weight:600;color:#f1f5f9}.overlay-controls{display:flex;gap:8px}.ovl-btn{background:#1e293b;border:1px solid #334155;color:#cbd5e1;padding:5px 12px;border-radius:5px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.ovl-btn:hover{background:#334155}.close-ovl{color:#fca5a5}.close-ovl:hover{background:#dc262633}.overlay-stage{flex:1;overflow:hidden;position:relative;background:#f8fafc}:host ::ng-deep .replayer-wrapper{position:absolute!important;top:0!important;left:0!important;transform-origin:top left!important}:host ::ng-deep .replayer-wrapper iframe{pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
|
|
1222
|
+
`, isInline: true, styles: [".replay-panel{padding:20px 16px;display:flex;flex-direction:column;gap:14px}.replay-empty{text-align:center;padding:20px 0;color:#64748b}.replay-icon{font-size:36px;margin-bottom:8px}.replay-empty p{margin:4px 0;font-size:13px}.hint{font-size:11px;color:#475569}.replay-info{background:#1e293b;border-radius:6px;padding:8px 12px}.event-count{font-size:12px;color:#6ee7b7;font-weight:600}.replay-actions{display:flex;gap:8px}.replay-btn{background:#334155;border:none;color:#cbd5e1;padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.replay-btn:hover{background:#475569}.replay-btn.primary{background:#1d4ed8;color:#fff}.replay-btn.primary:hover{background:#2563eb}.replay-hint{font-size:11px;color:#475569;margin:0}.replay-btn.import{background:#1e3a5f;color:#93c5fd}.replay-btn.import:hover{background:#1e4070}.imported-badge{font-size:11px;color:#93c5fd;margin-left:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:150px;display:inline-block;vertical-align:middle}.import-error{font-size:11px;color:#fca5a5;margin:4px 0 0}.replay-overlay{position:fixed;inset:0;z-index:99997;background:#000;display:flex;flex-direction:column}.overlay-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#0f172a;border-bottom:1px solid #1e293b;flex-shrink:0}.overlay-title{font-size:14px;font-weight:600;color:#f1f5f9}.overlay-controls{display:flex;gap:8px}.ovl-btn{background:#1e293b;border:1px solid #334155;color:#cbd5e1;padding:5px 12px;border-radius:5px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.ovl-btn:hover{background:#334155}.close-ovl{color:#fca5a5}.close-ovl:hover{background:#dc262633}.overlay-stage{flex:1;overflow:hidden;position:relative;background:#f8fafc}:host ::ng-deep .replayer-wrapper{position:absolute!important;top:0!important;left:0!important;transform-origin:top left!important}:host ::ng-deep .replayer-wrapper iframe{pointer-events:none;display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }] }); }
|
|
1077
1223
|
}
|
|
1078
1224
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SessionReplayComponent, decorators: [{
|
|
1079
1225
|
type: Component,
|
|
1080
1226
|
args: [{ selector: 'app-session-replay', standalone: true, imports: [CommonModule], template: `
|
|
1081
1227
|
<div class="replay-panel" data-debug-panel>
|
|
1228
|
+
<!-- Hidden file input for import -->
|
|
1229
|
+
<input
|
|
1230
|
+
#fileInput
|
|
1231
|
+
type="file"
|
|
1232
|
+
accept=".json"
|
|
1233
|
+
style="display:none"
|
|
1234
|
+
data-debug-panel
|
|
1235
|
+
(change)="onFileSelected($event)"
|
|
1236
|
+
/>
|
|
1237
|
+
|
|
1082
1238
|
@if (!rrweb.hasEvents()) {
|
|
1083
1239
|
<div class="replay-empty">
|
|
1084
1240
|
<div class="replay-icon">📽️</div>
|
|
1085
1241
|
<p>Kein Replay verfügbar.</p>
|
|
1086
1242
|
<p class="hint">Starte eine Aufnahme — rrweb zeichnet den DOM parallel mit.</p>
|
|
1243
|
+
<button class="replay-btn import" (click)="fileInput.click()">
|
|
1244
|
+
📂 JSON importieren
|
|
1245
|
+
</button>
|
|
1246
|
+
@if (importError()) {
|
|
1247
|
+
<p class="import-error">{{ importError() }}</p>
|
|
1248
|
+
}
|
|
1087
1249
|
</div>
|
|
1088
1250
|
} @else {
|
|
1089
1251
|
<div class="replay-info">
|
|
1090
1252
|
<span class="event-count">{{ rrweb.eventCount() }} Events aufgezeichnet</span>
|
|
1253
|
+
@if (importedFilename()) {
|
|
1254
|
+
<span class="imported-badge">📂 {{ importedFilename() }}</span>
|
|
1255
|
+
}
|
|
1091
1256
|
</div>
|
|
1092
1257
|
<div class="replay-actions">
|
|
1093
1258
|
<button class="replay-btn primary" (click)="openOverlay()">
|
|
@@ -1096,10 +1261,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1096
1261
|
<button class="replay-btn" (click)="exportSession()">
|
|
1097
1262
|
💾 JSON exportieren
|
|
1098
1263
|
</button>
|
|
1264
|
+
<button class="replay-btn import" (click)="fileInput.click()">
|
|
1265
|
+
📂 Importieren
|
|
1266
|
+
</button>
|
|
1099
1267
|
</div>
|
|
1100
1268
|
<p class="replay-hint">
|
|
1101
1269
|
Der Replay öffnet sich als Vollbild-Overlay über der aktuellen Seite.
|
|
1102
1270
|
</p>
|
|
1271
|
+
@if (importError()) {
|
|
1272
|
+
<p class="import-error">{{ importError() }}</p>
|
|
1273
|
+
}
|
|
1103
1274
|
}
|
|
1104
1275
|
</div>
|
|
1105
1276
|
|
|
@@ -1119,7 +1290,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
1119
1290
|
<div #replayContainer class="overlay-stage" data-debug-panel></div>
|
|
1120
1291
|
</div>
|
|
1121
1292
|
}
|
|
1122
|
-
`, styles: [".replay-panel{padding:20px 16px;display:flex;flex-direction:column;gap:14px}.replay-empty{text-align:center;padding:20px 0;color:#64748b}.replay-icon{font-size:36px;margin-bottom:8px}.replay-empty p{margin:4px 0;font-size:13px}.hint{font-size:11px;color:#475569}.replay-info{background:#1e293b;border-radius:6px;padding:8px 12px}.event-count{font-size:12px;color:#6ee7b7;font-weight:600}.replay-actions{display:flex;gap:8px}.replay-btn{background:#334155;border:none;color:#cbd5e1;padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.replay-btn:hover{background:#475569}.replay-btn.primary{background:#1d4ed8;color:#fff}.replay-btn.primary:hover{background:#2563eb}.replay-hint{font-size:11px;color:#475569;margin:0}.replay-overlay{position:fixed;inset:0;z-index:99997;background:#000;display:flex;flex-direction:column}.overlay-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#0f172a;border-bottom:1px solid #1e293b;flex-shrink:0}.overlay-title{font-size:14px;font-weight:600;color:#f1f5f9}.overlay-controls{display:flex;gap:8px}.ovl-btn{background:#1e293b;border:1px solid #334155;color:#cbd5e1;padding:5px 12px;border-radius:5px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.ovl-btn:hover{background:#334155}.close-ovl{color:#fca5a5}.close-ovl:hover{background:#dc262633}.overlay-stage{flex:1;overflow:hidden;position:relative;background:#f8fafc}:host ::ng-deep .replayer-wrapper{position:absolute!important;top:0!important;left:0!important;transform-origin:top left!important}:host ::ng-deep .replayer-wrapper iframe{pointer-events:none}\n"] }]
|
|
1293
|
+
`, styles: [".replay-panel{padding:20px 16px;display:flex;flex-direction:column;gap:14px}.replay-empty{text-align:center;padding:20px 0;color:#64748b}.replay-icon{font-size:36px;margin-bottom:8px}.replay-empty p{margin:4px 0;font-size:13px}.hint{font-size:11px;color:#475569}.replay-info{background:#1e293b;border-radius:6px;padding:8px 12px}.event-count{font-size:12px;color:#6ee7b7;font-weight:600}.replay-actions{display:flex;gap:8px}.replay-btn{background:#334155;border:none;color:#cbd5e1;padding:8px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.replay-btn:hover{background:#475569}.replay-btn.primary{background:#1d4ed8;color:#fff}.replay-btn.primary:hover{background:#2563eb}.replay-hint{font-size:11px;color:#475569;margin:0}.replay-btn.import{background:#1e3a5f;color:#93c5fd}.replay-btn.import:hover{background:#1e4070}.imported-badge{font-size:11px;color:#93c5fd;margin-left:8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:150px;display:inline-block;vertical-align:middle}.import-error{font-size:11px;color:#fca5a5;margin:4px 0 0}.replay-overlay{position:fixed;inset:0;z-index:99997;background:#000;display:flex;flex-direction:column}.overlay-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#0f172a;border-bottom:1px solid #1e293b;flex-shrink:0}.overlay-title{font-size:14px;font-weight:600;color:#f1f5f9}.overlay-controls{display:flex;gap:8px}.ovl-btn{background:#1e293b;border:1px solid #334155;color:#cbd5e1;padding:5px 12px;border-radius:5px;font-size:12px;font-weight:600;cursor:pointer;transition:background .15s}.ovl-btn:hover{background:#334155}.close-ovl{color:#fca5a5}.close-ovl:hover{background:#dc262633}.overlay-stage{flex:1;overflow:hidden;position:relative;background:#f8fafc}:host ::ng-deep .replayer-wrapper{position:absolute!important;top:0!important;left:0!important;transform-origin:top left!important}:host ::ng-deep .replayer-wrapper iframe{pointer-events:none;display:block}\n"] }]
|
|
1123
1294
|
}], propDecorators: { replayContainer: [{
|
|
1124
1295
|
type: ViewChild,
|
|
1125
1296
|
args: ['replayContainer']
|