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
text/typescript
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 };
}
};