@privacyscrubber/sdk 2.0.2

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/ui-modals.js ADDED
@@ -0,0 +1,819 @@
1
+ /**
2
+ * PrivacyScrubber UI Modal Orchestrator
3
+ * Handles smooth animations and state management for all site-wide modals.
4
+ */
5
+
6
+ const activeModals = [];
7
+
8
+ /**
9
+ * Hydrates a lazy-loaded modal by extracting its template content into the DOM.
10
+ * @param {HTMLElement|string} modal - Modal element or ID
11
+ * @returns {HTMLElement} The modal element
12
+ */
13
+ export const hydrateLazyModal = (modal) => {
14
+ const m = typeof modal === 'string' ? document.getElementById(modal) : modal;
15
+ if (!m) return null;
16
+
17
+ const template = m.querySelector('template[data-lazy-modal]');
18
+ if (template) {
19
+ m.appendChild(template.content.cloneNode(true));
20
+ template.remove();
21
+ }
22
+ return m;
23
+ };
24
+
25
+ /**
26
+ * Opens a modal with scale and fade animations.
27
+ * @param {HTMLElement|string} modal - Modal element or ID
28
+ * @param {HTMLElement|string} content - Content element or ID
29
+ */
30
+ export const openModal = (modal, content) => {
31
+ const m = hydrateLazyModal(modal);
32
+ const c = typeof content === 'string' ? document.getElementById(content) : content;
33
+
34
+ if (!m || !c) return;
35
+
36
+ const trigger = document.activeElement;
37
+
38
+ // Add to active stack
39
+ if (!activeModals.find(item => item.modal === m)) {
40
+ activeModals.push({ modal: m, content: c, trigger: trigger });
41
+ }
42
+
43
+ // 1. Ensure the modal is in the flex layout
44
+ m.classList.remove('hidden');
45
+ m.classList.add('flex');
46
+
47
+ // Force reflow
48
+ void m.offsetWidth;
49
+
50
+ // 2. Trigger transitions
51
+ m.classList.add('opacity-100');
52
+ m.classList.remove('opacity-0', 'pointer-events-none');
53
+
54
+ c.classList.remove('scale-95', 'opacity-0', 'translate-y-4');
55
+ c.classList.add('scale-100', 'opacity-100', 'translate-y-0');
56
+
57
+ document.body.style.overflow = 'hidden';
58
+
59
+ // 3. Auto-focus logic for inputs (great for desktop usability)
60
+ const firstInput = c.querySelector('input[type="text"], input[type="password"], input[type="email"], textarea');
61
+ if (firstInput) {
62
+ setTimeout(() => firstInput.focus(), 150); // delay ensures it's visible before focus
63
+ }
64
+ };
65
+
66
+ /**
67
+ * Closes a modal with scale and fade animations.
68
+ * @param {HTMLElement|string} modal - Modal element or ID
69
+ * @param {HTMLElement|string} content - Content element or ID
70
+ */
71
+ export const closeModal = (modal, content) => {
72
+ const m = typeof modal === 'string' ? document.getElementById(modal) : modal;
73
+ const c = typeof content === 'string' ? document.getElementById(content) : content;
74
+
75
+ if (!m || !c) return;
76
+
77
+ // Remove from active stack
78
+ let triggerToRestore = null;
79
+ const index = activeModals.findIndex(item => item.modal === m);
80
+ if (index !== -1) {
81
+ triggerToRestore = activeModals[index].trigger;
82
+ activeModals.splice(index, 1);
83
+ }
84
+
85
+ // 1. Trigger transitions
86
+ c.classList.add('scale-95', 'opacity-0', 'translate-y-4');
87
+ c.classList.remove('scale-100', 'opacity-100', 'translate-y-0');
88
+
89
+ m.classList.remove('opacity-100');
90
+ m.classList.add('opacity-0', 'pointer-events-none');
91
+
92
+ // 2. Clean up after animation
93
+ setTimeout(() => {
94
+ m.classList.add('hidden');
95
+ m.classList.remove('flex');
96
+ if (activeModals.length === 0) {
97
+ document.body.style.overflow = '';
98
+ }
99
+ if (triggerToRestore && typeof triggerToRestore.focus === 'function') {
100
+ triggerToRestore.focus();
101
+ }
102
+ }, 300);
103
+ };
104
+
105
+ /**
106
+ * Opens the native Teams Activation/Pilot modal.
107
+ */
108
+ export const openTeamsModal = () => {
109
+ const modal = document.getElementById('teamModal');
110
+ const content = document.getElementById('teamModalContent');
111
+ if (modal && content) {
112
+ openModal(modal, content);
113
+ }
114
+ };
115
+
116
+ /**
117
+ * Closes the native Teams Activation/Pilot modal.
118
+ */
119
+ export const closeTeamsModal = () => {
120
+ const modal = document.getElementById('teamModal');
121
+ const content = document.getElementById('teamModalContent');
122
+ if (modal && content) {
123
+ closeModal(modal, content);
124
+ }
125
+ };
126
+
127
+ // Global hooks for non-module compatibility
128
+ if (typeof window !== 'undefined') {
129
+ window.openModal = openModal;
130
+ window.closeModal = closeModal;
131
+ window.openTeamsModal = openTeamsModal;
132
+ window.closeTeamsModal = closeTeamsModal;
133
+ window.closeTeamModal = closeTeamsModal; // Alias to match older HTML usage
134
+ window.openTeamModal = openTeamsModal; // Alias to match older HTML usage
135
+
136
+ /**
137
+ * Session Handoff (Team) Modals
138
+ */
139
+ window.openSessionHandoffModal = function(e) {
140
+ if (e && e.preventDefault) e.preventDefault();
141
+ if (typeof window.openTeamModal === 'function') {
142
+ window.openTeamModal(true, 'teams');
143
+ return;
144
+ }
145
+ if (typeof window.showProRecoveryModal === 'function') {
146
+ window.showProRecoveryModal('teams');
147
+ return;
148
+ }
149
+ const m = document.getElementById('teamModal');
150
+ const c = document.getElementById('teamModalContent');
151
+ if (m && c) {
152
+ openModal(m, c);
153
+ if (typeof window.switchTeamTab === 'function') {
154
+ window.switchTeamTab('teams');
155
+ }
156
+ }
157
+ };
158
+
159
+ window.closeSessionHandoffModal = function() {
160
+ if (typeof window.closeTeamModal === 'function') {
161
+ window.closeTeamModal();
162
+ } else {
163
+ const e = document.getElementById("teamModal");
164
+ const t = document.getElementById("teamModalContent");
165
+ if (e && t) {
166
+ closeModal(e, t);
167
+ }
168
+ }
169
+ };
170
+ window.closeTeamRecoveryModal = function () {
171
+ const e = document.getElementById("teamRecoveryModal");
172
+ const t = document.getElementById("teamRecoveryModalContent") || e.children[0];
173
+ if (e) closeModal(e, t);
174
+ };
175
+
176
+ // Global Escape Key Listener
177
+ window.addEventListener('keydown', (e) => {
178
+ if (e.key === 'Escape' && activeModals.length > 0) {
179
+ e.preventDefault();
180
+ const lastModal = activeModals[activeModals.length - 1];
181
+ closeModal(lastModal.modal, lastModal.content);
182
+ }
183
+ });
184
+
185
+ /**
186
+ * Switches between Monthly and Lifetime PRO tiers in the modal.
187
+ */
188
+ window.setProTier = (tier) => {
189
+ const priceDisplay = document.getElementById('pro-price-display');
190
+ const descDisplay = document.getElementById('pro-desc-display');
191
+ const monthlyBtn = document.getElementById('btn-pro-monthly');
192
+ const lifetimeBtn = document.getElementById('btn-pro-lifetime');
193
+ const subContainer = document.getElementById('paypal-pro-btn-container');
194
+ const captureContainer = document.getElementById('paypal-lifetime-btn-container');
195
+
196
+ if (!priceDisplay) return;
197
+
198
+ if (tier === 'lifetime') {
199
+ const lifetimePrice = window.PII_CONFIG?.pricing?.PRO_LIFETIME || 110;
200
+ priceDisplay.innerHTML = `$${lifetimePrice}<span class="text-xs font-medium text-slate-500 uppercase ml-2">Lifetime</span>`;
201
+ descDisplay.innerText = "One-time payment for permanent PRO access. No recurring fees, ever.";
202
+
203
+ monthlyBtn.classList.remove('bg-neon-green', 'text-dark-900');
204
+ monthlyBtn.classList.add('text-slate-500', 'hover:text-white');
205
+
206
+ lifetimeBtn.classList.add('bg-neon-green', 'text-dark-900');
207
+ lifetimeBtn.classList.remove('text-slate-500', 'hover:text-white');
208
+
209
+ subContainer.classList.add('hidden');
210
+ captureContainer.classList.remove('hidden');
211
+
212
+ // Trigger PayPal button initialization for lifetime if not already done
213
+ if (typeof window.initLifetimePayPal === 'function') {
214
+ window.initLifetimePayPal();
215
+ }
216
+ } else {
217
+ const monthlyPrice = window.PII_CONFIG?.pricing?.PRO_MONTHLY || 15;
218
+ priceDisplay.innerHTML = `$${monthlyPrice}<span class="text-lg font-medium text-slate-500">/mo</span>`;
219
+ descDisplay.innerText = "Professional grade PII scrubbing for high-volume AI power users.";
220
+
221
+ lifetimeBtn.classList.remove('bg-neon-green', 'text-dark-900');
222
+ lifetimeBtn.classList.add('text-slate-500', 'hover:text-white');
223
+
224
+ monthlyBtn.classList.add('bg-neon-green', 'text-dark-900');
225
+ monthlyBtn.classList.remove('text-slate-500', 'hover:text-white');
226
+
227
+ captureContainer.classList.add('hidden');
228
+ subContainer.classList.remove('hidden');
229
+ }
230
+ };
231
+ }
232
+
233
+
234
+ window.psSetQuickTemplate = function(el) {
235
+ var lbl = document.getElementById('teamNewRuleLabel');
236
+ var inp = document.getElementById('teamNewRuleInput');
237
+ if (lbl && inp) {
238
+ lbl.value = el.dataset.label || '';
239
+ inp.value = el.dataset.pattern || '';
240
+ inp.dispatchEvent(new Event('input'));
241
+ }
242
+ };
243
+
244
+ window.downloadSessionFile = async function() {
245
+ let tokenMap = {};
246
+ if (window.piiWorkerManager) {
247
+ try {
248
+ const res = await window.piiWorkerManager.send("EXPORT_SESSION");
249
+ tokenMap = res.plaintextMap || res.tokenMap || {};
250
+ } catch(e) {}
251
+ }
252
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
253
+ const raw = sessionStorage.getItem(`sessionTokenMap_${window.tabId || 1}`);
254
+ if (raw) tokenMap = JSON.parse(raw);
255
+ }
256
+ const count = Object.keys(tokenMap).length;
257
+ if (count === 0) {
258
+ if (typeof window.showStatus === 'function') window.showStatus("No active session tokens in RAM to save.", "info", 3000);
259
+ return;
260
+ }
261
+ const payload = {
262
+ pssession: true,
263
+ version: "1.7.0",
264
+ timestamp: new Date().toISOString(),
265
+ tokenMap: tokenMap,
266
+ count: count
267
+ };
268
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
269
+ const url = URL.createObjectURL(blob);
270
+ const a = document.createElement("a");
271
+ a.href = url;
272
+ a.download = `privacyscrubber-session-${new Date().toISOString().slice(0,10)}.pssession`;
273
+ document.body.appendChild(a);
274
+ a.click();
275
+ document.body.removeChild(a);
276
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
277
+ if (typeof window.showStatus === "function") {
278
+ window.showStatus(`Session saved! (${count} tokens stored in .pssession file)`, "success", 4000);
279
+ }
280
+ window.closeSessionSaveModal();
281
+ };
282
+
283
+ window.importSessionFileFromInput = function(input) {
284
+ if (!input || !input.files || !input.files[0]) return;
285
+ const file = input.files[0];
286
+ const reader = new FileReader();
287
+ reader.onload = async function(e) {
288
+ try {
289
+ const data = JSON.parse(e.target.result);
290
+ const map = data.tokenMap || data.plaintextMap || data.tm;
291
+ if (map && typeof map === 'object') {
292
+ if (window.piiWorkerManager) {
293
+ await window.piiWorkerManager.send("IMPORT_SESSION", { tokenMap: map });
294
+ }
295
+ const count = Object.keys(map).length;
296
+ const entCount = document.getElementById("entityCount");
297
+ if (entCount) entCount.textContent = `${count} items protected`;
298
+ if (typeof window.showStatus === "function") {
299
+ window.showStatus(`Session restored! (${count} tokens loaded from file)`, "success", 4000);
300
+ }
301
+ window.closeSessionSaveModal();
302
+ } else {
303
+ throw new Error("Invalid format");
304
+ }
305
+ } catch(err) {
306
+ if (typeof window.showStatus === "function") {
307
+ window.showStatus("Invalid session file format. Upload a valid .pssession file.", "warning", 4000);
308
+ }
309
+ }
310
+ input.value = '';
311
+ };
312
+ reader.readAsText(file);
313
+ };
314
+
315
+ window.openSessionSaveModal = function() {
316
+ const modal = document.getElementById('sessionSaveModal');
317
+ if (!modal) return;
318
+ modal.classList.remove('hidden');
319
+ modal.classList.add('flex');
320
+ setTimeout(() => {
321
+ modal.classList.remove('opacity-0');
322
+ if (modal.children[0]) {
323
+ modal.children[0].classList.remove('scale-95');
324
+ modal.children[0].classList.add('scale-100');
325
+ }
326
+ }, 10);
327
+ };
328
+
329
+ window.closeSessionSaveModal = function() {
330
+ const modal = document.getElementById('sessionSaveModal');
331
+ if (!modal) return;
332
+ modal.classList.add('opacity-0');
333
+ if (modal.children[0]) {
334
+ modal.children[0].classList.remove('scale-100');
335
+ modal.children[0].classList.add('scale-95');
336
+ }
337
+ setTimeout(() => {
338
+ modal.classList.add('hidden');
339
+ modal.classList.remove('flex');
340
+ }, 200);
341
+ };
342
+
343
+ window.closeAuditModal = function() {
344
+ const modal = document.getElementById('devSecOpsAuditModal');
345
+ if (!modal) return;
346
+ modal.classList.add('opacity-0');
347
+ if (modal.children[0]) {
348
+ modal.children[0].classList.remove('scale-100');
349
+ modal.children[0].classList.add('scale-95');
350
+ }
351
+ setTimeout(() => {
352
+ modal.classList.add('hidden');
353
+ modal.classList.remove('flex');
354
+ }, 200);
355
+ };
356
+
357
+ let currentDocZoom = 1;
358
+ let isDocPanning = false;
359
+ let startPanX = 0, startPanY = 0;
360
+ let scrollPanLeft = 0, scrollPanTop = 0;
361
+
362
+ window.setPdfRedactionStyle = function(style) {
363
+ window.pdfRedactionStyle = style;
364
+ const isTok = style === 'tokenized';
365
+
366
+ // Main UI toggle
367
+ const btnTok = document.getElementById('btnStyleTokenized');
368
+ const btnBlk = document.getElementById('btnStyleBlackout');
369
+ if (btnTok && btnBlk) {
370
+ if (isTok) {
371
+ btnTok.className = 'px-2 py-0.5 rounded-md font-bold transition-all bg-neon-blue/20 text-neon-blue border border-neon-blue/30 cursor-pointer shadow-sm';
372
+ btnBlk.className = 'px-2 py-0.5 rounded-md font-bold transition-all text-slate-400 hover:text-white border border-transparent cursor-pointer';
373
+ } else {
374
+ btnBlk.className = 'px-2 py-0.5 rounded-md font-bold transition-all bg-neon-blue/20 text-neon-blue border border-neon-blue/30 cursor-pointer shadow-sm';
375
+ btnTok.className = 'px-2 py-0.5 rounded-md font-bold transition-all text-slate-400 hover:text-white border border-transparent cursor-pointer';
376
+ }
377
+ }
378
+
379
+ // Modal UI toggle
380
+ const mBtnTok = document.getElementById('modalBtnStyleTokenized');
381
+ const mBtnBlk = document.getElementById('modalBtnStyleBlackout');
382
+ if (mBtnTok && mBtnBlk) {
383
+ if (isTok) {
384
+ mBtnTok.className = 'px-2 py-0.5 rounded-md font-bold transition-all bg-neon-blue/20 text-neon-blue border border-neon-blue/30 cursor-pointer shadow-sm';
385
+ mBtnBlk.className = 'px-2 py-0.5 rounded-md font-bold transition-all text-slate-400 hover:text-white border border-transparent cursor-pointer';
386
+ } else {
387
+ mBtnBlk.className = 'px-2 py-0.5 rounded-md font-bold transition-all bg-neon-blue/20 text-neon-blue border border-neon-blue/30 cursor-pointer shadow-sm';
388
+ mBtnTok.className = 'px-2 py-0.5 rounded-md font-bold transition-all text-slate-400 hover:text-white border border-transparent cursor-pointer';
389
+ }
390
+ }
391
+
392
+ // Re-render previews
393
+ const docSnippet = document.getElementById('docModeLiveSnippet');
394
+ if (docSnippet && window._lastPDFBuffer && typeof window.renderPdfLivePreview === 'function') {
395
+ window.renderPdfLivePreview(window._lastPDFBuffer, docSnippet, style);
396
+ }
397
+ if (document.getElementById('imageZoomModal')?.classList.contains('flex')) {
398
+ window.renderPdfZoomInspection?.();
399
+ }
400
+ };
401
+
402
+ window.renderPdfZoomInspection = async function() {
403
+ if (!window._lastPDFBuffer) return false;
404
+ const modal = document.getElementById('imageZoomModal');
405
+ const img = document.getElementById('imageZoomImg');
406
+ const overlay = document.getElementById('imageZoomBadgeOverlay');
407
+ const styleGroup = document.getElementById('modalPdfStyleToggleGroup');
408
+ if (!modal || !img) return false;
409
+
410
+ if (styleGroup) {
411
+ styleGroup.classList.remove('hidden');
412
+ styleGroup.classList.add('flex');
413
+ }
414
+
415
+ try {
416
+ if (typeof loadPdfJs === 'function') await loadPdfJs();
417
+ const pdfjsDoc = await pdfjsLib.getDocument({ data: window._lastPDFBuffer.slice(0) }).promise;
418
+ const page = await pdfjsDoc.getPage(1);
419
+
420
+ const viewport = page.getViewport({ scale: 2.0 });
421
+ const canvas = document.createElement('canvas');
422
+ canvas.width = viewport.width;
423
+ canvas.height = viewport.height;
424
+ const ctx = canvas.getContext('2d');
425
+ await page.render({ canvasContext: ctx, viewport }).promise;
426
+
427
+ // Fetch active token map
428
+ let tokenMap = (window.AppState && window.AppState.sessionMap && Object.keys(window.AppState.sessionMap).length > 0)
429
+ ? window.AppState.sessionMap
430
+ : {};
431
+
432
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
433
+ if (window.piiWorkerManager) {
434
+ try {
435
+ const sessionData = await window.piiWorkerManager.send('EXPORT_SESSION');
436
+ tokenMap = sessionData?.plaintextMap || sessionData || {};
437
+ } catch (_) {}
438
+ }
439
+ }
440
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
441
+ const tabId = window._tabId || 'default';
442
+ try {
443
+ const raw = sessionStorage.getItem(`sessionTokenMap_${tabId}`);
444
+ if (raw) tokenMap = JSON.parse(raw);
445
+ } catch (_) {}
446
+ }
447
+
448
+ const piiEntries = Object.entries(tokenMap)
449
+ .filter(([k, v]) => typeof v === 'string' && v.trim().length >= 2)
450
+ .sort((a, b) => b[1].length - a[1].length);
451
+
452
+ const textContent = await page.getTextContent();
453
+ const lines = {};
454
+ const tolerance = 4;
455
+
456
+ textContent.items.forEach((item) => {
457
+ const [a, , , d, tx, ty] = item.transform;
458
+ const fontSz = Math.abs(d) || Math.abs(a) || 10;
459
+ const y = Math.round(ty);
460
+ let foundY = null;
461
+ for (let existingY in lines) {
462
+ if (Math.abs(y - existingY) <= tolerance) {
463
+ foundY = existingY;
464
+ break;
465
+ }
466
+ }
467
+ if (!foundY) {
468
+ lines[y] = [];
469
+ foundY = y;
470
+ }
471
+ lines[foundY].push({
472
+ str: item.str,
473
+ x: tx,
474
+ y: ty,
475
+ w: item.width || fontSz * item.str.length * 0.55,
476
+ h: fontSz * 1.15,
477
+ fontSz
478
+ });
479
+ });
480
+
481
+ const sortedY = Object.keys(lines).map(Number).sort((a, b) => b - a);
482
+
483
+ const badges = [];
484
+ const addBadge = (x, y, w, h, tokenKey, piiVal) => {
485
+ const pt1 = viewport.convertToViewportPoint(x, y + h * 0.85);
486
+ const pt2 = viewport.convertToViewportPoint(x + Math.max(w, 4), y - h * 0.15);
487
+ const cX = Math.min(pt1[0], pt2[0]);
488
+ const cY = Math.min(pt1[1], pt2[1]);
489
+ const cW = Math.max(Math.abs(pt1[0] - pt2[0]), 42);
490
+ const cH = Math.abs(pt1[1] - pt2[1]);
491
+ badges.push({ x: cX, y: cY, w: cW, h: cH, tokenKey, piiVal });
492
+ };
493
+
494
+ for (const [tokenKey, piiVal] of piiEntries) {
495
+ for (const y of sortedY) {
496
+ const lineItems = lines[y].sort((a, b) => a.x - b.x);
497
+
498
+ // Strategy A: full PII within single text item
499
+ let matchedInLine = false;
500
+ for (const item of lineItems) {
501
+ if (!item.str) continue;
502
+ const hits = window.findPIIWordBoundary ? window.findPIIWordBoundary(item.str, piiVal) : [];
503
+ for (const idx of hits) {
504
+ const geom = window.calcSubStringGeometry ? window.calcSubStringGeometry(item.str, idx, piiVal.length, item.w) : { prefixX: 0, targetW: item.w };
505
+ addBadge(item.x + geom.prefixX, item.y, geom.targetW, item.h, tokenKey, piiVal);
506
+ matchedInLine = true;
507
+ }
508
+ }
509
+
510
+ // Strategy B: PII spans 2-20 consecutive items on the SAME line
511
+ if (!matchedInLine && lineItems.length > 1) {
512
+ for (let i = 0; i < lineItems.length; i++) {
513
+ let concat = '';
514
+ let span = [];
515
+ for (let j = i; j < lineItems.length; j++) {
516
+ if (span.length > 0) {
517
+ const prev = span[span.length - 1];
518
+ const gap = lineItems[j].x - (prev.x + prev.w);
519
+ const spaceThreshold = Math.max(3.5, prev.fontSz * 0.22);
520
+ if (gap >= spaceThreshold) concat += ' ';
521
+ }
522
+ concat += lineItems[j].str;
523
+ span.push(lineItems[j]);
524
+ if (span.length < 2) continue;
525
+
526
+ const wbHits = window.findPIIWordBoundary ? window.findPIIWordBoundary(concat, piiVal) : [];
527
+ if (wbHits.length === 0) continue;
528
+ const matchIdx = wbHits[0];
529
+ const matchEnd = matchIdx + piiVal.length;
530
+ let charCursor = 0;
531
+ let barX = null, barEndX = null;
532
+
533
+ for (let si_idx = 0; si_idx < span.length; si_idx++) {
534
+ const si = span[si_idx];
535
+ if (si_idx > 0) {
536
+ const prevSi = span[si_idx - 1];
537
+ const gapPx = si.x - (prevSi.x + prevSi.w);
538
+ const spaceThresh = Math.max(3.5, prevSi.fontSz * 0.22);
539
+ if (gapPx >= spaceThresh) charCursor += 1;
540
+ }
541
+ const siLen = si.str.length;
542
+ const siStart = charCursor;
543
+ const siEnd = charCursor + siLen;
544
+
545
+ const overlapStart = Math.max(siStart, matchIdx);
546
+ const overlapEnd = Math.min(siEnd, matchEnd);
547
+ if (overlapStart < overlapEnd) {
548
+ const localStart = overlapStart - siStart;
549
+ const localLen = overlapEnd - overlapStart;
550
+ const geom = window.calcSubStringGeometry ? window.calcSubStringGeometry(si.str, localStart, localLen, si.w || 0) : { prefixX: 0, targetW: si.w };
551
+ const xStart = si.x + geom.prefixX;
552
+ const xEnd = xStart + geom.targetW;
553
+ if (barX === null || xStart < barX) barX = xStart;
554
+ if (barEndX === null || xEnd > barEndX) barEndX = xEnd;
555
+ }
556
+ charCursor = siEnd;
557
+ }
558
+
559
+ if (barX !== null && barEndX !== null && barEndX > barX) {
560
+ addBadge(barX, span[0].y, barEndX - barX, span[0].h, tokenKey, piiVal);
561
+ i = j;
562
+ break;
563
+ }
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ const style = window.pdfRedactionStyle || 'tokenized';
571
+
572
+ // Draw base image onto canvas
573
+ for (const b of badges) {
574
+ if (style === 'tokenized') {
575
+ const entityType = window.getEntityTypeFromToken ? window.getEntityTypeFromToken(b.tokenKey) : 'DEFAULT';
576
+ const colorScheme = (window.ENTITY_PDF_COLORS && window.ENTITY_PDF_COLORS[entityType])
577
+ ? window.ENTITY_PDF_COLORS[entityType]
578
+ : { hexBg: '#facc15', hexBorder: '#ca8a04', hexText: '#000000' };
579
+
580
+ ctx.fillStyle = colorScheme.hexBg;
581
+ ctx.strokeStyle = colorScheme.hexBorder;
582
+ ctx.lineWidth = 1.5;
583
+ if (typeof ctx.roundRect === 'function') {
584
+ ctx.beginPath();
585
+ ctx.roundRect(b.x - 1, b.y - 1, b.w + 2, b.h + 2, 3);
586
+ ctx.fill();
587
+ ctx.stroke();
588
+ } else {
589
+ ctx.fillRect(b.x - 1, b.y - 1, b.w + 2, b.h + 2);
590
+ ctx.strokeRect(b.x - 1, b.y - 1, b.w + 2, b.h + 2);
591
+ }
592
+
593
+ ctx.fillStyle = colorScheme.hexText;
594
+ const fontSz = Math.min(Math.max(b.h * 0.65, 10), 16);
595
+ ctx.font = `bold ${Math.round(fontSz)}px monospace`;
596
+ ctx.textAlign = 'center';
597
+ ctx.textBaseline = 'middle';
598
+ ctx.fillText(b.tokenKey, b.x + b.w / 2, b.y + b.h / 2);
599
+ } else {
600
+ ctx.fillStyle = '#000000';
601
+ ctx.fillRect(b.x, b.y, b.w, b.h);
602
+ }
603
+ }
604
+
605
+ const dataUrl = canvas.toDataURL('image/png');
606
+ img.src = dataUrl;
607
+
608
+ // Build interactive overlay for clicking false positives
609
+ if (overlay) {
610
+ overlay.innerHTML = '';
611
+ const cW = canvas.width;
612
+ const cH = canvas.height;
613
+ const escapeHTML = window.escapeHTML || (str => (str || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
614
+
615
+ badges.forEach((b) => {
616
+ const entityType = window.getEntityTypeFromToken ? window.getEntityTypeFromToken(b.tokenKey) : 'DEFAULT';
617
+ const colorScheme = (window.ENTITY_PDF_COLORS && window.ENTITY_PDF_COLORS[entityType])
618
+ ? window.ENTITY_PDF_COLORS[entityType]
619
+ : { hexBg: '#facc15', hexBorder: '#ca8a04', hexText: '#000000' };
620
+
621
+ const leftPct = (b.x / cW) * 100;
622
+ const topPct = (b.y / cH) * 100;
623
+ const widthPct = (b.w / cW) * 100;
624
+ const heightPct = (b.h / cH) * 100;
625
+
626
+ const badgeEl = document.createElement('div');
627
+ badgeEl.className = 'absolute cursor-pointer flex items-center justify-center font-mono font-bold select-none transition-all duration-150 hover:scale-110 hover:ring-2 hover:ring-white z-20 group/badge shadow-md active:scale-95';
628
+ badgeEl.style.left = `${leftPct}%`;
629
+ badgeEl.style.top = `${topPct}%`;
630
+ badgeEl.style.width = `${widthPct}%`;
631
+ badgeEl.style.height = `${heightPct}%`;
632
+ badgeEl.style.backgroundColor = style === 'tokenized' ? colorScheme.hexBg : 'rgba(0,0,0,0.95)';
633
+ badgeEl.style.color = style === 'tokenized' ? colorScheme.hexText : '#ffffff';
634
+ badgeEl.style.border = `1.5px solid ${style === 'tokenized' ? colorScheme.hexBorder : '#ffffff'}`;
635
+ badgeEl.style.borderRadius = '3px';
636
+ badgeEl.dataset.token = b.tokenKey;
637
+ badgeEl.title = `Click to remove false positive: ${b.piiVal}`;
638
+
639
+ badgeEl.innerHTML = `
640
+ <span class="truncate px-0.5 text-[10px] sm:text-xs pointer-events-none">${style === 'tokenized' ? b.tokenKey : '⬛'}</span>
641
+ <span class="hidden group-hover/badge:flex absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-950 border border-white/20 text-white text-[10px] px-2 py-0.5 rounded shadow-xl whitespace-nowrap pointer-events-none z-30 font-sans">
642
+ Unmask "${escapeHTML(b.piiVal.slice(0, 24))}"
643
+ </span>
644
+ `;
645
+
646
+ badgeEl.onclick = async (e) => {
647
+ e.stopPropagation();
648
+ // Immediate cleanup in AppState and session
649
+ if (window.AppState && window.AppState.sessionMap) {
650
+ delete window.AppState.sessionMap[b.tokenKey];
651
+ delete window.AppState.sessionMap[`[${b.tokenKey}]`];
652
+ }
653
+ if (window.ignoreToken) {
654
+ await window.ignoreToken(b.tokenKey);
655
+ }
656
+ if (window.showStatus) {
657
+ window.showStatus(`Revealed false positive: ${b.piiVal}`, 'success', 3500);
658
+ }
659
+ // Re-render both modal and live snippet
660
+ await window.renderPdfZoomInspection();
661
+ const docSnippet = document.getElementById('docModeLiveSnippet');
662
+ if (docSnippet && window._lastPDFBuffer && typeof window.renderPdfLivePreview === 'function') {
663
+ window.renderPdfLivePreview(window._lastPDFBuffer, docSnippet, window.pdfRedactionStyle || 'tokenized');
664
+ }
665
+ };
666
+
667
+ overlay.appendChild(badgeEl);
668
+ });
669
+ }
670
+ return true;
671
+ } catch (err) {
672
+ console.warn('[renderPdfZoomInspection] error:', err);
673
+ return false;
674
+ }
675
+ };
676
+
677
+ window.openPdfInspectionModal = async function() {
678
+ const modal = document.getElementById('imageZoomModal');
679
+ if (!modal) return;
680
+ const ok = await window.renderPdfZoomInspection();
681
+ if (ok) {
682
+ window.openImageZoomModal(document.getElementById('imageZoomImg')?.src || '');
683
+ }
684
+ };
685
+
686
+ window.openImageZoomModal = function(imageUrl) {
687
+ const modal = document.getElementById('imageZoomModal');
688
+ const img = document.getElementById('imageZoomImg');
689
+ const wrapper = document.getElementById('imageZoomWrapper');
690
+ const content = document.getElementById('imageZoomContent');
691
+ const viewport = document.getElementById('imageZoomViewport');
692
+ const zoomText = document.getElementById('zoomLevelText');
693
+ const downloadBtn = document.getElementById('modalDownloadImageBtn');
694
+ if (!modal || !img) return;
695
+
696
+ if (imageUrl && !window._lastPDFBuffer) {
697
+ img.src = imageUrl;
698
+ const overlay = document.getElementById('imageZoomBadgeOverlay');
699
+ if (overlay) overlay.innerHTML = '';
700
+ const styleGroup = document.getElementById('modalPdfStyleToggleGroup');
701
+ if (styleGroup) { styleGroup.classList.add('hidden'); styleGroup.classList.remove('flex'); }
702
+ }
703
+
704
+ currentDocZoom = 1;
705
+ if (wrapper) wrapper.style.transform = `scale(${currentDocZoom})`;
706
+ else img.style.transform = `scale(${currentDocZoom})`;
707
+ if (zoomText) zoomText.textContent = '100%';
708
+
709
+ if (downloadBtn) {
710
+ downloadBtn.onclick = () => {
711
+ const isProUser = window.isPro || (window.AppState && window.AppState.isPro) || localStorage.getItem('isPro') === 'true' || !!localStorage.getItem('ps_pro_sub') || !!localStorage.getItem('ps_pro_key') || !!localStorage.getItem('ps_team_key');
712
+ if (isProUser) {
713
+ const link = document.createElement("a");
714
+ link.download = `redacted_document_${Date.now()}.png`;
715
+ link.href = img.src || imageUrl;
716
+ link.click();
717
+ } else if (typeof window.openProModalAction === "function") {
718
+ window.openProModalAction();
719
+ }
720
+ };
721
+ }
722
+
723
+ modal.classList.remove('hidden');
724
+ modal.classList.add('flex');
725
+ setTimeout(() => {
726
+ modal.classList.remove('opacity-0', 'pointer-events-none');
727
+ if (content) {
728
+ content.classList.remove('scale-95');
729
+ content.classList.add('scale-100');
730
+ }
731
+ }, 10);
732
+ document.body.style.overflow = 'hidden';
733
+
734
+ // Set up interactive zoom & pan once
735
+ if (!modal._hasZoomListeners) {
736
+ modal._hasZoomListeners = true;
737
+
738
+ const updateZoom = (newZoom) => {
739
+ currentDocZoom = Math.min(Math.max(newZoom, 0.5), 3.5);
740
+ const targetEl = document.getElementById('imageZoomWrapper') || img;
741
+ targetEl.style.transform = `scale(${currentDocZoom})`;
742
+ if (zoomText) zoomText.textContent = `${Math.round(currentDocZoom * 100)}%`;
743
+ };
744
+
745
+ const zoomInBtn = document.getElementById('zoomInBtn');
746
+ const zoomOutBtn = document.getElementById('zoomOutBtn');
747
+ const zoomResetBtn = document.getElementById('zoomResetBtn');
748
+
749
+ if (zoomInBtn) zoomInBtn.onclick = () => updateZoom(currentDocZoom + 0.25);
750
+ if (zoomOutBtn) zoomOutBtn.onclick = () => updateZoom(currentDocZoom - 0.25);
751
+ if (zoomResetBtn) zoomResetBtn.onclick = () => updateZoom(1);
752
+
753
+ // Wire modal style toggles
754
+ const mBtnTok = document.getElementById('modalBtnStyleTokenized');
755
+ const mBtnBlk = document.getElementById('modalBtnStyleBlackout');
756
+ if (mBtnTok) mBtnTok.onclick = () => window.setPdfRedactionStyle('tokenized');
757
+ if (mBtnBlk) mBtnBlk.onclick = () => window.setPdfRedactionStyle('blackout');
758
+
759
+ // Wire main style toggles
760
+ const btnTok = document.getElementById('btnStyleTokenized');
761
+ const btnBlk = document.getElementById('btnStyleBlackout');
762
+ if (btnTok) btnTok.onclick = () => window.setPdfRedactionStyle('tokenized');
763
+ if (btnBlk) btnBlk.onclick = () => window.setPdfRedactionStyle('blackout');
764
+
765
+ if (viewport) {
766
+ viewport.addEventListener('wheel', (e) => {
767
+ if (modal.classList.contains('flex')) {
768
+ e.preventDefault();
769
+ const delta = e.deltaY < 0 ? 0.15 : -0.15;
770
+ updateZoom(currentDocZoom + delta);
771
+ }
772
+ }, { passive: false });
773
+
774
+ viewport.addEventListener('mousedown', (e) => {
775
+ if (e.target.tagName === 'BUTTON' || e.target.closest('#imageZoomBadgeOverlay')) return;
776
+ isDocPanning = true;
777
+ startPanX = e.pageX - viewport.offsetLeft;
778
+ startPanY = e.pageY - viewport.offsetTop;
779
+ scrollPanLeft = viewport.scrollLeft;
780
+ scrollPanTop = viewport.scrollTop;
781
+ });
782
+
783
+ viewport.addEventListener('mouseleave', () => { isDocPanning = false; });
784
+ viewport.addEventListener('mouseup', () => { isDocPanning = false; });
785
+ viewport.addEventListener('mousemove', (e) => {
786
+ if (!isDocPanning) return;
787
+ e.preventDefault();
788
+ const x = e.pageX - viewport.offsetLeft;
789
+ const y = e.pageY - viewport.offsetTop;
790
+ const walkX = (x - startPanX) * 1.5;
791
+ const walkY = (y - startPanY) * 1.5;
792
+ viewport.scrollLeft = scrollPanLeft - walkX;
793
+ viewport.scrollTop = scrollPanTop - walkY;
794
+ });
795
+ }
796
+
797
+ document.addEventListener('keydown', (e) => {
798
+ if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
799
+ window.closeImageZoomModal();
800
+ }
801
+ });
802
+ }
803
+ };
804
+
805
+ window.closeImageZoomModal = function() {
806
+ const modal = document.getElementById('imageZoomModal');
807
+ const content = document.getElementById('imageZoomContent');
808
+ if (!modal) return;
809
+ modal.classList.add('opacity-0', 'pointer-events-none');
810
+ if (content) {
811
+ content.classList.remove('scale-100');
812
+ content.classList.add('scale-95');
813
+ }
814
+ setTimeout(() => {
815
+ modal.classList.add('hidden');
816
+ modal.classList.remove('flex');
817
+ document.body.style.overflow = '';
818
+ }, 200);
819
+ };