UNPKG

persist-ui

Version:

Universal input persistence for Vanilla, React, Vue, Astro, Next.js etc. with secure AES encryption.

196 lines (176 loc) 6.88 kB
import CryptoJS from "crypto-js"; type PersistUIOptions = { key: string; debounce?: number; encrypt?: boolean; encryptionSecret?: string; ignoreTypes?: string[]; resetSelector?: string; onRestore?: (data: Record<string, any>) => void; clearOnSubmit?: boolean; submitSelector?: string; }; const ENCRYPT_PREFIX = 'persistUI::enc::'; function encrypt(data: string, secret: string): string { try { const encrypted = CryptoJS.AES.encrypt(data, secret).toString(); return ENCRYPT_PREFIX + encrypted; } catch (err) { console.error("persistUI: Encryption failed", err); return data; // fallback to unencrypted data } } function decrypt(data: string, secret: string): string { if (!data || !data.startsWith(ENCRYPT_PREFIX)) return data; try { const encrypted = data.replace(ENCRYPT_PREFIX, ''); const bytes = CryptoJS.AES.decrypt(encrypted, secret); const decrypted = bytes.toString(CryptoJS.enc.Utf8); return decrypted; } catch (err) { console.error("persistUI: Decryption failed", err); return ""; // return empty string on decryption failure } } function safeGet(key: string): string | null { try { return localStorage.getItem(key); } catch { return null; } } function safeSet(key: string, value: string) { try { localStorage.setItem(key, value); } catch {} } function safeRemove(key: string) { try { localStorage.removeItem(key); } catch {} } export let persistUI = { attach( selectors: string | string[] | HTMLElement | HTMLElement[], options: PersistUIOptions ) { const nodes = Array.isArray(selectors) ? selectors.map(sel => typeof sel === "string" ? document.querySelector(sel) : sel ).filter(Boolean) as HTMLElement[] : typeof selectors === "string" ? [document.querySelector(selectors)].filter(Boolean) as HTMLElement[] : [selectors].filter(Boolean) as HTMLElement[]; const ignoreTypes = options.ignoreTypes || []; const debounce = options.debounce ?? 0; const encryptFlag = options.encrypt ?? false; const secret = encryptFlag ? (options.encryptionSecret || options.key) : ""; let timer: ReturnType<typeof setTimeout> | null = null; function save() { const data: Record<string, any> = {}; for (const node of nodes) { if ( (node as HTMLInputElement).type && ignoreTypes.includes((node as HTMLInputElement).type) ) continue; const key = node.id || (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).name || node.tagName; if (node instanceof HTMLInputElement) { if (node.type === "checkbox" || node.type === "radio") { data[key] = node.checked; } else { data[key] = node.value; } } else if (node instanceof HTMLTextAreaElement) { data[key] = node.value; } else if (node instanceof HTMLSelectElement) { data[key] = node.value; } else if ("checked" in node) { data[key] = (node as any).checked; } } let str = JSON.stringify(data); if (encryptFlag) str = encrypt(str, secret); safeSet(options.key, str); } function restore() { const raw = safeGet(options.key); if (!raw) return; let data: Record<string, any>; try { const json = encryptFlag ? decrypt(raw, secret) : raw; data = JSON.parse(json); } catch { return; } for (const node of nodes) { const key = node.id || (node as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).name || node.tagName; if (data[key] !== undefined) { if (node instanceof HTMLInputElement) { if (node.type === "checkbox" || node.type === "radio") { node.checked = !!data[key]; } else { node.value = data[key]; } } else if (node instanceof HTMLTextAreaElement) { node.value = data[key]; } else if (node instanceof HTMLSelectElement) { node.value = data[key]; } else if ("checked" in node) { (node as any).checked = !!data[key]; } } } if (typeof options.onRestore === "function") options.onRestore(data); } function reset() { safeRemove(options.key); for (const node of nodes) { if (node instanceof HTMLInputElement) { if (node.type === "checkbox" || node.type === "radio") { node.checked = false; } else { node.value = ""; } } else if (node instanceof HTMLTextAreaElement) { node.value = ""; } else if (node instanceof HTMLSelectElement) { node.value = ""; } else if ("checked" in node) { (node as any).checked = false; } } } nodes.forEach(node => { const handler = () => { if (debounce > 0) { if (timer) clearTimeout(timer); timer = setTimeout(save, debounce); } else { save(); } }; node.addEventListener("input", handler); node.addEventListener("change", handler); }); restore(); if (options.resetSelector) { const resetBtn = document.querySelector(options.resetSelector); if (resetBtn) resetBtn.addEventListener("click", reset); } if (options.clearOnSubmit && options.submitSelector) { const submitBtn = document.querySelector(options.submitSelector); if (submitBtn) { submitBtn.addEventListener("click", () => { safeRemove(options.key); }); } } window.addEventListener("beforeunload", () => { if (options.clearOnSubmit) { safeRemove(options.key); } }); return { save, restore, reset }; } };