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.
@@ -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('navigation', window.location.href);
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
- // ─── Event Listeners ──────────────────────────────────────────────────────
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
- const onClick = (e) => this.zone.run(() => this.handleClick(e));
78
- const onDblClick = (e) => this.zone.run(() => this.handleDblClick(e));
79
- const onInput = (e) => this.zone.run(() => this.handleInput(e));
80
- const onChange = (e) => this.zone.run(() => this.handleChange(e));
81
- const onSubmit = (e) => this.zone.run(() => this.handleSubmit(e));
82
- const onKeydown = (e) => this.zone.run(() => this.handleKeydown(e));
83
- const onScroll = (e) => this.zone.run(() => this.handleScroll(e));
84
- document.addEventListener('click', onClick, opts);
85
- document.addEventListener('dblclick', onDblClick, opts);
86
- document.addEventListener('input', onInput, opts);
87
- document.addEventListener('change', onChange, opts);
88
- document.addEventListener('submit', onSubmit, opts);
89
- document.addEventListener('keydown', onKeydown, opts);
90
- document.addEventListener('scroll', onScroll, { capture: true, passive: true });
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 ? info.name : ''}`.trim(),
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 1s
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(type, navUrl) {
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 Building ────────────────────────────────────────────────────
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
- // ─── Helpers ──────────────────────────────────────────────────────────────
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
- // Deduplicate consecutive identical inputs (keep only latest value)
335
- if (action.type === 'input') {
336
- this._currentSession.update(s => {
337
- if (!s)
338
- return s;
339
- const last = s.actions[s.actions.length - 1];
340
- if (last?.type === 'input' && last.selector === action.selector) {
341
- return { ...s, actions: [...s.actions.slice(0, -1), action] };
342
- }
343
- return { ...s, actions: [...s.actions, action] };
344
- });
345
- return;
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
- this._currentSession.update(s => s ? { ...s, actions: [...s.actions, action] } : s);
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']