/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked * to the field via scroll/resize) into username AND password fields. * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. * 4. "More options…" shows a second panel with vault/generator actions. * 5. Listens for DO_AUTOFILL from the popup → fills fields. * 6. Watches form submissions → shows save-credentials banner. */ (() => { 'use strict'; const PK_ATTR = 'data-pk-decorated'; const PK_BTN_CLASS = '__pk_btn__'; const PK_DROPDOWN_ID = '__pk_dropdown__'; const VAULT_URL = 'https://pwkeeper.ngodanguyen.tech/vault'; let _bannerEl = null; let _hasNotifiedForm = false; let _formObserver = null; let _matchingItems = []; // Map from field element → its fixed-position icon button element const _fieldBtnMap = new WeakMap(); // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? '').replace(/&/g, '&').replace(//g, '>'); } /** * More robust visibility check than offsetParent (which fails for * position:fixed elements and some modern layouts). */ function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; if (el.disabled) return false; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false; return true; } function visiblePasswordFields() { return Array.from(document.querySelectorAll('input[type="password"]')) .filter(el => isVisible(el) && !el.disabled); } function findUsernameField(pwField) { // 1. Walk backwards through all inputs in DOM order. const all = Array.from(document.querySelectorAll('input')); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; if (!isVisible(el) || el.disabled) continue; if (['email', 'text', 'tel'].includes(el.type)) return el; } // 2. Fallback: search within the same form/ancestor container. const container = pwField.closest('form') || pwField.closest('[role="form"]') || pwField.parentElement; if (container) { const candidate = container.querySelector('input[type="email"]:not([disabled]), input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])'); if (candidate && isVisible(candidate)) return candidate; } return null; } // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { const nativeSet = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; if (nativeSet) nativeSet.call(el, value); else el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function doAutofill(username, password) { const pwFields = visiblePasswordFields(); if (!pwFields.length) return; const pwField = pwFields[0]; const usernameField = findUsernameField(pwField); if (usernameField && username) fillField(usernameField, username); if (password) fillField(pwField, password); [usernameField, pwField].filter(Boolean).forEach(el => { el.style.outline = '2px solid #c0392b'; setTimeout(() => { el.style.outline = ''; }, 1500); }); } // ── Icon button (fixed-position, outside the DOM tree of the field) ─────────── /** * Position the icon button over the right edge of `field` using fixed coords. * This never touches the field's parent, so it can't break any layout. */ function positionBtn(btn, field) { const rect = field.getBoundingClientRect(); if (rect.width === 0) { btn.style.display = 'none'; return; } btn.style.display = 'flex'; btn.style.top = (rect.top + rect.height / 2 - 13) + 'px'; btn.style.left = (rect.right - 30) + 'px'; } // createIconBtn is defined in the Field decoration section below. // ── Suggestion dropdown ─────────────────────────────────────────────────────── function removeDropdown() { const el = document.getElementById(PK_DROPDOWN_ID); if (el) el.remove(); } /** * Build the dropdown anchored below `anchorField`. * Uses _matchingItems which is kept fresh via storage.onChanged listener. * `panel` is either 'credentials' (main list) or 'more' (options menu). */ async function showDropdown(anchorField, pwField, filterText, panel) { removeDropdown(); // Use module-level _matchingItems (kept fresh by storage.onChanged). // If still empty, try a direct storage read as last resort. var freshItems = _matchingItems; if (!freshItems.length) { try { var result = await chrome.storage.local.get('vault_items_cs'); var all = (result && result.vault_items_cs) || []; freshItems = _filterForHost(all); if (freshItems.length) _matchingItems = freshItems; } catch (e) { } } const rect = anchorField.getBoundingClientRect(); const dropWidth = Math.max(260, rect.width); const dropdown = document.createElement('div'); dropdown.id = PK_DROPDOWN_ID; Object.assign(dropdown.style, { position: 'fixed', top: (rect.bottom + 4) + 'px', left: rect.left + 'px', width: dropWidth + 'px', background: '#fff', border: '1px solid #dadce0', borderRadius: '10px', boxShadow: '0 6px 24px rgba(0,0,0,0.18)', zIndex: '2147483647', fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: '13px', overflow: 'hidden', }); if (panel === 'more') { buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); } else { buildCredentialsPanel(dropdown, anchorField, pwField, freshItems, filterText); } document.body.appendChild(dropdown); // Reposition on scroll/resize so it stays under the field. function reposition() { const r = anchorField.getBoundingClientRect(); dropdown.style.top = (r.bottom + 4) + 'px'; dropdown.style.left = r.left + 'px'; } window.addEventListener('scroll', reposition, { passive: true, capture: true }); window.addEventListener('resize', reposition, { passive: true }); // Close on outside mousedown or keyboard navigation. function onOutside(e) { const btn = _fieldBtnMap.get(anchorField); if (dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target))) return; removeDropdown(); document.removeEventListener('mousedown', onOutside, true); document.removeEventListener('keydown', onKeydown, true); } // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. function onKeydown(e) { if (e.key === 'Escape') { removeDropdown(); document.removeEventListener('mousedown', onOutside, true); document.removeEventListener('keydown', onKeydown, true); return; } if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Enter') return; // Only navigate credential rows (divs with data-pk-row attribute). var rows = Array.from(dropdown.querySelectorAll('[data-pk-row]')); if (!rows.length) return; e.preventDefault(); // prevent the field from scrolling the page if (e.key === 'Enter') { var focused = dropdown.querySelector('[data-pk-row].pk-row-focused'); if (focused && focused._pkFill) focused._pkFill(); return; } var currentIdx = rows.findIndex(function (r) { return r.classList.contains('pk-row-focused'); }); var nextIdx; if (e.key === 'ArrowDown') { nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; } else { nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; } rows.forEach(function (r) { r.classList.remove('pk-row-focused'); r.style.background = ''; }); rows[nextIdx].classList.add('pk-row-focused'); rows[nextIdx].style.background = '#e8f0fe'; rows[nextIdx].scrollIntoView({ block: 'nearest' }); } setTimeout(function () { document.addEventListener('mousedown', onOutside, true); document.addEventListener('keydown', onKeydown, true); }, 0); } // ── Credentials panel (main list) ──────────────────────────────────────────── function buildCredentialsPanel(dropdown, anchorField, pwField, items, filterText) { const q = (filterText || '').trim().toLowerCase(); const filtered = q ? items.filter(function (item) { return ((item.plain && item.plain.username) || '').toLowerCase().includes(q) || item.name.toLowerCase().includes(q); }) : items; const usernameField = anchorField.type === 'password' ? findUsernameField(anchorField) : anchorField; if (filtered.length === 0) { // No saved passwords — show a minimal "no items" row + More options. const empty = document.createElement('div'); Object.assign(empty.style, { padding: '12px 14px', color: '#5f6368', fontSize: '12px', }); empty.textContent = q ? 'No matches found.' : 'No saved passwords for this site.'; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { const row = document.createElement('div'); row.setAttribute('data-pk-row', '1'); // enables keyboard navigation Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer', transition: 'background 0.1s', }); row.onmouseenter = function () { if (!row.classList.contains('pk-row-focused')) row.style.background = '#f1f3f4'; }; row.onmouseleave = function () { if (!row.classList.contains('pk-row-focused')) row.style.background = ''; }; // Derive display hostname. var siteHost = item.name; if (item.plain && item.plain.url) { try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ''); } catch (e) { } } var username = escHtml((item.plain && item.plain.username) || ''); var site = escHtml(siteHost); // Lock icon avatar — filled dark circle like the screenshot. var avatar = document.createElement('div'); Object.assign(avatar.style, { width: '34px', height: '34px', borderRadius: '50%', background: '#1a1a2e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0', }); avatar.innerHTML = '' + '' + '' + ''; // Text. var text = document.createElement('div'); text.style.cssText = 'flex:1;min-width:0;'; text.innerHTML = '
' + site + '
' + (username ? '
' + username + '
' : ''); // Edit pencil. var editBtn = document.createElement('button'); Object.assign(editBtn.style, { background: 'none', border: 'none', cursor: 'pointer', padding: '5px', color: '#1a73e8', display: 'flex', alignItems: 'center', flexShrink: '0', borderRadius: '4px', }); editBtn.title = 'Edit in PassKeeper'; editBtn.innerHTML = '' + '' + '' + ''; editBtn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }); row.appendChild(avatar); row.appendChild(text); row.appendChild(editBtn); // Shared fill action — used by both mousedown and keyboard Enter. function doFill() { if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); // ✓ Filled flash: replace row content briefly before closing. row.innerHTML = '
' + '' + '' + 'Filled
'; row.style.background = '#f0fdf4'; setTimeout(function () { removeDropdown(); if (pwField && anchorField !== pwField) pwField.focus(); }, 600); } row.addEventListener('mousedown', function (e) { if (e.target === editBtn || editBtn.contains(e.target)) return; e.preventDefault(); doFill(); }); // Expose doFill for the keyboard Enter handler via a custom property. row._pkFill = doFill; dropdown.appendChild(row); }); } // Divider + "More options…" footer — always shown. var divider = document.createElement('div'); divider.style.cssText = 'height:1px;background:#e8eaed;'; dropdown.appendChild(divider); var more = document.createElement('div'); Object.assign(more.style, { display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer', color: '#202124', fontSize: '13px', transition: 'background 0.1s', }); more.onmouseenter = function () { more.style.background = '#f1f3f4'; }; more.onmouseleave = function () { more.style.background = ''; }; more.innerHTML = '' + '' + '' + '' + '' + 'More options\u2026'; more.addEventListener('mousedown', function (e) { e.preventDefault(); showDropdown(anchorField, pwField, filterText, 'more'); }); dropdown.appendChild(more); } // ── More options panel ──────────────────────────────────────────────────────── function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { // Back header. var backRow = document.createElement('div'); Object.assign(backRow.style, { display: 'flex', alignItems: 'center', gap: '6px', padding: '10px 14px', cursor: 'pointer', color: '#1a73e8', fontSize: '13px', fontWeight: '600', borderBottom: '1px solid #e8eaed', transition: 'background 0.1s', }); backRow.onmouseenter = function () { backRow.style.background = '#f1f3f4'; }; backRow.onmouseleave = function () { backRow.style.background = ''; }; backRow.innerHTML = '' + '' + ' Back'; backRow.addEventListener('mousedown', function (e) { e.preventDefault(); showDropdown(anchorField, pwField, filterText, 'credentials'); }); dropdown.appendChild(backRow); // Menu items matching the screenshot. var menuItems = [ { icon: '', label: 'Report a problem', action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, }, { icon: '', label: 'Generate a password', chevron: true, action: function () { chrome.runtime.sendMessage({ type: 'OPEN_GENERATOR' }).catch(function () { }); removeDropdown(); }, }, { icon: '', label: 'Open my vault', action: function () { chrome.runtime.sendMessage({ type: 'OPEN_VAULT' }).catch(function () { }); removeDropdown(); }, }, ]; menuItems.forEach(function (item) { var row = document.createElement('div'); Object.assign(row.style, { display: 'flex', alignItems: 'center', gap: '12px', padding: '11px 14px', cursor: 'pointer', color: '#202124', fontSize: '13px', transition: 'background 0.1s', borderBottom: '1px solid #f3f4f6', }); row.onmouseenter = function () { row.style.background = '#f1f3f4'; }; row.onmouseleave = function () { row.style.background = ''; }; var iconWrap = document.createElement('div'); iconWrap.style.cssText = 'width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;'; iconWrap.innerHTML = '' + item.icon + ''; var label = document.createElement('span'); label.style.cssText = 'flex:1;'; label.textContent = item.label; row.appendChild(iconWrap); row.appendChild(label); if (item.chevron) { var chev = document.createElement('div'); chev.innerHTML = '' + '' + ''; row.appendChild(chev); } else { var extIcon = document.createElement('div'); extIcon.innerHTML = '' + '' + '' + ''; row.appendChild(extIcon); } row.addEventListener('mousedown', function (e) { e.preventDefault(); item.action(); }); dropdown.appendChild(row); }); } // ── Field decoration ────────────────────────────────────────────────────────── // Map from field element → AbortController so we can cancel its listeners on re-decoration. const _fieldAbortMap = new WeakMap(); function decorateField(field, pwField) { if (field.getAttribute(PK_ATTR)) return; field.setAttribute(PK_ATTR, '1'); // Cancel any previous listeners on this field. const prevAC = _fieldAbortMap.get(field); if (prevAC) prevAC.abort(); const ac = new AbortController(); _fieldAbortMap.set(field, ac); const sig = ac.signal; createIconBtn(field, pwField, sig); } function createIconBtn(field, pwField, abortSignal) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = PK_BTN_CLASS; btn.title = 'PassKeeper autofill'; btn.setAttribute('aria-label', 'Autofill with PassKeeper'); btn.style.cssText = [ 'position:fixed', 'width:26px', 'height:26px', 'background:#c0392b', 'border:none', 'border-radius:5px', 'cursor:pointer', 'display:flex', 'align-items:center', 'justify-content:center', 'z-index:2147483646', 'padding:0', 'box-shadow:0 1px 4px rgba(0,0,0,0.3)', 'transition:background 0.15s', ].join(';'); btn.innerHTML = '' + '' + '' + '' + ''; btn.addEventListener('mouseenter', function () { btn.style.background = '#a93226'; }); btn.addEventListener('mouseleave', function () { btn.style.background = '#c0392b'; }); positionBtn(btn, field); document.body.appendChild(btn); _fieldBtnMap.set(field, btn); // Remove the button when the AbortController fires (re-decoration). abortSignal.addEventListener('abort', function () { btn.remove(); _fieldBtnMap.delete(field); }); // Keep button tracked as page scrolls/resizes. function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } window.addEventListener('scroll', reposition, { passive: true, signal: abortSignal }); window.addEventListener('resize', reposition, { passive: true, signal: abortSignal }); // ── All event handlers read _matchingItems at call time, never from closure ── // Show dropdown on focus — reads vault_items fresh from storage each time. field.addEventListener('focus', function () { showDropdown(field, pwField, field.value, 'credentials'); }, { signal: abortSignal }); // Re-filter as user types. field.addEventListener('input', function () { showDropdown(field, pwField, field.value, 'credentials'); }, { signal: abortSignal }); // Dim button when field loses focus and no dropdown is open. field.addEventListener('blur', function () { setTimeout(function () { if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = '0.4'; }, 150); }, { signal: abortSignal }); field.addEventListener('focus', function () { btn.style.opacity = '1'; }, { signal: abortSignal }); // Icon click: toggle dropdown. btn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } else { showDropdown(field, pwField, field.value, 'credentials'); } }); return btn; } async function decorateFields() { // Read from chrome.storage.local — reliable across all Chrome versions and // does not depend on message delivery from the service worker. var all = []; try { var result = await chrome.storage.local.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { } _matchingItems = _filterForHost(all); console.log('[PassKeeper] decorateFields: host=' + location.hostname.replace(/^www\./, '') + ', matched=' + _matchingItems.length + ' of ' + all.length + ' items'); // Decorate every visible password field and its paired username field. visiblePasswordFields().forEach(function (pwField) { var usernameField = findUsernameField(pwField); if (usernameField) decorateField(usernameField, pwField); decorateField(pwField, pwField); }); } // ── Vault item helpers ──────────────────────────────────────────────────────── function _filterForHost(items) { var host = location.hostname.replace(/^www\./, ''); return (items || []).filter(function (item) { if (item.item_type !== 'password' || !(item.plain && item.plain.url)) return false; try { var h = new URL(item.plain.url).hostname.replace(/^www\./, ''); return h === host || h.endsWith('.' + host) || host.endsWith('.' + h); } catch (e) { return false; } }); } // ── Form detection ──────────────────────────────────────────────────────────── function notifyFormDetected() { if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; chrome.runtime.sendMessage({ type: 'FORMS_DETECTED' }).catch(function () { }); } // ── Duplicate detection ─────────────────────────────────────────────────────── async function classifyCredentials(username, password) { var all = []; try { var result = await chrome.storage.local.get('vault_items_cs'); all = (result && result.vault_items_cs) || []; } catch (e) { return 'new'; } if (!all.length) return 'new'; var siteItems = _filterForHost(all); if (!siteItems.length) return 'new'; var exactMatch = siteItems.some(function (item) { return item.plain && item.plain.username === username && item.plain.password === password; }); return exactMatch ? 'same' : 'updated'; } // ── Save blocklist ──────────────────────────────────────────────────────────── const BLOCKLIST_KEY = 'save_blocklist'; async function isBlocked(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; return list.indexOf(hostname) !== -1; } catch (e) { return false; } } async function addToBlocklist(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; if (list.indexOf(hostname) === -1) { list.push(hostname); await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); console.log('[PassKeeper] Added to save blocklist:', hostname); } } catch (e) { } } // ── Auto-save banner ────────────────────────────────────────────────────────── function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); var banner = document.createElement('div'); banner.id = '__pk_save_banner__'; Object.assign(banner.style, { position: 'fixed', top: '12px', right: '12px', zIndex: '2147483647', background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '10px', boxShadow: '0 8px 30px rgba(0,0,0,0.15)', padding: '14px 16px 12px', fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: '13px', color: '#1a1a2e', maxWidth: '300px', minWidth: '240px', }); var site = escHtml(location.hostname); var user = escHtml(username); var title = credentialState === 'updated' ? 'Update in PassKeeper?' : 'Save to PassKeeper?'; banner.innerHTML = '
' + '' + '' + escHtml(title) + '' + '' + '
' + '

' + '' + user + ' on ' + site + '' + '

' + '
' + '' + '' + '
' + ''; document.body.appendChild(banner); _bannerEl = banner; var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; banner.querySelector('#__pk_close__').addEventListener('click', dismiss); banner.querySelector('#__pk_skip__').addEventListener('click', dismiss); banner.querySelector('#__pk_save__').addEventListener('click', function () { console.log('[PassKeeper] User chose to save credentials for', location.hostname); chrome.runtime.sendMessage({ type: 'SAVE_CREDENTIALS', data: { url: location.href, siteName: document.title || location.hostname, username: username, password: password }, }).catch(function () { }); dismiss(); }); banner.querySelector('#__pk_never__').addEventListener('click', function () { addToBlocklist(location.hostname); dismiss(); }); } // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { document.addEventListener('submit', async function (e) { var form = e.target; var pwField = form.querySelector('input[type="password"]:not([disabled])'); if (!pwField || !pwField.value) return; var userField = findUsernameField(pwField) || form.querySelector('input[type="email"]:not([disabled])') || form.querySelector('input[type="text"]:not([disabled])'); var username = (userField && userField.value && userField.value.trim()) || ''; var password = pwField.value; if (!username || !password) return; removeDropdown(); // Check blocklist before doing anything else. if (await isBlocked(location.hostname)) { console.log('[PassKeeper] Site is blocklisted, skipping save banner:', location.hostname); return; } var credentialState = await classifyCredentials(username, password); console.log('[PassKeeper] Credential state for', location.hostname, '\u2192', credentialState); if (credentialState === 'same') return; setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); }, true); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { if (msg.type === 'DO_AUTOFILL') { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } if (msg.type === 'VAULT_UPDATED') { // Items may arrive in the message payload (best-effort), but the source // of truth is now chrome.storage.local which was already written by the popup. var allItems = msg.vault_items || []; if (allItems.length) { _matchingItems = _filterForHost(allItems); console.log('[PassKeeper] VAULT_UPDATED (message): matched=' + _matchingItems.length + ' of ' + allItems.length); } // Always re-decorate (also re-reads storage if message had no items). document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(); } return false; }); // ── Init ────────────────────────────────────────────────────────────────────── function init() { notifyFormDetected(); decorateFields(); watchSubmissions(); // React instantly when the popup writes fresh vault data to local storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { if (area === 'local' && changes.vault_items_cs) { var allItems = (changes.vault_items_cs.newValue) || []; _matchingItems = _filterForHost(allItems); console.log('[PassKeeper] storage.onChanged: matched=' + _matchingItems.length + ' of ' + allItems.length + ' items'); // Re-decorate all fields with the fresh items. document.querySelectorAll('[' + PK_ATTR + ']').forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll('.' + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(); } }); _formObserver = new MutationObserver(function () { _hasNotifiedForm = false; notifyFormDetected(); decorateFields(); }); _formObserver.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();