您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required)
// ==UserScript== // @name JanitorAI Character Card Scraper // @version 3.0 // @description Extract character card with "T" key (WHILE IN CHAT PAGE) and save as .txt, .png, or .json (proxy required) // @match https://janitorai.com/* // @icon https://images.dwncdn.net/images/t_app-icon-l/p/46413ec0-e1d8-4eab-a0bc-67eadabb2604/3920235030/janitor-ai-logo // @grant none // @namespace https://sleazyfork.org/en/scripts/537206-janitorai-character-card-scraper // @run-at document-start // @license MIT // ==/UserScript== (() => { "use strict"; /* ============================ == VARIABLES == ============================ */ let hasInitialized = false; let viewActive = false; let shouldInterceptNext = false; let networkInterceptActive = false; let exportFormat = null; let chatData = null; let currentTab = sessionStorage.getItem("lastActiveTab") || "export"; let useChatNameForName = localStorage.getItem("useChatNameForName") === "true" || false; let applyCharToken = localStorage.getItem("applyCharToken") !== "false"; let filenameTemplate = localStorage.getItem("filenameTemplateDraft") || localStorage.getItem("filenameTemplate") || "{name}"; // Migration: Update old default for existing users const storedSavePath = localStorage.getItem("savePathTemplate"); if (storedSavePath === "images/{creator}") { localStorage.setItem("savePathTemplate", "cards/{creator}"); } // Simple boolean: false = show prefill, true = user has applied changes // Default to false for fresh installs, only true if user explicitly applied changes let userChangedSavePath = localStorage.getItem("userChangedSavePath") === "true"; let savePathTemplate = userChangedSavePath ? localStorage.getItem("savePathTemplate") || "" : "cards/{creator}"; let animationTimeouts = []; let currentActiveTab = "export"; // Track current tab state let restorationInProgress = false; // Prevent multiple simultaneous restorations let guiElement = null; sessionStorage.removeItem("char_export_scroll"); sessionStorage.removeItem("char_settings_scroll"); const ANIMATION_DURATION = 150; // Animation duration for modal open/close in ms const TAB_ANIMATION_DURATION = 300; // Animation duration for tab switching in ms const TAB_BUTTON_DURATION = 250; // Animation duration for tab button effects const BUTTON_ANIMATION = 200; // Animation duration for format buttons const TOGGLE_ANIMATION = 350; // Animation duration for toggle switch const ACTIVE_TAB_COLOR = "#0080ff"; // Color for active tab indicator const INACTIVE_TAB_COLOR = "transparent"; // Color for inactive tab indicator const BUTTON_COLOR = "#3a3a3a"; // Base color for buttons const BUTTON_HOVER_COLOR = "#4a4a4a"; // Hover color for buttons const BUTTON_ACTIVE_COLOR = "#0070dd"; // Active color for buttons when clicked const TOOLTIP_SLIDE_FROM_RIGHT = true; // true = slide towards right (default). Set false for slide-left variant. const TOOLTIP_SLIDE_OFFSET = 10; // px the tooltip travels during slide animation const blankMeta = { creatorUrl: "", characterVersion: "", characterCardUrl: "", name: "", creatorNotes: "", personality: "", scenario: "", firstMessage: "", exampleDialogs: "", definitionExposed: false, }; const characterMetaCache = { id: null, useChatNameForName: false, ...blankMeta, }; interceptNetwork(); /* ============================ == UTILITIES == ============================ */ function makeElement(tag, attrs = {}, styles = {}) { const el = document.createElement(tag); Object.entries(attrs).forEach(([key, value]) => (el[key] = value)); if (styles) { Object.entries(styles).forEach(([key, value]) => (el.style[key] = value)); } return el; } /** * Extract creator profile URL from fetched character page document. * Looks for the profile anchor used across pages. * @param {Document} doc – HTML document parsed from character page. * @returns {string} Absolute URL or empty string. */ function getCreatorUrlFromDoc(doc) { const link = doc.querySelector("a.chakra-link.css-15sl5jl"); if (link) { const href = link.getAttribute("href"); if (href) return `https://janitorai.com${href}`; } return ""; } async function saveFile(filename, blob) { const useFileSystemAPI = localStorage.getItem("useFileSystemAccess") === "true"; if (useFileSystemAPI && "showDirectoryPicker" in window) { try { // Get the selected directory handle from global storage const directoryHandle = window.selectedDirectoryHandle; if (directoryHandle) { // Get save path template - use empty string if user changed it and set to empty const userChangedSavePath = localStorage.getItem("userChangedSavePath") === "true"; const savePathTemplate = userChangedSavePath ? localStorage.getItem("savePathTemplate") || "" : "cards/{creator}"; // Simple path building - replace tokens using same data as filename system let savePath = savePathTemplate; console.log("[DEBUG] Original save path template:", savePathTemplate); console.log("[DEBUG] Available chatData:", chatData); // Get character metadata for token replacement const meta = await getCharacterMeta(); const tokens = await getFilenameTokens(meta); console.log("[DEBUG] Available tokens:", tokens); // Replace tokens using the same system as filename templates Object.entries(tokens).forEach(([tokenKey, tokenValue]) => { if (tokenValue && String(tokenValue).trim() !== "") { const regex = new RegExp(`\\{${escapeRegExp(tokenKey)}\\}`, "g"); const oldSavePath = savePath; savePath = savePath.replace(regex, String(tokenValue)); if (oldSavePath !== savePath) { console.log(`[DEBUG] Replaced {${tokenKey}} with:`, tokenValue); } } }); console.log("[DEBUG] Final save path:", savePath); // Clean up the path and split into segments savePath = savePath.replace(/^\/+|\/+$/g, ""); // Remove leading/trailing slashes const pathSegments = savePath ? savePath.split("/").filter((segment) => segment.length > 0) : []; // Navigate/create directory structure let currentDir = directoryHandle; for (const segment of pathSegments) { try { currentDir = await currentDir.getDirectoryHandle(segment, { create: true, }); } catch (error) { console.error( `Failed to create/access directory ${segment}:`, error, ); // Fall back to regular download if directory creation fails regularDownload(filename, blob); return; } } // Create unique filename if file already exists let finalFilename = filename; let counter = 1; let fileHandle; while (true) { try { // Try to get existing file await currentDir.getFileHandle(finalFilename); // File exists, increment counter and try again const lastDot = filename.lastIndexOf("."); if (lastDot === -1) { finalFilename = `${filename} (${counter})`; } else { const nameWithoutExt = filename.substring(0, lastDot); const extension = filename.substring(lastDot); finalFilename = `${nameWithoutExt} (${counter})${extension}`; } counter++; } catch (error) { // File doesn't exist, we can use this filename break; } } // Create and write the file with unique name fileHandle = await currentDir.getFileHandle(finalFilename, { create: true, }); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); // Visual feedback for successful save const successMessage = `Saved to: ${pathSegments.length > 0 ? pathSegments.join("/") + "/" : ""}${finalFilename}`; console.log(successMessage); // Show temporary success notification const notification = document.createElement("div"); notification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #4CAF50; color: white; padding: 12px 20px; border-radius: 6px; z-index: 10001; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-size: 14px; font-weight: bold; opacity: 0; transform: translateX(100%); transition: all 300ms ease; `; notification.textContent = `✓ ${successMessage}`; document.body.appendChild(notification); requestAnimationFrame(() => { notification.style.opacity = "1"; notification.style.transform = "translateX(0)"; }); setTimeout(() => { notification.style.opacity = "0"; notification.style.transform = "translateX(100%)"; setTimeout(() => notification.remove(), 300); }, 3000); return; } } catch (error) { console.error("FileSystemAccess API failed:", error); // Show error notification const errorNotification = document.createElement("div"); errorNotification.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #f44336; color: white; padding: 12px 20px; border-radius: 6px; z-index: 10001; box-shadow: 0 4px 12px rgba(0,0,0,0.3); font-size: 14px; font-weight: bold; opacity: 0; transform: translateX(100%); transition: all 300ms ease; `; errorNotification.textContent = `⚠ FileSystem save failed, using download instead`; document.body.appendChild(errorNotification); requestAnimationFrame(() => { errorNotification.style.opacity = "1"; errorNotification.style.transform = "translateX(0)"; }); setTimeout(() => { errorNotification.style.opacity = "0"; errorNotification.style.transform = "translateX(100%)"; setTimeout(() => errorNotification.remove(), 300); }, 3000); // Fall back to regular download } } // Regular download fallback regularDownload(filename, blob); } function regularDownload(filename, blob) { const url = URL.createObjectURL(blob); const a = makeElement("a", { href: url, download: filename, }); a.addEventListener("click", (e) => { e.stopPropagation(); }); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function getCharacterCardUrl(id) { return `https://janitorai.com/characters/${id}`; } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function extractTagContent(sys, charName) { if (!charName || !sys) return ""; const variants = []; const trimmed = charName.trim(); variants.push(trimmed); const collapsed = trimmed.replace(/\s+/g, " "); if (collapsed !== trimmed) variants.push(collapsed); if (trimmed.includes(" ")) variants.push(trimmed.replace(/\s+/g, "_")); for (const name of variants) { const escName = escapeRegExp(name); const regex = new RegExp( `<${escName}(?:\\s[^>]*)?\\s*>([\\s\\S]*?)<\\/${escName}\\s*>`, "i", ); const m = sys.match(regex); if (m && m[1] != null) { return m[1].trim(); } const openTagRx = new RegExp(`<${escName}(?:\\s[^>]*)?\\s*>`, "i"); const closeTagRx = new RegExp(`<\\/${escName}\\s*>`, "i"); const openMatch = openTagRx.exec(sys); const closeMatch = closeTagRx.exec(sys); if (openMatch && closeMatch && closeMatch.index > openMatch.index) { const start = openMatch.index + openMatch[0].length; const end = closeMatch.index; return sys.substring(start, end).trim(); } try { const parser = new DOMParser(); const doc = parser.parseFromString( `<root>${sys}</root>`, "application/xml", ); const elems = doc.getElementsByTagName(name); if (elems.length) { return elems[0].textContent.trim(); } } catch (_) {} } return ""; } function stripWatermark(text) { if (!text) return ""; const lines = text.split(/\r?\n/); const filtered = lines.filter((l) => { const t = l.trim(); return !(/^created/i.test(t) && /janitorai\.com"?$/i.test(t)); }); return filtered.join("\n").trim(); } // === Early Chat Data Prefetch === function findChatId() { // Try URL first const direct = window.location.href.match(/\/chats\/(\d+)/); if (direct) return direct[1]; // Fallback: parse window._storeState_ script tag const scripts = document.querySelectorAll("script"); for (const s of scripts) { const txt = s.textContent; if (!txt || !txt.includes("window._storeState_")) continue; const m = txt.match(/JSON\.parse\("([\s\S]*?)"\)/); if (m && m[1]) { try { const decoded = JSON.parse(`"${m[1]}"`); // unescape const obj = JSON.parse(decoded); let cid = null; const walk = (o) => { if (!o || typeof o !== "object" || cid) return; if ( Object.prototype.hasOwnProperty.call(o, "chatId") && typeof o.chatId === "number" ) { cid = o.chatId; return; } for (const k in o) walk(o[k]); }; walk(obj); if (cid) return cid; } catch (_) {} } } return null; } function getAuthHeader() { // Look for any sb-auth-auth-token.* cookie variants const matches = document.cookie.match(/sb-auth-auth-token\.[^=]+=([^;]+)/g); if (matches && matches.length) { for (const seg of matches) { const rawVal = seg.substring(seg.indexOf("=") + 1); const hdr = extractBearer(rawVal); if (hdr) return hdr; } } return null; function extractBearer(val) { let raw = decodeURIComponent(val); if (raw.startsWith("base64-")) raw = raw.slice(7); // Raw JWT if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) { console.log("[getAuthHeader] using raw JWT"); return `Bearer ${raw}`; } // Try base64 JSON with access_token field try { const json = JSON.parse(atob(raw)); if (json && json.access_token) { console.log("[getAuthHeader] using access_token from JSON"); return `Bearer ${json.access_token}`; } } catch (err) { /* ignore */ } return null; } // Extract Bearer token from sb-auth-auth-token cookie (value is URL-encoded base64-JSON) const m = document.cookie.match(/sb-auth-auth-token\.\d+=([^;]+)/); if (!m) return null; let raw = decodeURIComponent(m[1]); if (raw.startsWith("base64-")) raw = raw.slice(7); try { const json = JSON.parse(atob(raw)); if (json.access_token) return `Bearer ${json.access_token}`; } catch (err) { // Fallback: some builds store the token directly, detect JWT shape (three dot-separated parts) if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) { return `Bearer ${raw}`; } } return null; } function getAppVersion() { // Extract version from any script src query param like '?v=2025-06-24.<hash>' const scr = [...document.querySelectorAll("script[src]")].find((s) => /\bv=([\w.-]+)/.test(s.src), ); if (scr) { const m = scr.src.match(/\bv=([\w.-]+)/); if (m) return m[1]; } // Fallback to hard-coded value seen in Headers (may need update if site version changes) return "2025-06-24.81a918c33"; } async function prefetchChatData() { if (chatData) return; const chatId = findChatId(); if (!chatId) return; const endpoint = `https://janitorai.com/hampter/chats/${chatId}`; const appVer = getAppVersion(); const auth = getAuthHeader(); console.log("[prefetchChatData] auth:", auth); const baseHeaders = { "x-app-version": appVer, accept: "application/json, text/plain, */*", }; if (auth) baseHeaders["Authorization"] = auth; console.log("[prefetchChatData] request headers", baseHeaders); // First try with cookies + headers try { let res = await fetch(endpoint, { method: "GET", credentials: "include", headers: baseHeaders, }); if (res.status === 401) { // Retry with Authorization header if available const auth = getAuthHeader(); if (auth) { res = await fetch(endpoint, { method: "GET", credentials: "include", headers: { ...baseHeaders, Authorization: auth, }, }); } } if (res.ok) { const json = await res.json(); if (json && json.character) { chatData = json; console.log("[prefetchChatData] chatData pre-fetched"); } } } catch (err) { console.warn("[prefetchChatData] failed:", err); } } function tokenizeNames(text, charName, userName) { if (!text) return text; const parts = text.split("\n\n"); const [cRx, uRx] = [charName, userName].map((n) => n ? escapeRegExp(n) : "", ); for (let i = 0, l = parts.length; i < l; ++i) { if ( !/^==== (Name|Chat Name|Initial Message|Character Card|Creator) ====/.test( parts[i], ) ) { if (applyCharToken) { if (cRx) parts[i] = parts[i].replace( new RegExp(`(?<!\\w)${cRx}(?!\\w)`, "g"), "{{char}}", ); if (uRx) parts[i] = parts[i].replace( new RegExp(`(?<!\\w)${uRx}(?!\\w)`, "g"), "{{user}}", ); } else if (charName) { parts[i] = parts[i].replace(/\{\{char\}\}/gi, charName); } } } return parts.join("\n\n"); } function tokenizeField(text, charName, userName) { if (!text) return text; const esc = (n) => escapeRegExp(n); const rules = []; if (applyCharToken && charName) { rules.push([ new RegExp(`\\b${esc(charName)}('s)?\\b`, "gi"), (_, sfx) => `{{char}}${sfx || ""}`, ]); } if (userName) { rules.push([ new RegExp(`\\b${esc(userName)}('s)?\\b`, "gi"), (_, sfx) => `{{user}}${sfx || ""}`, ]); } let out = rules.reduce((t, [rx, repl]) => t.replace(rx, repl), text); if (!applyCharToken && charName) { out = out.replace(/\{\{char\}\}/gi, charName); } return out; } /** * Adds a token value to the filename template input field * @param {string} token - Token to add (e.g., "id", "name", etc.) * @param {HTMLInputElement} inputField - The template input field */ function addTokenToTemplate(token, inputField) { if (!inputField || !token) return; const currentValue = inputField.value || ""; const cursorPos = inputField.selectionStart || currentValue.length; // Add space before token if there's content and no trailing space or slash const needsSpace = currentValue && !currentValue.endsWith(" ") && !currentValue.endsWith("/") && cursorPos === currentValue.length; const tokenToAdd = needsSpace ? ` {${token}}` : `{${token}}`; const newValue = currentValue.slice(0, cursorPos) + tokenToAdd + currentValue.slice(cursorPos); inputField.value = newValue; // Update cursor position const newCursorPos = cursorPos + tokenToAdd.length; inputField.setSelectionRange(newCursorPos, newCursorPos); inputField.focus(); // Trigger change event inputField.dispatchEvent(new Event("input", { bubbles: true })); } /** * Creates a token button for the filename template UI * @param {string} token - Token name * @param {string} label - Display label for the button * @param {HTMLInputElement} inputField - Template input field * @returns {HTMLElement} - Button element */ function createTokenButton(token, label, inputElement, isSavePath = false) { const button = makeElement( "button", { textContent: label, type: "button", }, { background: "#3a3a3a", border: "1px solid #555", color: "#fff", padding: "4px 8px", borderRadius: "4px", cursor: "pointer", fontSize: "11px", transition: "all 150ms ease", margin: "2px", }, ); button.addEventListener("mouseover", () => { button.style.background = "#4a4a4a"; button.style.borderColor = "#666"; }); button.addEventListener("mouseout", () => { button.style.background = "#3a3a3a"; button.style.borderColor = "#555"; }); button.addEventListener("mousedown", (e) => { // Only trigger on left mouse button if (e.button !== 0) return; e.preventDefault(); addTokenToTemplate(token, inputElement); // Green flash and pulse animation for left click button.style.background = "#4CAF50"; button.style.borderColor = "#66BB6A"; button.style.transform = "scale(1.1)"; button.style.boxShadow = "0 0 10px rgba(76, 175, 80, 0.6)"; setTimeout(() => { button.style.background = "#3a3a3a"; button.style.borderColor = "#555"; button.style.transform = "scale(1)"; button.style.boxShadow = "none"; }, 100); }); // Right mousedown for immediate removal button.addEventListener("mousedown", (e) => { // Only trigger on right mouse button if (e.button !== 2) return; e.preventDefault(); e.stopPropagation(); const currentValue = inputElement.value; const tokenPattern = `{${token}}`; if (currentValue.includes(tokenPattern)) { // Find the first occurrence and remove only one instance with intelligent spacing let newValue = currentValue; const tokenRegex = new RegExp(`\\{${token}\\}`, ""); // Check if token has a space before it const tokenWithSpaceRegex = new RegExp(` \\{${token}\\}`, ""); if (tokenWithSpaceRegex.test(newValue)) { // Remove token with preceding space newValue = newValue.replace(tokenWithSpaceRegex, ""); } else { // Remove just the token newValue = newValue.replace(tokenRegex, ""); } // Clean up any double spaces that might result newValue = newValue.replace(/\s+/g, " ").trim(); inputElement.value = newValue; // Trigger input event to update state if (isSavePath) { savePathTemplate = newValue; } else { filenameTemplate = newValue; localStorage.setItem("filenameTemplateDraft", filenameTemplate); } inputElement.dispatchEvent(new Event("input", { bubbles: true })); // Immediate red flash and pulse animation button.style.background = "#f44336"; button.style.borderColor = "#ff6666"; button.style.transform = "scale(1.1)"; button.style.boxShadow = "0 0 10px rgba(244, 67, 54, 0.6)"; setTimeout(() => { button.style.background = "#3a3a3a"; button.style.borderColor = "#555"; button.style.transform = "scale(1)"; button.style.boxShadow = "none"; }, 100); } else { // Budge animation when token not found button.style.transform = "translateX(-3px)"; setTimeout(() => { button.style.transform = "translateX(3px)"; setTimeout(() => { button.style.transform = "translateX(0)"; }, 100); }, 100); } }); // Prevent context menu from appearing button.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); }); return button; } /** * Creates a standardized toggle component * @param {string} key - localStorage key * @param {string} label - Display label * @param {string} tooltip - Tooltip text * @param {boolean} defaultValue - Default checked state * @returns {Object} - Object containing container and input elements */ function createToggle(key, label, tooltip, defaultValue = false) { const container = makeElement( "div", {}, { display: "flex", alignItems: "center", marginBottom: "8px", marginTop: "10px", padding: "15px", background: "#2a2a2a", borderRadius: "10px", gap: "12px", boxShadow: "0 2px 4px rgba(0,0,0,0.1)", isolation: "isolate", contain: "layout style paint", border: "1px solid #444", background: "linear-gradient(135deg, #2a2a2a 0%, #2e2e2e 100%)", transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", cursor: "pointer", }, ); const wrapper = makeElement( "div", { className: "toggle-wrapper", }, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", cursor: "pointer", position: "relative", isolation: "isolate", transition: "all 0.2s ease", }, ); const labelElement = makeElement( "span", { textContent: label, }, { fontSize: "13px", color: "#fff", order: "2", textAlign: "left", flex: "1", paddingLeft: "10px", wordBreak: "break-word", lineHeight: "1.4", }, ); const toggle = makeElement( "label", { className: "switch", }, { position: "relative", display: "inline-block", width: "40px", height: "24px", order: "1", margin: "0", flexShrink: "0", borderRadius: "24px", boxShadow: "0 1px 3px rgba(0,0,0,0.2) inset", transition: `all ${TOGGLE_ANIMATION}ms cubic-bezier(0.4, 0, 0.2, 1)`, cursor: "pointer", }, ); const slider = makeElement( "span", { className: "slider round", }, { position: "absolute", cursor: "pointer", top: "0", left: "0", right: "0", bottom: "0", backgroundColor: defaultValue ? ACTIVE_TAB_COLOR : "#ccc", transition: `all ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`, borderRadius: "24px", overflow: "hidden", boxShadow: defaultValue ? "0 0 8px rgba(0, 128, 255, 0.3)" : "none", }, ); const sliderShine = makeElement( "div", {}, { position: "absolute", top: "0", left: "0", width: "100%", height: "100%", background: "linear-gradient(135deg, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 50%)", opacity: "0.5", transition: `opacity ${TOGGLE_ANIMATION}ms ease`, }, ); slider.appendChild(sliderShine); const sliderBefore = makeElement( "span", { className: "slider-before", }, { position: "absolute", content: '""', height: "16px", width: "16px", left: "4px", bottom: "4px", backgroundColor: "white", transition: `transform ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow ${TOGGLE_ANIMATION}ms ease`, borderRadius: "50%", transform: defaultValue ? "translateX(16px)" : "translateX(0)", boxShadow: defaultValue ? "0 0 2px rgba(0,0,0,0.2), 0 0 5px rgba(0,128,255,0.3)" : "0 0 2px rgba(0,0,0,0.2)", }, ); const input = makeElement( "input", { type: "checkbox", checked: defaultValue, }, { opacity: "0", width: "0", height: "0", position: "absolute", }, ); input.addEventListener("change", (e) => { const isChecked = e.target.checked; localStorage.setItem(key, isChecked); // Enhanced animations slider.style.backgroundColor = isChecked ? ACTIVE_TAB_COLOR : "#ccc"; slider.style.boxShadow = isChecked ? "0 0 8px rgba(0, 128, 255, 0.3)" : "none"; sliderBefore.style.transform = isChecked ? "translateX(16px)" : "translateX(0)"; sliderBefore.style.boxShadow = isChecked ? "0 0 2px rgba(0,0,0,0.2), 0 0 8px rgba(0,128,255,0.5)" : "0 0 2px rgba(0,0,0,0.2)"; wrapper.classList.toggle("active", isChecked); // Enhanced container animation container.style.transform = "scale(1.02)"; container.style.borderColor = isChecked ? "rgba(0, 128, 255, 0.4)" : "#444"; container.style.boxShadow = isChecked ? "0 4px 12px rgba(0, 128, 255, 0.15)" : "0 2px 4px rgba(0,0,0,0.1)"; setTimeout(() => { container.style.transform = "scale(1)"; }, 150); }); slider.appendChild(sliderBefore); toggle.appendChild(input); toggle.appendChild(slider); // Enhanced hover effects for container container.addEventListener("mouseenter", () => { if (!container.style.transform.includes("scale")) { container.style.transform = "translateY(-1px) scale(1.005)"; container.style.boxShadow = "0 6px 16px rgba(0,0,0,0.15)"; } }); container.addEventListener("mouseleave", () => { if (!container.style.transform.includes("1.02")) { container.style.transform = "translateY(0) scale(1)"; const isActive = input.checked; container.style.boxShadow = isActive ? "0 4px 12px rgba(0, 128, 255, 0.15)" : "0 2px 4px rgba(0,0,0,0.1)"; } }); wrapper.addEventListener("click", (e) => { e.preventDefault(); input.checked = !input.checked; const event = new Event("change"); input.dispatchEvent(event); document.body.focus(); }); wrapper.appendChild(labelElement); labelElement.setAttribute("data-tooltip", tooltip); wrapper.appendChild(toggle); container.appendChild(wrapper); return { container, input, wrapper }; } /* ============================ == UI == ============================ */ function createUI() { if (guiElement && document.body.contains(guiElement)) { return; } animationTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); animationTimeouts = []; viewActive = true; const gui = makeElement( "div", { id: "char-export-gui", }, { position: "fixed", top: "50%", left: "50%", transform: "translate(-50%, -50%) scale(0.95)", background: "#222", color: "white", padding: "15px 20px 7px", borderRadius: "8px", boxShadow: "0 0 20px rgba(0,0,0,0.5)", zIndex: "10000", textAlign: "center", width: "min(480px, 90vw)", minHeight: "400px", maxHeight: "85vh", overflow: "hidden", display: "flex", flexDirection: "column", boxSizing: "border-box", opacity: "0", transition: `opacity ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1), transform ${ANIMATION_DURATION}ms cubic-bezier(0.4, 0, 0.2, 1)`, }, ); guiElement = gui; const unselectStyle = document.createElement("style"); unselectStyle.textContent = `#char-export-gui, #char-export-gui * { user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } #char-export-gui { backdrop-filter: blur(6px); background: rgba(34,34,34,0.92); border: none; border-radius: 8px; } #char-export-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9998; } #char-export-gui #char-export-tooltip { font-size: 12px; background: #222; color: #fff; padding: 6px 10px; border-radius: 4px; pointer-events: none; } #char-export-gui button { background: #333; color: #fff; border: none; border-radius: 4px; padding: 8px 12px; transition: background 200ms ease, transform 200ms ease; } #char-export-gui button:hover:not(:disabled) { background: #444; transform: translateY(-1px); } #char-export-gui button:disabled { opacity: 0.6; cursor: not-allowed; } #char-export-gui button:focus { outline: none; } /* Enhanced input field styling */ #char-export-gui input[type="text"] { transition: border-color 300ms cubic-bezier(0.4, 0, 0.2, 1) !important; border-radius: 6px !important; } #char-export-gui input[type="text"]:not(.applying-changes):hover { border-color: #777 !important; } #char-export-gui input[type="text"]:not(.applying-changes):focus { border-color: #888 !important; } /* Ensure apply animation takes priority */ #char-export-gui input.applying-changes { border-color: #4CAF50 !important; } /* Disable all browser tooltips */ #char-export-gui input, #char-export-gui input:hover, #char-export-gui input:focus, #char-export-gui textarea, #char-export-gui textarea:hover, #char-export-gui textarea:focus { title: "" !important; } #char-export-gui input[title], #char-export-gui textarea[title] { title: "" !important; } /* Enhanced toggle container styling */ .toggle-wrapper { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; border-radius: 8px !important; } .toggle-wrapper:hover { transform: translateY(-1px) scale(1.005) !important; box-shadow: 0 6px 16px rgba(0,0,0,0.15) !important; background: linear-gradient(135deg, rgba(128, 128, 128, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%) !important; } .toggle-wrapper.active { border-color: rgba(0, 128, 255, 0.4) !important; box-shadow: 0 4px 12px rgba(0, 128, 255, 0.15) !important; } /* Token button styling */ #char-export-gui button[type="button"] { transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1) !important; } #char-export-gui button[type="button"]:hover { transform: translateY(-2px) scale(1.05) !important; box-shadow: 0 4px 12px rgba(0,0,0,0.2) !important; } /* Directory button styling */ .directory-button { transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1) !important; } .directory-button:hover { box-shadow: 0 0 8px rgba(74, 144, 226, 0.4) !important; background: #4a90e2 !important; } .directory-button:active { transform: scale(0.98) !important; } /* Custom overlay scrollbar - hide native scrollbar completely */ #settings-tab { scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE/Edge */ margin-right: -20px; padding-right: 20px; } #settings-tab::-webkit-scrollbar { display: none; /* Chrome/Safari */ } /* Custom scrollbar overlay container */ .custom-scrollbar-container { position: relative; } .custom-scrollbar-track { position: absolute; top: 0; right: -6px; width: 4px; height: 100%; background: rgba(68, 68, 68, 0.1); border-radius: 2px; opacity: 0.6; visibility: visible; transition: opacity 50ms ease, box-shadow 100ms ease; z-index: 1000; pointer-events: auto; } .custom-scrollbar-track.visible { opacity: 0.6; visibility: visible; } .custom-scrollbar-track:hover { opacity: 1; background: rgba(68, 68, 68, 0.2); box-shadow: 0 0 2px rgba(136, 136, 136, 0.2); } .custom-scrollbar-track.expanded { opacity: 1; background: rgba(68, 68, 68, 0.2); box-shadow: 0 0 4px rgba(136, 136, 136, 0.3); } .custom-scrollbar-thumb { position: absolute; top: 0; left: 0; width: 100%; background: rgba(136, 136, 136, 0.7); border-radius: 2px; border: none; transition: background 50ms ease; cursor: pointer; min-height: 20px; } .custom-scrollbar-thumb:hover { background: rgba(170, 170, 170, 0.8); } .custom-scrollbar-thumb:active, .custom-scrollbar-thumb.dragging { background: rgba(200, 200, 200, 0.9); } }`; document.head.appendChild(unselectStyle); const tabContainer = makeElement( "div", {}, { display: "flex", justifyContent: "center", marginBottom: "20px", borderBottom: "1px solid #444", paddingBottom: "12px", width: "100%", }, ); const tabsWrapper = makeElement( "div", {}, { display: "flex", justifyContent: "center", width: "100%", maxWidth: "min(400px, 100%)", margin: "0 auto", }, ); const createTabButton = (text, isActive) => { const button = makeElement( "button", { textContent: text, }, { background: "transparent", border: "none", color: "#fff", padding: "8px 20px", cursor: "pointer", margin: "0 5px", fontWeight: "bold", flex: "1", textAlign: "center", position: "relative", overflow: "hidden", transition: `opacity ${TAB_BUTTON_DURATION}ms ease, transform ${TAB_BUTTON_DURATION}ms ease, color ${TAB_BUTTON_DURATION}ms ease`, }, ); const indicator = makeElement( "div", {}, { position: "absolute", bottom: "0", left: "0", width: "100%", height: "2px", background: isActive ? ACTIVE_TAB_COLOR : INACTIVE_TAB_COLOR, transition: `transform ${TAB_BUTTON_DURATION}ms ease, background-color ${TAB_BUTTON_DURATION}ms ease`, }, ); if (!isActive) { button.style.opacity = "0.7"; indicator.style.transform = "scaleX(0.5)"; } button.appendChild(indicator); return { button, indicator, }; }; const { button: exportTab, indicator: exportIndicator } = createTabButton( "Export", true, ); const { button: settingsTab, indicator: settingsIndicator } = createTabButton("Settings", false); exportTab.onmouseover = () => { if (currentTab !== "export") { exportTab.style.opacity = "1"; exportTab.style.transform = "translateY(-2px)"; exportIndicator.style.transform = "scaleX(0.8)"; } }; exportTab.onmouseout = () => { if (currentTab !== "export") { exportTab.style.opacity = "0.7"; exportTab.style.transform = ""; exportIndicator.style.transform = "scaleX(0.5)"; } }; settingsTab.onmouseover = () => { if (currentTab !== "settings") { settingsTab.style.opacity = "1"; settingsTab.style.transform = "translateY(-2px)"; settingsIndicator.style.transform = "scaleX(0.8)"; } }; settingsTab.onmouseout = () => { if (currentTab !== "settings") { settingsTab.style.opacity = "0.7"; settingsTab.style.transform = ""; settingsIndicator.style.transform = "scaleX(0.5)"; } }; tabsWrapper.appendChild(exportTab); tabsWrapper.appendChild(settingsTab); tabContainer.appendChild(tabsWrapper); gui.appendChild(tabContainer); /* ========= Dynamic Tooltip ========= */ (() => { let tEl; const show = (target) => { const msg = target.getAttribute("data-tooltip"); if (!msg) return; if (!tEl) { tEl = document.createElement("div"); tEl.id = "char-export-tooltip"; tEl.id = "char-export-tooltip"; tEl.style.cssText = "position:fixed;padding:6px 10px;font-size:12px;background:#222;color:#fff;border-radius:4px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,0.4);opacity:0;transition:opacity 200ms ease,transform 200ms ease;z-index:10002;"; const offset = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offset}px)`; document.body.appendChild(tEl); } tEl.textContent = msg; const guiRect = gui.getBoundingClientRect(); const tgtRect = target.getBoundingClientRect(); tEl.style.top = tgtRect.top + tgtRect.height / 2 - tEl.offsetHeight / 2 + "px"; tEl.style.left = guiRect.right + 10 + "px"; requestAnimationFrame(() => { tEl.style.opacity = "1"; tEl.style.transform = "translateX(0)"; }); }; const hide = () => { if (!tEl) return; tEl.style.opacity = "0"; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offsetHide}px)`; }; gui.addEventListener("mouseover", (e) => { const tgt = e.target.closest("[data-tooltip]"); if (tgt) show(tgt); }); gui.addEventListener("mouseout", (e) => { const tgt = e.target.closest("[data-tooltip]"); if (tgt) hide(); }); window.addEventListener("keydown", (ev) => { hide(); if ((ev.key === "t" || ev.key === "T") && tEl) { tEl.style.opacity = "0"; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tEl.style.transform = `translateX(${offsetHide}px)`; } }); })(); const exportContent = makeElement( "div", { id: "export-tab", }, { maxHeight: "60vh", overflowY: "auto", padding: "0 5px 10px 0", display: "flex", flexDirection: "column", justifyContent: "center", }, ); const title = makeElement( "h1", { textContent: "Export Character Card", }, { margin: "-10px 0 25px 0", fontSize: "24px", paddingTop: "0px", textAlign: "center", fontWeight: "bold", color: "#fff", borderBottom: "2px solid #444", paddingBottom: "8px", }, ); exportContent.appendChild(title); const buttonContainer = makeElement( "div", {}, { display: "flex", gap: "12px", justifyContent: "center", marginBottom: "8px", marginTop: "12px", }, ); ["TXT", "PNG", "JSON"].forEach((format) => { const type = format.toLowerCase(); const button = makeElement( "button", { textContent: format, }, { background: BUTTON_COLOR, border: "none", color: "white", padding: "12px 24px", borderRadius: "8px", cursor: "pointer", fontWeight: "bold", position: "relative", overflow: "hidden", flex: "1", maxWidth: "min(120px, 30%)", transition: `all ${BUTTON_ANIMATION}ms ease`, boxShadow: "0 3px 6px rgba(0,0,0,0.15)", transform: "translateY(0)", fontSize: "14px", }, ); const shine = makeElement( "div", {}, { position: "absolute", top: "0", left: "0", width: "100%", height: "100%", background: "linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 60%)", transform: "translateX(-100%)", transition: `transform ${BUTTON_ANIMATION * 1.5}ms ease-out`, pointerEvents: "none", }, ); button.appendChild(shine); button.onmouseover = () => { button.style.background = BUTTON_HOVER_COLOR; button.style.transform = "translateY(-3px) scale(1.02)"; button.style.boxShadow = "0 6px 20px rgba(0,0,0,0.25)"; button.style.filter = "brightness(1.1)"; shine.style.transform = "translateX(100%)"; }; button.onmouseout = () => { button.style.background = BUTTON_COLOR; button.style.transform = "translateY(0) scale(1)"; button.style.boxShadow = "0 3px 6px rgba(0,0,0,0.15)"; button.style.filter = "brightness(1)"; shine.style.transform = "translateX(-100%)"; }; button.onmousedown = () => { button.style.transform = "translateY(0) scale(0.98)"; button.style.boxShadow = "0 2px 4px rgba(0,0,0,0.3)"; button.style.background = BUTTON_ACTIVE_COLOR; button.style.filter = "brightness(1.2)"; }; button.onmouseup = () => { button.style.transform = "translateY(-3px) scale(1.02)"; button.style.boxShadow = "0 6px 20px rgba(0,0,0,0.25)"; button.style.background = BUTTON_HOVER_COLOR; button.style.filter = "brightness(1.1)"; }; button.onclick = (e) => { const rect = button.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const ripple = makeElement( "div", {}, { position: "absolute", borderRadius: "50%", backgroundColor: "rgba(255,255,255,0.4)", width: "5px", height: "5px", transform: "scale(1)", opacity: "1", animation: "ripple 600ms linear", pointerEvents: "none", top: `${y}px`, left: `${x}px`, marginLeft: "-2.5px", marginTop: "-2.5px", }, ); button.appendChild(ripple); exportFormat = type; closeV(); extraction(); setTimeout(() => ripple.remove(), 600); }; buttonContainer.appendChild(button); }); if (!document.getElementById("char-export-style")) { const style = document.createElement("style"); style.id = "char-export-style"; style.textContent = ` @keyframes ripple { to { transform: scale(30); opacity: 0; pointer-events: none; } } `; document.head.appendChild(style); } // Add responsive design styles if (!document.getElementById("char-export-responsive")) { const responsiveStyle = document.createElement("style"); responsiveStyle.id = "char-export-responsive"; responsiveStyle.textContent = ` @media (max-width: 600px) { #char-export-gui { width: 95vw !important; height: 90vh !important; max-height: 90vh !important; padding: 10px 15px 5px !important; } #content-wrapper { min-height: 280px !important; } #export-tab, #settings-tab { padding: 10px !important; } .toggle-wrapper { flex-direction: column; align-items: flex-start !important; gap: 8px !important; } .toggle-wrapper span { order: 1 !important; padding-left: 0 !important; text-align: left !important; } .toggle-wrapper label { order: 2 !important; align-self: flex-end; } } @media (max-height: 600px) { #char-export-gui { max-height: 95vh !important; } } `; document.head.appendChild(responsiveStyle); } // Add scrollbar styling for settings if (!document.getElementById("char-export-settings-scroll")) { const settingsScrollStyle = document.createElement("style"); settingsScrollStyle.id = "char-export-settings-scroll"; settingsScrollStyle.textContent = ` #settings-tab { scrollbar-width: thin; scrollbar-color: #555 #2a2a2a; } #settings-tab::-webkit-scrollbar { width: 6px; } #settings-tab::-webkit-scrollbar-track { background: #2a2a2a; border-radius: 3px; } #settings-tab::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } #settings-tab::-webkit-scrollbar-thumb:hover { background: #666; } /* Enhanced hover effects */ .toggle-wrapper { transition: background 200ms ease; } .toggle-wrapper:hover { background: rgba(255, 255, 255, 0.02); } /* Enhanced section hover effects */ #settings-tab > div { transition: box-shadow 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94), border 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 300ms cubic-bezier(0.25, 0.46, 0.45, 0.94); border: 1px solid transparent; background: #2a2a2a; } #settings-tab > div:hover { box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2), 0 4px 12px rgba(0,0,0,0.15); border: 1px solid rgba(128, 128, 128, 0.3); transform: translateY(-2px) scale(1.01); background: linear-gradient(135deg, #2a2a2a 0%, #323232 100%); } /* Enhanced toggle hover effects */ .toggle-wrapper { transition: background 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94); border-radius: 8px; contain: layout style; isolation: isolate; } .toggle-wrapper:hover { background: linear-gradient(135deg, rgba(128, 128, 128, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); box-shadow: inset 0 0 0 1px rgba(128, 128, 128, 0.2); } /* Disable all tooltips inside UI */ #char-export-gui [data-tooltip]:hover::before, #char-export-gui [data-tooltip]:hover::after, #char-export-gui input:hover::before, #char-export-gui input:hover::after, #char-export-gui input[data-tooltip-disabled]:hover::before, #char-export-gui input[data-tooltip-disabled]:hover::after { display: none !important; opacity: 0 !important; } /* Slot machine animation overflow clipping */ .template-input-container { overflow: hidden !important; position: relative !important; } .template-apply-btn { will-change: transform, opacity, filter; } /* Applied text animation clipping */ .template-input-container span { will-change: transform, opacity, filter; user-select: none; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; } /* Disable browser tooltips on input */ #char-export-gui input[data-tooltip-disabled] { pointer-events: auto !important; } #char-export-gui input[data-tooltip-disabled]:hover::before, #char-export-gui input[data-tooltip-disabled]:hover::after { display: none !important; } `; document.head.appendChild(settingsScrollStyle); } if (!document.getElementById("char-export-tooltip-style")) { const tooltipStyle = document.createElement("style"); tooltipStyle.id = "char-export-tooltip-style"; tooltipStyle.textContent = ` [data-tooltip] { position: relative; } [data-tooltip]::before { content: ''; position: absolute; left: calc(100% + 4px); top: 50%; transform: translateY(-50%) scaleY(0.5); border: solid transparent; border-width: 6px 6px 6px 0; border-left-color: #333; opacity: 0; pointer-events: none; transition: opacity 150ms ease; z-index: 10001; } [data-tooltip]::after { content: attr(data-tooltip); position: absolute; left: calc(100% + 10px); top: 50%; transform: translateY(-50%) scale(0.8); background: #333; color: #fff; padding: 6px 10px; border-radius: 4px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease; z-index: 10001; } [data-tooltip]:hover::before, [data-tooltip]:hover::after { opacity: 1; transform: translateY(-50%) scale(1); } .toggle-wrapper { border-radius: 8px; transition: background 0.2s ease, box-shadow 0.2s ease; } .toggle-wrapper.active { background: transparent; box-shadow: none; } `; document.head.appendChild(tooltipStyle); if (!document.getElementById("char-export-scrollbar-style")) { const scrollStyle = document.createElement("style"); scrollStyle.id = "char-export-scrollbar-style"; scrollStyle.textContent = ` #content-wrapper { overflow: visible; } #settings-tab { overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; } #settings-tab::-webkit-scrollbar { width: 0; height: 0; } `; document.head.appendChild(scrollStyle); if (!document.getElementById("char-export-tooltip-override-style")) { const overrideStyle = document.createElement("style"); overrideStyle.id = "char-export-tooltip-override-style"; overrideStyle.textContent = ` [data-tooltip]::before { transform: translateY(-50%) translateX(-6px); transition: transform 200ms ease, opacity 200ms ease; } [data-tooltip]::after { transform: translateY(-50%) translateX(-10px); transition: transform 200ms ease, opacity 200ms ease; } [data-tooltip]:hover::before { transform: translateY(-50%) translateX(0); opacity:1; } [data-tooltip]:hover::after { transform: translateY(-50%) translateX(0); opacity:1; } [data-tooltip]::before,[data-tooltip]::after{display:none !important;} @keyframes toggle-pulse { 0% { transform: scale(1); } 50% { transform: scale(1.05); } 100% { transform: scale(1); } } .toggle-wrapper.pulse { animation: toggle-pulse 300ms ease; } .switch .slider-before { transition: transform 200ms ease, box-shadow 200ms ease; } .toggle-wrapper.active .slider-before { box-shadow: 0 0 2px rgba(0,0,0,0.2), 0 0 8px rgba(0,128,255,0.5); } .toggle-wrapper.active .slider { background-color: #0080ff; } `; document.head.appendChild(overrideStyle); } } } exportContent.appendChild(buttonContainer); const contentWrapper = makeElement( "div", { id: "content-wrapper", }, { minHeight: "320px", width: "100%", overflow: "visible", display: "flex", flexDirection: "column", justifyContent: "flex-start", position: "relative", flex: "1", }, ); gui.appendChild(contentWrapper); const tabContentStyles = { height: "100%", width: "100%", overflowY: "auto", overflowX: "hidden", padding: "15px", paddingTop: "10px", position: "absolute", top: "0", left: "0", opacity: "1", transform: "scale(1)", transition: `opacity ${TAB_ANIMATION_DURATION}ms ease, transform ${TAB_ANIMATION_DURATION}ms ease`, boxSizing: "border-box", }; Object.assign(exportContent.style, tabContentStyles); exportContent.style.overflowY = "visible"; exportContent.style.overflowX = "hidden"; exportContent.style.display = "flex"; exportContent.style.flexDirection = "column"; exportContent.style.alignItems = "center"; const settingsContent = makeElement( "div", { id: "settings-tab", style: "display: none;", }, tabContentStyles, ); contentWrapper.appendChild(exportContent); contentWrapper.appendChild(settingsContent); const savedExportScroll = parseInt( sessionStorage.getItem("char_export_scroll") || "0", 10, ); exportContent.scrollTop = savedExportScroll; const savedSettingsScroll = parseInt( sessionStorage.getItem("char_settings_scroll") || "0", 10, ); settingsContent.scrollTop = savedSettingsScroll; settingsContent.addEventListener("scroll", () => sessionStorage.setItem("char_settings_scroll", settingsContent.scrollTop), ); requestAnimationFrame(() => { exportContent.scrollTop = savedExportScroll; settingsContent.scrollTop = savedSettingsScroll; }); const settingsTitle = makeElement( "h1", { textContent: "Export Settings", }, { margin: "-10px 0 25px 0", fontSize: "24px", paddingTop: "0px", textAlign: "center", fontWeight: "bold", color: "#fff", borderBottom: "2px solid #444", paddingBottom: "12px", }, ); settingsContent.appendChild(settingsTitle); /** * Custom overlay scrollbar implementation * Creates a fully custom scrollbar that overlays the content * @param {HTMLElement} element - Target scrollable element */ const createCustomScrollbar = (element) => { let track, thumb; let isDragging = false; let startY = 0; let startScrollTop = 0; let isVisible = false; let hideTimeout; const createScrollbarElements = () => { // Create track track = makeElement("div", { className: "custom-scrollbar-track" }, {}); // Create thumb thumb = makeElement("div", { className: "custom-scrollbar-thumb" }, {}); track.appendChild(thumb); // Add to container const container = element.parentElement; if (container) { container.classList.add("custom-scrollbar-container"); container.style.position = "relative"; container.appendChild(track); } // Ensure completely clean state on creation track.classList.remove("expanded", "visible"); thumb.classList.remove("dragging", "scrolling"); track.style.opacity = "0.6"; thumb.style.transition = "background 50ms ease"; }; const updateThumbSize = () => { if (!track || !thumb) return; const containerHeight = element.clientHeight; const contentHeight = element.scrollHeight; const scrollRatio = containerHeight / contentHeight; if (scrollRatio >= 1 || contentHeight <= containerHeight) { track.classList.remove("visible"); track.style.visibility = "hidden"; isVisible = false; return; } track.classList.add("visible"); track.style.visibility = "visible"; isVisible = true; const thumbHeight = Math.max(20, containerHeight * scrollRatio); thumb.style.height = `${thumbHeight}px`; updateThumbPosition(); }; const updateThumbPosition = () => { if (!thumb || !isVisible) return; const containerHeight = element.clientHeight; const contentHeight = element.scrollHeight; const trackHeight = track.clientHeight; const thumbHeight = thumb.clientHeight; const maxThumbTop = trackHeight - thumbHeight; const scrollRatio = element.scrollTop / (contentHeight - containerHeight); const thumbTop = scrollRatio * maxThumbTop; thumb.style.transition = "none"; thumb.style.transform = `translateY(${thumbTop}px)`; requestAnimationFrame(() => { thumb.style.transition = "background 50ms ease"; }); }; const onThumbMouseDown = (e) => { e.preventDefault(); e.stopPropagation(); isDragging = true; startY = e.clientY; startScrollTop = element.scrollTop; thumb.classList.add("dragging"); track.classList.add("expanded"); document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", onMouseUp); document.body.style.userSelect = "none"; }; const onMouseMove = (e) => { if (!isDragging) return; const deltaY = e.clientY - startY; const trackHeight = track.clientHeight; const thumbHeight = thumb.clientHeight; const contentHeight = element.scrollHeight; const containerHeight = element.clientHeight; const scrollRange = contentHeight - containerHeight; const thumbRange = trackHeight - thumbHeight; const scrollRatio = deltaY / thumbRange; const scrollDelta = scrollRatio * scrollRange; element.scrollTop = Math.max( 0, Math.min(startScrollTop + scrollDelta, scrollRange), ); }; const onMouseUp = () => { isDragging = false; thumb.classList.remove("dragging"); track.classList.remove("expanded"); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", onMouseUp); document.body.style.userSelect = ""; }; const onTrackClick = (e) => { if (e.target === thumb) return; const trackRect = track.getBoundingClientRect(); const clickY = e.clientY - trackRect.top; const trackHeight = track.clientHeight; const thumbHeight = thumb.clientHeight; const containerHeight = element.clientHeight; const contentHeight = element.scrollHeight; const scrollRatio = (clickY - thumbHeight / 2) / (trackHeight - thumbHeight); const scrollTop = scrollRatio * (contentHeight - containerHeight); element.scrollTop = Math.max( 0, Math.min(scrollTop, contentHeight - containerHeight), ); }; const onScroll = () => { updateThumbPosition(); }; const onResize = () => { updateThumbSize(); }; const init = () => { createScrollbarElements(); // Mouse wheel support - just update position const onWheel = () => { updateThumbPosition(); }; // No special hover events needed - CSS handles it // Event listeners element.addEventListener("scroll", onScroll); element.addEventListener("wheel", onWheel, { passive: true }); thumb.addEventListener("mousedown", onThumbMouseDown); track.addEventListener("click", onTrackClick); // Resize observer for content changes const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(element); // No scroll position restoration // Force clean initial state with proper timing setTimeout(() => { track.classList.remove("expanded", "visible"); thumb.classList.remove("dragging", "scrolling"); track.style.opacity = "0.6"; thumb.style.transition = "background 50ms ease"; }, 0); // Initial setup - immediate and delayed for reliability updateThumbSize(); requestAnimationFrame(() => { updateThumbSize(); // Additional state reset after first frame track.classList.remove("expanded"); }); setTimeout(() => { updateThumbSize(); // Final state cleanup track.classList.remove("expanded"); }, 50); return { destroy: () => { element.removeEventListener("scroll", onScroll); element.removeEventListener("wheel", onWheel); if (thumb) thumb.removeEventListener("mousedown", onThumbMouseDown); if (track) { track.removeEventListener("click", onTrackClick); track.remove(); } resizeObserver.disconnect(); clearTimeout(element.scrollTimeout); }, }; }; return { init }; }; // Initialize custom scrollbar const settingsScrollbar = createCustomScrollbar(settingsContent); // Ensure scrollbar appears on first open setTimeout(() => { settingsScrollbar.init(); }, 10); // Filename Template Section const templateSection = makeElement( "div", {}, { marginTop: "20px", marginBottom: "15px", padding: "15px", background: "#2a2a2a", borderRadius: "10px", boxShadow: "0 2px 4px rgba(0,0,0,0.1)", transition: "all 200ms ease", cursor: "default", }, ); const templateTitle = makeElement( "h3", { textContent: "Filename", }, { margin: "0 0 15px 0", fontSize: "16px", color: "#fff", fontWeight: "bold", textAlign: "center", }, ); templateSection.appendChild(templateTitle); const templateInputContainer = makeElement( "div", { className: "template-input-container", }, { marginBottom: "12px", position: "relative", overflow: "hidden", }, ); const templateInput = makeElement( "input", { type: "text", value: filenameTemplate, placeholder: "Enter filename template", }, { width: "100%", padding: "10px 55px 10px 12px", border: "1px solid #555", borderRadius: "6px", background: "#1a1a1a", color: "#fff", fontSize: "13px", boxSizing: "border-box", transition: "border-color 200ms ease, box-shadow 200ms ease", outline: "none", }, ); // Disable tooltips completely templateInput.setAttribute("title", ""); templateInput.setAttribute("data-tooltip-disabled", "true"); templateInput.removeAttribute("title"); templateInput.addEventListener("mouseenter", (e) => { e.target.removeAttribute("title"); e.target.title = ""; }); // Override browser tooltip styles templateInput.style.setProperty("pointer-events", "auto", "important"); templateInput.addEventListener("mouseover", (e) => { e.preventDefault(); e.stopPropagation(); }); let hasChanges = filenameTemplate !== "" && filenameTemplate !== (localStorage.getItem("filenameTemplate") || "{name}"); let originalTemplate = localStorage.getItem("filenameTemplate") || "{name}"; // Auto-restore functionality const restoreTemplate = () => { const savedDraft = localStorage.getItem("filenameTemplateDraft"); if (savedDraft && savedDraft !== templateInput.value) { templateInput.value = savedDraft; filenameTemplate = savedDraft; hasChanges = filenameTemplate !== "" && filenameTemplate !== (localStorage.getItem("filenameTemplate") || "{name}"); // Update apply button state const applyBtn = templateSection.querySelector(".template-apply-btn"); if (applyBtn && hasChanges) { applyBtn.style.opacity = "1"; applyBtn.style.color = "#4CAF50"; applyBtn.style.filter = "drop-shadow(0 0 4px rgba(76, 175, 80, 0.6))"; } } }; templateInput.addEventListener("input", (e) => { filenameTemplate = e.target.value; localStorage.setItem("filenameTemplateDraft", filenameTemplate); hasChanges = filenameTemplate !== "" && filenameTemplate !== (localStorage.getItem("filenameTemplate") || "{name}"); // Update apply button state with smooth animation const applyBtn = templateSection.querySelector(".template-apply-btn"); if (applyBtn && !isAnimating) { applyBtn.style.transition = "all 200ms ease"; if (hasChanges) { applyBtn.style.opacity = "1"; applyBtn.style.color = "#4CAF50"; applyBtn.style.filter = "drop-shadow(0 0 4px rgba(76, 175, 80, 0.6))"; } else { applyBtn.style.opacity = "0.5"; applyBtn.style.color = "#666"; applyBtn.style.filter = "none"; } } }); // Restore on tab switch and UI open restoreTemplate(); let isEnterHeld = false; let isMouseHeld = false; templateInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !isEnterHeld) { isEnterHeld = true; e.preventDefault(); if (hasChanges && !isAnimating) { performApplyAction(); } else if (!hasChanges && !isAnimating) { startGrayHold(); } } }); templateInput.addEventListener("keyup", (e) => { if (e.key === "Enter") { isEnterHeld = false; if (!hasChanges && !isAnimating) { endGrayHold(); } } }); templateInput.addEventListener("mouseover", () => { if ( !isAnimating && document.activeElement !== templateInput && templateInput.style.borderColor !== "rgb(76, 175, 80)" ) { templateInput.style.borderColor = "#777"; } }); templateInput.addEventListener("mouseout", () => { if ( !isAnimating && document.activeElement !== templateInput && templateInput.style.borderColor !== "rgb(76, 175, 80)" ) { templateInput.style.borderColor = "#555"; } }); templateInput.addEventListener("focus", () => { if ( !isAnimating && templateInput.style.borderColor !== "rgb(76, 175, 80)" ) { templateInput.style.borderColor = "#888"; } }); templateInput.addEventListener("blur", () => { if ( !isAnimating && templateInput.style.borderColor !== "rgb(76, 175, 80)" ) { templateInput.style.borderColor = "#555"; } }); templateInputContainer.appendChild(templateInput); const applyButton = makeElement( "button", { textContent: "✓", className: "template-apply-btn", }, { position: "absolute", right: "8px", top: "50%", transform: "translateY(-50%)", background: "transparent", border: "none", color: "#666", fontSize: "14px", cursor: "pointer", padding: "4px", borderRadius: "3px", opacity: "0.5", transition: "all 200ms ease", }, ); let isAnimating = false; const performApplyAction = () => { if (!hasChanges || isAnimating) return; isAnimating = true; // Green glow for input box (slightly thicker but subtle) templateInput.classList.add("applying-changes"); templateInput.style.borderColor = "#4CAF50"; templateInput.style.boxShadow = "0 0 8px rgba(76, 175, 80, 0.5)"; // Show bright green glow before animation applyButton.style.color = "#2ecc71"; applyButton.style.filter = "drop-shadow(0 0 8px rgba(46, 204, 113, 0.8))"; applyButton.style.textShadow = "0 0 12px rgba(46, 204, 113, 0.9)"; setTimeout(() => { // Save current template localStorage.setItem("filenameTemplate", filenameTemplate); originalTemplate = filenameTemplate; hasChanges = false; // Slot machine animation sequence with proper overflow clipping // Phase 1: Checkmark slides up with fast acceleration (like slot reel) applyButton.style.transition = "all 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94)"; applyButton.style.transform = "translateY(-50%) translateY(-30px)"; applyButton.style.filter = "blur(4px)"; applyButton.style.opacity = "0"; setTimeout(() => { // Phase 2: Applied! slides up from bottom (like slot winning symbol) const appliedText = makeElement( "span", { textContent: "Applied!", }, { position: "absolute", right: "8px", top: "50%", transform: "translateY(-50%) translateY(25px)", color: "#4CAF50", fontSize: "11px", fontWeight: "bold", opacity: "0", transition: "all 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", pointerEvents: "none", whiteSpace: "nowrap", }, ); templateInputContainer.appendChild(appliedText); // Applied! slides into view with deceleration requestAnimationFrame(() => { appliedText.style.opacity = "1"; appliedText.style.transform = "translateY(-50%) translateY(0px)"; appliedText.style.filter = "blur(0px)"; }); // Phase 3: Applied! slides up and disappears (faster stay time) setTimeout(() => { appliedText.style.transition = "all 120ms cubic-bezier(0.25, 0.46, 0.45, 0.94)"; appliedText.style.transform = "translateY(-50%) translateY(-25px)"; appliedText.style.filter = "blur(3px)"; appliedText.style.opacity = "0"; // Phase 4: New checkmark slides up from bottom with glow setTimeout(() => { // Position new checkmark below input box ready to slide up applyButton.style.transition = "none"; applyButton.style.transform = "translateY(-50%) translateY(25px)"; applyButton.style.opacity = "0"; applyButton.style.color = "#aaa"; applyButton.style.filter = "blur(1px)"; requestAnimationFrame(() => { // Slide up with glow effect and color mixing applyButton.style.transition = "all 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275)"; applyButton.style.transform = "translateY(-50%) translateY(-2px)"; applyButton.style.opacity = "0.9"; applyButton.style.color = "#999"; applyButton.style.filter = "blur(0px) drop-shadow(0 0 6px rgba(153, 153, 153, 0.8)) hue-rotate(20deg)"; applyButton.style.textShadow = "0 0 10px rgba(200, 200, 200, 0.9)"; // Phase 5: Settle with subtle bounce and color normalization setTimeout(() => { // Check if user made changes during animation const currentChanges = filenameTemplate !== "" && filenameTemplate !== (localStorage.getItem("filenameTemplate") || "{name}"); applyButton.style.transition = "all 150ms ease-out"; applyButton.style.transform = "translateY(-50%) translateY(0px)"; if (currentChanges) { // Show green if changes exist applyButton.style.opacity = "1"; applyButton.style.color = "#4CAF50"; applyButton.style.filter = "drop-shadow(0 0 4px rgba(76, 175, 80, 0.6))"; } else { // Show gray if no changes applyButton.style.opacity = "0.5"; applyButton.style.color = "#666"; applyButton.style.filter = "none"; } applyButton.style.textShadow = "none"; // Reset input box border after delay setTimeout(() => { templateInput.classList.remove("applying-changes"); if (document.activeElement === templateInput) { templateInput.style.borderColor = "#888"; templateInput.style.boxShadow = "inset 0 0 0 1px rgba(136, 136, 136, 0.3)"; } else { templateInput.style.borderColor = "#555"; templateInput.style.boxShadow = "none"; } }, 100); // Clean up Applied! text appliedText.remove(); isAnimating = false; }, 250); }); }, 100); }, 500); // Shorter stay time for snappier feel }, 120); }, 50); }; const startGrayHold = () => { if (!hasChanges && !isAnimating) { // Hold down effect for gray checkmark applyButton.style.transition = "all 100ms ease"; applyButton.style.transform = "translateY(-50%) scale(0.9)"; applyButton.style.opacity = "0.3"; } }; const endGrayHold = () => { if (!hasChanges && !isAnimating) { // Release effect for gray checkmark applyButton.style.transition = "all 100ms ease"; applyButton.style.transform = "translateY(-50%) scale(1)"; applyButton.style.opacity = "0.5"; } }; const handleGrayClick = () => { if (!hasChanges && !isAnimating) { startGrayHold(); setTimeout(() => endGrayHold(), 100); } }; applyButton.addEventListener("mousedown", () => { if (hasChanges && !isAnimating) { // Green glow for input box on click too (slightly thicker but subtle) templateInput.classList.add("applying-changes"); templateInput.style.borderColor = "#4CAF50"; templateInput.style.boxShadow = "0 0 8px rgba(76, 175, 80, 0.5)"; performApplyAction(); } else if (!hasChanges && !isAnimating) { isMouseHeld = true; startGrayHold(); } }); applyButton.addEventListener("mouseup", () => { if (isMouseHeld) { isMouseHeld = false; endGrayHold(); } }); applyButton.addEventListener("mouseleave", () => { if (isMouseHeld) { isMouseHeld = false; endGrayHold(); } }); // Set initial apply button state if (hasChanges) { applyButton.style.opacity = "1"; applyButton.style.color = "#4CAF50"; } templateInputContainer.appendChild(applyButton); templateSection.appendChild(templateInputContainer); const tokenButtonsContainer = makeElement( "div", {}, { display: "flex", flexWrap: "wrap", gap: "4px", marginBottom: "8px", alignItems: "center", }, ); // Create token buttons const tokens = [{ token: "id", label: "ID" }, { token: "creator", label: "Creator" }, { token: "name", label: "Name" }, { token: "chat_name", label: "Chat Name" }, { token: "created", label: "Created" }, { token: "updated", label: "Updated" }, { token: "tags", label: "Tags" }, ]; tokens.forEach(({ token, label }) => { const button = createTokenButton(token, label, templateInput, false); tokenButtonsContainer.appendChild(button); }); templateSection.appendChild(tokenButtonsContainer); // Save Path Section const savePathSection = makeElement( "div", {}, { marginTop: "20px", marginBottom: "15px", padding: "15px", background: "#2a2a2a", borderRadius: "10px", boxShadow: "0 2px 4px rgba(0,0,0,0.1)", transition: "all 200ms ease", cursor: "default", }, ); const savePathTitle = makeElement( "h3", { textContent: "Save Path", }, { margin: "0 0 15px 0", fontSize: "16px", color: "#fff", fontWeight: "bold", textAlign: "center", }, ); savePathSection.appendChild(savePathTitle); const savePathInputContainer = makeElement( "div", { className: "savepath-input-container", }, { marginBottom: "12px", position: "relative", overflow: "hidden", }, ); // Update userChangedSavePath to current state userChangedSavePath = localStorage.getItem("userChangedSavePath") === "true"; // Always use default prefill if user hasn't applied changes yet let savePathTemplate = userChangedSavePath ? localStorage.getItem("savePathTemplate") || "" : "cards/{creator}"; const savePathInput = makeElement( "input", { type: "text", value: savePathTemplate, placeholder: "Leave empty to save to the root directory", }, { width: "100%", padding: "10px 55px 10px 12px", border: "1px solid #555", borderRadius: "6px", background: "#1a1a1a", color: "#fff", fontSize: "13px", boxSizing: "border-box", transition: "border-color 200ms ease, box-shadow 200ms ease", outline: "none", }, ); // Disable tooltips savePathInput.setAttribute("title", ""); savePathInput.setAttribute("data-tooltip-disabled", "true"); savePathInput.removeAttribute("title"); savePathInput.addEventListener("mouseenter", (e) => { e.target.removeAttribute("title"); e.target.title = ""; }); // Override browser tooltip styles savePathInput.style.setProperty("pointer-events", "auto", "important"); savePathInput.addEventListener("mouseover", (e) => { e.preventDefault(); e.stopPropagation(); }); let originalSavePathTemplate = userChangedSavePath ? localStorage.getItem("savePathTemplate") || "" : "cards/{creator}"; let savePathHasChanges = savePathTemplate !== originalSavePathTemplate; // Auto-restore functionality removed for Save Path savePathInput.addEventListener("input", (e) => { savePathTemplate = e.target.value; // Update userChangedSavePath state userChangedSavePath = localStorage.getItem("userChangedSavePath") === "true"; const currentOriginal = userChangedSavePath ? localStorage.getItem("savePathTemplate") || "" : "cards/{creator}"; savePathHasChanges = savePathTemplate !== currentOriginal; // Update apply button state const applyBtn = savePathSection.querySelector(".savepath-apply-btn"); if (applyBtn && !savePathIsAnimating) { applyBtn.style.transition = "all 200ms ease"; if (savePathHasChanges) { applyBtn.style.opacity = "1"; applyBtn.style.color = "#4CAF50"; applyBtn.style.filter = "drop-shadow(0 0 4px rgba(76, 175, 80, 0.6))"; } else { applyBtn.style.opacity = "0.5"; applyBtn.style.color = "#666"; applyBtn.style.filter = "none"; } } }); // restoreSavePath(); // Removed auto-restore for Save Path let savePathIsEnterHeld = false; let savePathIsMouseHeld = false; let savePathIsAnimating = false; savePathInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !savePathIsEnterHeld) { savePathIsEnterHeld = true; e.preventDefault(); if (savePathHasChanges && !savePathIsAnimating) { performSavePathApplyAction(); } else if (!savePathHasChanges && !savePathIsAnimating) { startSavePathGrayHold(); } } }); savePathInput.addEventListener("keyup", (e) => { if (e.key === "Enter") { savePathIsEnterHeld = false; if (!savePathHasChanges && !savePathIsAnimating) { endSavePathGrayHold(); } } }); savePathInput.addEventListener("mouseover", () => { if ( !savePathIsAnimating && document.activeElement !== savePathInput && savePathInput.style.borderColor !== "rgb(76, 175, 80)" ) { savePathInput.style.borderColor = "#777"; } }); savePathInput.addEventListener("mouseout", () => { if ( !savePathIsAnimating && document.activeElement !== savePathInput && savePathInput.style.borderColor !== "rgb(76, 175, 80)" ) { savePathInput.style.borderColor = "#555"; } }); savePathInput.addEventListener("focus", () => { if ( !savePathIsAnimating && savePathInput.style.borderColor !== "rgb(76, 175, 80)" ) { savePathInput.style.borderColor = "#888"; } }); savePathInput.addEventListener("blur", () => { if ( !savePathIsAnimating && savePathInput.style.borderColor !== "rgb(76, 175, 80)" ) { savePathInput.style.borderColor = "#555"; } }); savePathInputContainer.appendChild(savePathInput); const savePathApplyButton = makeElement( "button", { textContent: "✓", className: "savepath-apply-btn", }, { position: "absolute", right: "8px", top: "50%", transform: "translateY(-50%)", background: "transparent", border: "none", color: "#666", fontSize: "14px", cursor: "pointer", padding: "4px", borderRadius: "3px", opacity: "0.5", transition: "all 200ms ease", }, ); const performSavePathApplyAction = () => { if (!savePathHasChanges || savePathIsAnimating) return; savePathIsAnimating = true; savePathInput.classList.add("applying-changes"); savePathInput.style.borderColor = "#4CAF50"; savePathInput.style.boxShadow = "0 0 8px rgba(76, 175, 80, 0.5)"; savePathApplyButton.style.color = "#2ecc71"; savePathApplyButton.style.filter = "drop-shadow(0 0 8px rgba(46, 204, 113, 0.8))"; savePathApplyButton.style.textShadow = "0 0 12px rgba(46, 204, 113, 0.9)"; setTimeout(() => { localStorage.setItem("savePathTemplate", savePathTemplate); // Set boolean to true when user applies any changes to save path localStorage.setItem("userChangedSavePath", "true"); userChangedSavePath = true; // Update the variable immediately originalSavePathTemplate = savePathTemplate; savePathHasChanges = false; savePathApplyButton.style.transition = "all 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94)"; savePathApplyButton.style.transform = "translateY(-50%) translateY(-30px)"; savePathApplyButton.style.filter = "blur(4px)"; savePathApplyButton.style.opacity = "0"; setTimeout(() => { const appliedText = makeElement( "span", { textContent: "Applied!", }, { position: "absolute", right: "8px", top: "50%", transform: "translateY(-50%) translateY(25px)", color: "#4CAF50", fontSize: "11px", fontWeight: "bold", opacity: "0", transition: "all 150ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", pointerEvents: "none", whiteSpace: "nowrap", }, ); savePathInputContainer.appendChild(appliedText); requestAnimationFrame(() => { appliedText.style.opacity = "1"; appliedText.style.transform = "translateY(-50%) translateY(0px)"; appliedText.style.filter = "blur(0px)"; }); setTimeout(() => { appliedText.style.transition = "all 120ms cubic-bezier(0.25, 0.46, 0.45, 0.94)"; appliedText.style.transform = "translateY(-50%) translateY(-25px)"; appliedText.style.filter = "blur(3px)"; appliedText.style.opacity = "0"; setTimeout(() => { savePathApplyButton.style.transition = "none"; savePathApplyButton.style.transform = "translateY(-50%) translateY(25px)"; savePathApplyButton.style.opacity = "0"; savePathApplyButton.style.color = "#aaa"; savePathApplyButton.style.filter = "blur(1px)"; requestAnimationFrame(() => { savePathApplyButton.style.transition = "all 200ms cubic-bezier(0.175, 0.885, 0.32, 1.275)"; savePathApplyButton.style.transform = "translateY(-50%) translateY(-2px)"; savePathApplyButton.style.opacity = "0.9"; savePathApplyButton.style.color = "#999"; savePathApplyButton.style.filter = "blur(0px) drop-shadow(0 0 6px rgba(153, 153, 153, 0.8)) hue-rotate(20deg)"; savePathApplyButton.style.textShadow = "0 0 10px rgba(200, 200, 200, 0.9)"; setTimeout(() => { // Always set to gray state after applying since changes are now saved savePathApplyButton.style.transition = "all 150ms ease-out"; savePathApplyButton.style.transform = "translateY(-50%) translateY(0px)"; savePathApplyButton.style.opacity = "0.5"; savePathApplyButton.style.color = "#666"; savePathApplyButton.style.filter = "none"; savePathApplyButton.style.textShadow = "none"; setTimeout(() => { savePathInput.classList.remove("applying-changes"); if (document.activeElement === savePathInput) { savePathInput.style.borderColor = "#888"; savePathInput.style.boxShadow = "inset 0 0 0 1px rgba(136, 136, 136, 0.3)"; } else { savePathInput.style.borderColor = "#555"; savePathInput.style.boxShadow = "none"; } }, 100); appliedText.remove(); savePathIsAnimating = false; }, 250); }); }, 100); }, 500); }, 120); }, 50); }; const startSavePathGrayHold = () => { if (!savePathHasChanges && !savePathIsAnimating) { savePathApplyButton.style.transition = "all 100ms ease"; savePathApplyButton.style.transform = "translateY(-50%) scale(0.9)"; savePathApplyButton.style.opacity = "0.3"; } }; const endSavePathGrayHold = () => { if (!savePathHasChanges && !savePathIsAnimating) { savePathApplyButton.style.transition = "all 100ms ease"; savePathApplyButton.style.transform = "translateY(-50%) scale(1)"; savePathApplyButton.style.opacity = "0.5"; } }; savePathApplyButton.addEventListener("mousedown", () => { if (savePathHasChanges && !savePathIsAnimating) { savePathInput.classList.add("applying-changes"); savePathInput.style.borderColor = "#4CAF50"; savePathInput.style.boxShadow = "0 0 8px rgba(76, 175, 80, 0.5)"; performSavePathApplyAction(); } else if (!savePathHasChanges && !savePathIsAnimating) { savePathIsMouseHeld = true; startSavePathGrayHold(); } }); savePathApplyButton.addEventListener("mouseup", () => { if (savePathIsMouseHeld) { savePathIsMouseHeld = false; endSavePathGrayHold(); } }); savePathApplyButton.addEventListener("mouseleave", () => { if (savePathIsMouseHeld) { savePathIsMouseHeld = false; endSavePathGrayHold(); } }); if (savePathHasChanges) { savePathApplyButton.style.opacity = "1"; savePathApplyButton.style.color = "#4CAF50"; } savePathInputContainer.appendChild(savePathApplyButton); savePathSection.appendChild(savePathInputContainer); const savePathTokenButtonsContainer = makeElement( "div", {}, { display: "flex", flexWrap: "wrap", gap: "4px", marginBottom: "8px", alignItems: "center", }, ); tokens.forEach(({ token, label }) => { const button = createTokenButton(token, label, savePathInput, true); savePathTokenButtonsContainer.appendChild(button); }); savePathSection.appendChild(savePathTokenButtonsContainer); // FileSystemAccess API Toggle const useFileSystemToggle = createToggle( "useFileSystemAccess", "Use FileSystemAccess API", "Enable to use save path with directory picker. Disable for regular downloads.", localStorage.getItem("useFileSystemAccess") === "true", ); useFileSystemToggle.input.addEventListener("change", (e) => { const isEnabled = e.target.checked; localStorage.setItem("useFileSystemAccess", isEnabled); // Smooth animation with resume logic directorySection.style.transition = "all 400ms cubic-bezier(0.4, 0, 0.2, 1)"; if (isEnabled) { // Slide down animation directorySection.style.maxHeight = "35px"; directorySection.style.opacity = "1"; directorySection.style.paddingTop = "5px"; directorySection.style.paddingBottom = "5px"; directorySection.style.marginBottom = "5px"; directorySection.style.transform = "translateY(0)"; } else { // Slide up animation directorySection.style.maxHeight = "0"; directorySection.style.opacity = "0"; directorySection.style.paddingTop = "0"; directorySection.style.paddingBottom = "0"; directorySection.style.marginBottom = "0"; directorySection.style.transform = "translateY(-10px)"; } }); savePathSection.appendChild(useFileSystemToggle.container); // Directory Picker with smooth animation support const directorySection = makeElement( "div", {}, { marginTop: "5px", display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", overflow: "hidden", transition: "all 400ms cubic-bezier(0.4, 0, 0.2, 1)", maxHeight: localStorage.getItem("useFileSystemAccess") === "true" ? "35px" : "0", opacity: localStorage.getItem("useFileSystemAccess") === "true" ? "1" : "0", paddingTop: localStorage.getItem("useFileSystemAccess") === "true" ? "5px" : "0", paddingBottom: localStorage.getItem("useFileSystemAccess") === "true" ? "5px" : "0", marginBottom: localStorage.getItem("useFileSystemAccess") === "true" ? "5px" : "0", }, ); const directoryLabel = makeElement( "span", { textContent: "Directory", }, { fontSize: "13px", color: "#fff", fontWeight: "bold", }, ); const directoryRightSection = makeElement( "div", {}, { display: "flex", alignItems: "center", gap: "10px", }, ); const directoryName = makeElement( "span", { textContent: "Not selected", }, { fontSize: "12px", color: "#aaa", fontStyle: "italic", }, ); const directoryButton = makeElement( "button", { textContent: "Browse", className: "directory-button", }, { background: "#333", color: "#fff", border: "none", borderRadius: "6px", padding: "8px 16px", cursor: "pointer", fontSize: "12px", transition: "all 200ms ease", }, ); let selectedDirectory = null; // IndexedDB functions for directory handle persistence const DB_NAME = "DirectoryHandles"; const DB_VERSION = 1; const STORE_NAME = "handles"; async function storeDirectoryHandle(handle) { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }; request.onsuccess = (e) => { const db = e.target.result; const transaction = db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); const putRequest = store.put(handle, "selectedDirectory"); putRequest.onsuccess = () => resolve(); putRequest.onerror = () => reject(putRequest.error); }; request.onerror = () => reject(request.error); }); } async function getDirectoryHandle() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }; request.onsuccess = (e) => { const db = e.target.result; const transaction = db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const getRequest = store.get("selectedDirectory"); getRequest.onsuccess = () => resolve(getRequest.result); getRequest.onerror = () => reject(getRequest.error); }; request.onerror = () => reject(request.error); }); } // Restore directory selection on page load directoryName.textContent = "Not selected"; directoryName.style.color = "#aaa"; directoryName.style.fontStyle = "italic"; // Async function to restore directory handle (async () => { try { const storedHandle = await getDirectoryHandle(); if (storedHandle) { // Test if the handle is still valid try { const permission = await storedHandle.queryPermission({ mode: "readwrite", }); if (permission === "granted" || permission === "prompt") { selectedDirectory = storedHandle; window.selectedDirectoryHandle = storedHandle; directoryName.textContent = storedHandle.name; directoryName.style.color = "#4CAF50"; directoryName.style.fontStyle = "normal"; console.log( "[Directory] Restored from IndexedDB:", storedHandle.name, ); } else { console.log("[Directory] Handle exists but permission denied"); } } catch (error) { console.log("[Directory] Handle invalid, removing from storage"); // Handle is invalid, remove it const request = indexedDB.open(DB_NAME, DB_VERSION); request.onsuccess = (e) => { const db = e.target.result; const transaction = db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); store.delete("selectedDirectory"); }; } } } catch (error) { console.log("[Directory] Error restoring from IndexedDB:", error); } })(); directoryButton.addEventListener("click", async () => { if ("showDirectoryPicker" in window) { try { selectedDirectory = await window.showDirectoryPicker(); window.selectedDirectoryHandle = selectedDirectory; // Store globally for save function directoryName.textContent = selectedDirectory.name; directoryName.style.color = "#4CAF50"; directoryName.style.fontStyle = "normal"; // Persist directory handle in IndexedDB storeDirectoryHandle(selectedDirectory) .then(() => { console.log( "[Directory] Stored in IndexedDB:", selectedDirectory.name, ); }) .catch((error) => { console.log("[Directory] Failed to store in IndexedDB:", error); }); } catch (err) { console.log("Directory picker was cancelled or failed:", err); } } else { alert("FileSystemAccess API is not supported in this browser."); } }); directoryRightSection.appendChild(directoryName); directoryRightSection.appendChild(directoryButton); directorySection.appendChild(directoryLabel); directorySection.appendChild(directoryRightSection); savePathSection.appendChild(directorySection); settingsContent.appendChild(savePathSection); settingsContent.appendChild(templateSection); // Export Options Section const exportOptionsSection = makeElement( "div", {}, { marginTop: "20px", marginBottom: "15px", padding: "15px", background: "#2a2a2a", borderRadius: "10px", boxShadow: "0 2px 4px rgba(0,0,0,0.1)", transition: "all 200ms ease", cursor: "default", }, ); const exportOptionsTitle = makeElement( "h3", { textContent: "Export Options", }, { margin: "0 0 15px 0", fontSize: "16px", color: "#fff", fontWeight: "bold", }, ); exportOptionsSection.appendChild(exportOptionsTitle); // Use character's chat name toggle const chatNameToggle = createToggle( "useChatNameForName", "Use character's chat name", "Uses chat name for the character name instead of label name.", useChatNameForName, ); chatNameToggle.input.addEventListener("change", (e) => { useChatNameForName = e.target.checked; }); exportOptionsSection.appendChild(chatNameToggle.container); // Apply {{char}} tokenization toggle const charTokenToggle = createToggle( "applyCharToken", "Apply {{char}} tokenization", "Toggle replacement of character names with {{char}} placeholder.", applyCharToken, ); charTokenToggle.input.addEventListener("change", (e) => { applyCharToken = e.target.checked; }); exportOptionsSection.appendChild(charTokenToggle.container); // Include Creator Notes toggle const creatorNotesToggle = createToggle( "includeCreatorNotes", "Include creator notes", "Include creator notes in exported files", localStorage.getItem("includeCreatorNotes") !== "false", ); exportOptionsSection.appendChild(creatorNotesToggle.container); settingsContent.appendChild(exportOptionsSection); // Advanced Section const advancedSection = makeElement( "div", {}, { marginTop: "20px", marginBottom: "15px", padding: "15px", background: "#2a2a2a", borderRadius: "10px", boxShadow: "0 2px 4px rgba(0,0,0,0.1)", transition: "all 200ms ease", cursor: "default", }, ); const advancedTitle = makeElement( "h3", { textContent: "Advanced", }, { margin: "0 0 15px 0", fontSize: "16px", color: "#fff", fontWeight: "bold", }, ); advancedSection.appendChild(advancedTitle); // Show Debug Logs toggle const debugLogsToggle = createToggle( "showDebugLogs", "Debug logs", "Show verbose console logging for debugging", localStorage.getItem("showDebugLogs") === "true", ); advancedSection.appendChild(debugLogsToggle.container); settingsContent.appendChild(advancedSection); const tabs = { export: { content: exportContent, tab: exportTab, active: true, }, settings: { content: settingsContent, tab: settingsTab, active: false, }, }; function switchTab(tabKey) { const previousTab = currentActiveTab; currentActiveTab = tabKey; // Save filename draft and clean up toggle states when switching AWAY from settings if (previousTab === "settings" && tabKey !== "settings") { const templateInput = document.querySelector( 'input[placeholder="Enter filename template"]', ); if (templateInput && templateInput.value) { localStorage.setItem("filenameTemplateDraft", templateInput.value); } } // Too many flaws, will fix the toggle state visual restoration later // The commented code below is the original (flawed version) logic for restoring toggle states // // Clean up any lingering toggle visual states // document.querySelectorAll(".toggle-wrapper").forEach((wrapper) => { // const container = wrapper.closest("div"); // if (container) { // // Reset any stuck styles and classes // container.style.transform = ""; // container.style.borderColor = ""; // container.style.boxShadow = ""; // container.className = container.className.replace( // /toggle-container-(active|inactive)/g, // "", // ); // } // }); // } // Only restore when switching TO settings (not from settings) if (tabKey === "settings" && previousTab !== "settings") { // Immediate restoration before settings tab loads inputStateManager.restoreAll(false); // Re-initialize scrollbar when switching to settings with proper cleanup requestAnimationFrame(() => { // Clean up any existing scrollbar state const existingTrack = settingsContent.parentElement?.querySelector( ".custom-scrollbar-track", ); if (existingTrack) { existingTrack.remove(); } const newScrollbar = createCustomScrollbar(settingsContent); newScrollbar.init(); // Restore complete toggle visual states document.querySelectorAll(".toggle-wrapper").forEach((wrapper) => { const input = wrapper.querySelector('input[type="checkbox"]'); const container = wrapper.closest("div"); const slider = wrapper.querySelector(".slider"); const sliderBefore = wrapper.querySelector(".slider-before"); if (input && container && slider && sliderBefore) { const isChecked = input.checked; // // Clear any existing state classes and inline styles // container.style.borderColor = ""; // container.style.boxShadow = ""; // container.style.transform = ""; // container.className = container.className.replace( // /toggle-container-(active|inactive)/g, // "", // ); // // Apply correct state class // container.classList.add( // isChecked // ? "toggle-container-active" // : "toggle-container-inactive", // ); // Restore slider styles slider.style.backgroundColor = isChecked ? "#0080ff" : "#ccc"; slider.style.boxShadow = isChecked ? "0 0 8px rgba(0, 128, 255, 0.3)" : "none"; // Restore slider before styles sliderBefore.style.transform = isChecked ? "translateX(16px)" : "translateX(0)"; sliderBefore.style.boxShadow = isChecked ? "0 0 2px rgba(0,0,0,0.2), 0 0 8px rgba(0,128,255,0.5)" : "0 0 2px rgba(0,0,0,0.2)"; // Restore wrapper class // wrapper.classList.toggle("active", isChecked); } }); }); // Use requestAnimationFrame to ensure DOM is ready for draft restoration requestAnimationFrame(() => { const templateInput = document.querySelector( 'input[placeholder="Enter filename template"]', ); if (templateInput) { const savedDraft = localStorage.getItem("filenameTemplateDraft"); if (savedDraft && savedDraft !== templateInput.value) { templateInput.value = savedDraft; filenameTemplate = savedDraft; templateInput.dispatchEvent( new Event("input", { bubbles: true }), ); } } // Auto-restore removed for Save Path input }); } animationTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); animationTimeouts = []; Object.entries(tabs).forEach(([key, { content, tab }]) => { const isActive = key === tabKey; tab.style.opacity = isActive ? "1" : "0.7"; tab.style.transform = isActive ? "translateY(-2px)" : ""; const indicator = tab.lastChild; if (indicator) { if (isActive) { indicator.style.background = ACTIVE_TAB_COLOR; indicator.style.transform = "scaleX(1)"; } else { indicator.style.background = INACTIVE_TAB_COLOR; indicator.style.transform = "scaleX(0.5)"; } } content.style.display = "block"; content.style.pointerEvents = isActive ? "auto" : "none"; if (isActive) { content.style.opacity = "0"; content.style.transform = "scale(0.95)"; void content.offsetWidth; requestAnimationFrame(() => { content.style.opacity = "1"; content.style.transform = "scale(1)"; }); } else { requestAnimationFrame(() => { content.style.opacity = "0"; content.style.transform = "scale(0.95)"; }); const hideTimeout = setTimeout(() => { if (!tabs[key].active) { content.style.display = "none"; // Clean up scrollbar when hiding settings tab if (key === "settings") { const existingTrack = content.parentElement?.querySelector( ".custom-scrollbar-track", ); if (existingTrack) { existingTrack.remove(); } } } }, TAB_ANIMATION_DURATION); animationTimeouts.push(hideTimeout); } tabs[key].active = isActive; }); currentTab = tabKey; try { sessionStorage.setItem("lastActiveTab", tabKey); } catch (e) { console.warn("Failed to save tab state to sessionStorage", e); } } const handleTabClick = (e) => { const tt = document.getElementById("char-export-tooltip"); if (tt) { tt.style.opacity = "0"; const offsetHide = TOOLTIP_SLIDE_FROM_RIGHT ? -TOOLTIP_SLIDE_OFFSET : TOOLTIP_SLIDE_OFFSET; tt.style.transform = `translateX(${offsetHide}px)`; } const tabKey = e.target === exportTab ? "export" : "settings"; if (!tabs[tabKey].active) { switchTab(tabKey); } }; exportTab.onclick = handleTabClick; settingsTab.onclick = handleTabClick; Object.entries(tabs).forEach(([key, { content }]) => { const isActive = key === currentTab; content.style.display = isActive ? "block" : "none"; content.style.opacity = isActive ? "1" : "0"; content.style.transform = isActive ? "scale(1)" : "scale(0.95)"; }); switchTab(currentTab); document.body.appendChild(gui); // Add backdrop with smooth linear animation const backdrop = makeElement( "div", { id: "char-export-backdrop", }, { position: "fixed", top: "0", left: "0", width: "100%", height: "100%", background: "rgba(0, 0, 0, 0)", backdropFilter: "blur(0px)", zIndex: "9999", opacity: "0", transition: "all 150ms ease-out", }, ); document.body.insertBefore(backdrop, gui); // Set initial modal state gui.style.opacity = "0"; gui.style.transform = "translate(-50%, -50%) scale(0.95)"; gui.style.filter = "blur(3px)"; gui.style.transition = "all 150ms ease-out"; // Start backdrop animation smoothly - no instant fill requestAnimationFrame(() => { backdrop.style.opacity = "1"; backdrop.style.background = "rgba(0, 0, 0, 0.4)"; backdrop.style.backdropFilter = "blur(3px)"; gui.style.opacity = "1"; gui.style.transform = "translate(-50%, -50%) scale(1)"; gui.style.filter = "blur(0px) drop-shadow(0 15px 35px rgba(0,0,0,0.3))"; }); document.addEventListener("mousedown", handleDialogOutsideClick); document.addEventListener("mouseup", handleDialogOutsideClick); } function toggleUIState() { // Clear any existing animations animationTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); animationTimeouts = []; if (guiElement && document.body.contains(guiElement)) { if (viewActive) { // If reopening while closing, get current state and resume const backdrop = document.getElementById("char-export-backdrop"); const currentGuiOpacity = parseFloat(getComputedStyle(guiElement).opacity) || 0; const currentBackdropOpacity = backdrop ? parseFloat(getComputedStyle(backdrop).opacity) || 0 : 0; // Resume opening animation from current state - ensure smooth animation guiElement.style.display = "flex"; // Force a reflow to ensure display change is applied void guiElement.offsetHeight; requestAnimationFrame(() => { guiElement.style.transition = "all 150ms ease-out"; guiElement.style.opacity = "1"; guiElement.style.transform = "translate(-50%, -50%) scale(1)"; guiElement.style.filter = "blur(0px) drop-shadow(0 15px 35px rgba(0,0,0,0.3))"; if (backdrop) { backdrop.style.transition = "all 150ms ease-out"; backdrop.style.opacity = "1"; backdrop.style.background = "rgba(0, 0, 0, 0.4)"; backdrop.style.backdropFilter = "blur(3px)"; } }); } else { const backdrop = document.getElementById("char-export-backdrop"); // Get current animation state to resume from const currentGuiOpacity = parseFloat(getComputedStyle(guiElement).opacity) || 1; const currentBackdropOpacity = backdrop ? parseFloat(getComputedStyle(backdrop).opacity) || 1 : 0; // Calculate current scale from transform const transformMatrix = getComputedStyle(guiElement).transform; let currentScale = 1; if (transformMatrix !== "none") { const values = transformMatrix.match(/-?\d+\.?\d*/g); if (values && values.length >= 6) { currentScale = parseFloat(values[0]); // Get scale from matrix } } // Apply smooth exit transition with proper timing requestAnimationFrame(() => { guiElement.style.transition = "all 120ms ease-in"; guiElement.style.opacity = "0"; guiElement.style.transform = "translate(-50%, -50%) scale(0.9)"; guiElement.style.filter = "blur(4px)"; if (backdrop) { backdrop.style.transition = "all 120ms ease-in"; backdrop.style.opacity = "0"; backdrop.style.background = "rgba(0, 0, 0, 0)"; backdrop.style.backdropFilter = "blur(0px)"; } }); const removeTimeout = setTimeout(() => { if (!viewActive && guiElement && document.body.contains(guiElement)) { document.body.removeChild(guiElement); const backdrop = document.getElementById("char-export-backdrop"); if (backdrop) backdrop.remove(); document.removeEventListener("mousedown", handleDialogOutsideClick); document.removeEventListener("mouseup", handleDialogOutsideClick); guiElement = null; } }, 140); animationTimeouts.push(removeTimeout); } } else if (viewActive) { createUI(); } } /** * Centralized input state manager with immediate restoration * Handles DOM queries, variable sync, and immediate updates with retry logic * @param {boolean} force - Force restoration regardless of current value * @param {number} retryCount - Internal retry counter for DOM queries */ function createInputStateManager() { const SELECTORS = { filename: 'input[placeholder="Enter filename template"]', }; const DEFAULTS = { filename: "{name}", }; const STATE_KEYS = { filename: "filenameTemplate", }; function restoreInput(type, force = false, retryCount = 0) { const input = document.querySelector(SELECTORS[type]); if (!input) { // Retry with RAF if element not found (during tab transitions) if (retryCount < 5) { requestAnimationFrame(() => restoreInput(type, force, retryCount + 1), ); } return; } const savedValue = localStorage.getItem(STATE_KEYS[type]) || DEFAULTS[type]; const needsRestore = force || input.value.trim() === ""; if (needsRestore) { input.value = savedValue; // Sync global variables immediately if (type === "filename") { window.filenameTemplate = savedValue; filenameTemplate = savedValue; } // Trigger input event for UI updates input.dispatchEvent(new Event("input", { bubbles: true })); } } return { restoreAll: (force = false) => { if (restorationInProgress) return; restorationInProgress = true; restoreInput("filename", force); // Reset flag after completion setTimeout(() => { restorationInProgress = false; }, 50); }, restoreFilename: (force = false) => restoreInput("filename", force), }; } const inputStateManager = createInputStateManager(); function closeV() { if (!viewActive) return; // Save filename template draft before closing const templateInput = document.querySelector( 'input[placeholder="Enter filename template"]', ); if (templateInput && templateInput.value) { localStorage.setItem("filenameTemplateDraft", templateInput.value); } // Restore IMMEDIATELY before any close logic or animations inputStateManager.restoreAll(false); // Use RAF to ensure restoration completes before UI animations requestAnimationFrame(() => { viewActive = false; toggleUIState(); }); } let isMouseDownInside = false; function handleDialogOutsideClick(e) { const gui = document.getElementById("char-export-gui"); const backdrop = document.getElementById("char-export-backdrop"); if (e.type === "mousedown") { isMouseDownInside = gui && gui.contains(e.target); } else if (e.type === "mouseup") { const isMouseUpOutside = !gui || !gui.contains(e.target); if (!isMouseDownInside && isMouseUpOutside) { closeV(); } isMouseDownInside = false; } } /* ============================ == INTERCEPTORS == ============================ */ function interceptNetwork() { if (networkInterceptActive) return; networkInterceptActive = true; const origXHR = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { this.addEventListener("load", () => { if (url.includes("generateAlpha")) modifyResponse(this.responseText); if (url.includes("/hampter/chats/")) modifyChatResponse(this.responseText); }); return origXHR.apply(this, arguments); }; const origFetch = window.fetch; window.fetch = function(input, init) { const url = typeof input === "string" ? input : input?.url; if (url && (url.includes("skibidi.com") || url.includes("proxy"))) { if (shouldInterceptNext && exportFormat) { setTimeout(() => modifyResponse("{}"), 300); return Promise.resolve(new Response("{}")); } return Promise.resolve( new Response( JSON.stringify({ error: "Service unavailable", }), ), ); } try { return origFetch.apply(this, arguments).then((res) => { if (res.url?.includes("generateAlpha")) res.clone().text().then(modifyResponse); if (res.url?.includes("/hampter/chats/")) res.clone().text().then(modifyChatResponse); return res; }); } catch (e) { return Promise.resolve(new Response("{}")); } }; } function modifyResponse(text) { if (!shouldInterceptNext) return; shouldInterceptNext = false; try { const json = JSON.parse(text); const sys = json.messages.find((m) => m.role === "system")?.content || ""; let initMsg = ""; if (chatData?.chatMessages?.length) { const msgs = chatData.chatMessages; initMsg = msgs[msgs.length - 1].message; } const header = document.querySelector("p.chakra-text.css-1nj33dt"); const headerName = header?.textContent .match(/Chat with\s+(.*)$/)?.[1] ?.trim(); const fullName = ( chatData?.character?.chat_name || chatData?.character?.name || "" ).trim(); const nameFirst = (chatData?.character?.name || "") .trim() .split(/\s+/)[0]; const charName = fullName || nameFirst || headerName || "char"; let charBlock = extractTagContent(sys, fullName); if (!charBlock) { charBlock = extractTagContent(sys, nameFirst || charName); } const scen = extractTagContent(sys, "scenario"); const rawExs = extractTagContent(sys, "example_dialogs"); const exs = rawExs.replace( /^\s*Example conversations between[^:]*:\s*/, "", ); const userName = document.documentElement.innerHTML.match( /\\"name\\":\\"([^\\"]+)\\"/, )?.[1] || ""; switch (exportFormat) { case "txt": { saveAsTxt(charBlock, scen, initMsg, exs, charName, userName); break; } case "png": { saveAsPng(charName, charBlock, scen, initMsg, exs, userName); break; } case "json": { saveAsJson(charName, charBlock, scen, initMsg, exs, userName); break; } } exportFormat = null; } catch (err) { console.error("Error processing response:", err); } } function modifyChatResponse(text) { try { if (!text || typeof text !== "string" || !text.trim()) return; const data = JSON.parse(text); if (data && data.character) { chatData = data; } } catch (err) { // ignore parsing errors } } /* ============================ == CORE LOGIC == ============================ */ async function getCharacterMeta() { // ---------- BEGIN Method 1 helpers ---------- const findCharacter = (obj) => { if (!obj || typeof obj !== "object") return null; if ("showdefinition" in obj && "name" in obj && "id" in obj) return obj; for (const key of Object.keys(obj)) { const res = findCharacter(obj[key]); if (res) return res; } return null; }; const extractFromJson = (json) => { if (!json) return null; const charObj = findCharacter(json); if (!charObj) { if (localStorage.getItem("showDebugLogs") === "true") { console.log("[getCharacterMeta] Method 1: no character object found"); } return null; } if (localStorage.getItem("showDebugLogs") === "true") { console.log( "[getCharacterMeta] Method 1: character object located, showdefinition=", charObj.showdefinition, ); } const { name: rawName = "", chat_name: chatName = "", description: creatorNotesRaw = "", creator = {}, personality: personalityRaw = "", scenario: scenarioRaw = "", first_message: firstMsgRaw = "", example_dialogs: exDialogsRaw = "", showdefinition = false, id = "", } = charObj; if (!showdefinition) return null; const name = ( chatName && !useChatNameForName ? rawName : chatName || rawName ).trim(); let characterVersion = getCharacterCardUrl(id); if (chatName && !useChatNameForName) { characterVersion += `\nChat Name: ${chatName.trim()}`; } // Verbose logging for Method 1 (JSON.parse when showdefinition=true) if (localStorage.getItem("showDebugLogs") === "true") { const src = "JSON.parse (showdefinition=true)"; console.log("[getCharacterMeta] name", name, `<= ${src}`); console.log( "[getCharacterMeta] personality", personalityRaw, `<= ${src}`, ); console.log("[getCharacterMeta] scenario", scenarioRaw, `<= ${src}`); console.log( "[getCharacterMeta] first_message", firstMsgRaw, `<= ${src}`, ); console.log( "[getCharacterMeta] example_dialogs", exDialogsRaw, `<= ${src}`, ); console.log( "[getCharacterMeta] creator_notes", creatorNotesRaw, `<= ${src}`, ); } return { characterVersion, characterCardUrl: getCharacterCardUrl(id), name, creatorNotes: creatorNotesRaw, personality: stripWatermark(personalityRaw), scenario: stripWatermark(scenarioRaw), firstMessage: stripWatermark(firstMsgRaw), exampleDialogs: stripWatermark(exDialogsRaw), definitionExposed: true, }; }; // ---------- END Method 1 helpers ---------- // ---------- BEGIN Method 2 helpers (chatData fallback) ---------- // Method 2 (showdefinition false) reads directly from chatData for character information. // No additional helper functions are required for this path. // ---------- END Method 2 helpers ---------- const charId = chatData?.character?.id; if (!charId) return { creatorUrl: "", characterVersion: "", characterCardUrl: "", name: "", creatorNotes: "", personality: "", scenario: "", firstMessage: "", exampleDialogs: "", definitionExposed: false, }; // Check cache first - return cached values unless debug logging is enabled const skipCache = localStorage.getItem("showDebugLogs") === "true"; if ( !skipCache && characterMetaCache.id === charId && characterMetaCache.useChatNameForName === useChatNameForName ) { if (localStorage.getItem("showDebugLogs") === "true") { console.log( "[getCharacterMeta] Returning cached result for character:", charId, ); } return { creatorUrl: characterMetaCache.creatorUrl || "", characterVersion: characterMetaCache.characterVersion || "", characterCardUrl: characterMetaCache.characterCardUrl || "", name: characterMetaCache.name || "", creatorNotes: characterMetaCache.creatorNotes || "", personality: characterMetaCache.personality || "", scenario: characterMetaCache.scenario || "", firstMessage: characterMetaCache.firstMessage || "", exampleDialogs: characterMetaCache.exampleDialogs || "", definitionExposed: characterMetaCache.definitionExposed || false, }; } const characterCardUrl = getCharacterCardUrl(charId); let meta = { ...blankMeta, characterVersion: characterCardUrl, characterCardUrl, }; try { const response = await fetch(characterCardUrl); const html = await response.text(); const doc = new DOMParser().parseFromString(html, "text/html"); // ---------- BEGIN Method 1 execution ---------- let metaFromJson = null; try { if (localStorage.getItem("showDebugLogs") === "true") { console.log("[getCharacterMeta] Method 1: scanning <script> tags"); } const scripts = Array.from(doc.querySelectorAll("script")); for (const s of scripts) { const txt = s.textContent || ""; if (!txt.includes("window.mbxM.push(JSON.parse(")) continue; const m = txt.match(/JSON\.parse\(\s*("([\s\S]*?)")\s*\)/); if (!m || !m[1]) continue; let innerStr; try { innerStr = JSON.parse(m[1]); } catch (_) { continue; } let obj; try { obj = typeof innerStr === "string" ? JSON.parse(innerStr) : innerStr; } catch (_) { continue; } metaFromJson = extractFromJson(obj); if (metaFromJson) break; } } catch (parseErr) { console.error( "[getCharacterMeta] JSON parse error (Method 1):", parseErr, ); } // ---------- END Method 1 execution ---------- Object.assign(meta, metaFromJson || {}, { creatorUrl: getCreatorUrlFromDoc(doc), creatorNotes: chatData?.character?.description || "", }); } catch (_) {} // Debug logging before cache assignment (shows every time when enabled) if (localStorage.getItem("showDebugLogs") === "true") { console.log( "[getCharacterMeta] creator_url", meta.creatorUrl, "<= getCreatorUrlFromDoc", ); if (!meta.definitionExposed) { const src = "generateAlpha/chatData (showdefinition=false)"; console.log( "[getCharacterMeta] name", meta.name || chatData?.character?.name || chatData?.character?.chat_name, `<= ${src}`, ); console.log( "[getCharacterMeta] personality", chatData?.character?.personality || "extracted from generateAlpha", `<= ${src}`, ); console.log( "[getCharacterMeta] scenario", chatData?.character?.scenario || "extracted from generateAlpha", `<= ${src}`, ); console.log( "[getCharacterMeta] first_message", chatData?.character?.first_message || "extracted from generateAlpha", `<= ${src}`, ); console.log( "[getCharacterMeta] example_dialogs", chatData?.character?.example_dialogs || "extracted from generateAlpha", `<= ${src}`, ); console.log( "[getCharacterMeta] creator_notes", chatData?.character?.description, `<= ${src}`, ); } } Object.assign(characterMetaCache, { id: charId, useChatNameForName, ...meta, }); return meta; } async function buildTemplate(charBlock, scen, initMsg, exs) { const sections = []; const { creatorUrl, characterCardUrl, creatorNotes } = await getCharacterMeta(); const includeCreatorNotes = localStorage.getItem("includeCreatorNotes") !== "false"; const realName = chatData.character.name.trim(); sections.push(`==== Name ====\n${realName}`); const chatName = (chatData.character.chat_name || realName).trim(); sections.push(`==== Chat Name ====\n${chatName}`); if (charBlock) sections.push(`==== Description ====\n${charBlock.trim()}`); if (scen) sections.push(`==== Scenario ====\n${scen.trim()}`); if (initMsg) sections.push(`==== Initial Message ====\n${initMsg.trim()}`); if (exs) sections.push(`==== Example Dialogs ====\n${exs.trim()}`); sections.push(`==== Character Card ====\n${characterCardUrl}`); sections.push(`==== Creator ====\n${creatorUrl}`); if (includeCreatorNotes && creatorNotes) sections.push(`==== Creator Notes ====\n${creatorNotes}`); return sections.join("\n\n"); } /** * Extracts the updated date from character page HTML * @param {Document} doc - Parsed HTML document from character page * @returns {string} - Date in format MM/DD/YYYY or empty string if not found */ function extractUpdatedDate(doc) { try { const dateElement = doc.querySelector(".chakra-text.css-722v25"); if (dateElement && dateElement.textContent) { return dateElement.textContent.trim(); } } catch (err) { console.warn("[extractUpdatedDate] Failed to extract date:", err); } return ""; } /** * Builds filename from template using available tokens * @param {string} template - Template string with tokens like {id}, {name}, etc. * @param {Object} tokens - Available token values * @returns {string} - Processed filename */ function buildFilenameFromTemplate(template, tokens = {}) { if (!template || typeof template !== "string") { return tokens.name || tokens.chat_name || "card"; } if (!template || template.trim() === "") { return ""; } let result = template; // Step 1: Create unique placeholders for double brackets to prevent interference const placeholders = {}; Object.entries(tokens).forEach(([key, value]) => { if ( value !== undefined && value !== null && String(value).trim() !== "" ) { const placeholder = `__TEMP_DOUBLE_${key}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}__`; placeholders[placeholder] = `{${String(value)}}`; const doubleBracketRegex = new RegExp( `\\{\\{${escapeRegExp(key)}\\}\\}`, "g", ); result = result.replace(doubleBracketRegex, placeholder); } }); // Step 2: Handle single brackets normally Object.entries(tokens).forEach(([key, value]) => { if ( value !== undefined && value !== null && String(value).trim() !== "" ) { const singleBracketRegex = new RegExp( `\\{${escapeRegExp(key)}\\}`, "g", ); result = result.replace(singleBracketRegex, String(value)); } }); // Step 3: Replace placeholders with final double bracket values Object.entries(placeholders).forEach(([placeholder, finalValue]) => { result = result.replace( new RegExp(escapeRegExp(placeholder), "g"), finalValue, ); }); // Clean up any remaining unreplaced tokens only if they don't have values result = result.replace(/\{\{[^}]+\}\}/g, ""); result = result.replace(/\{[^}]+\}/g, ""); // Clean up multiple spaces and trim result = result.replace(/\s+/g, " ").trim(); // Return actual result or fallback to "export" only if completely empty return result || "export"; } /** * Gets all available tokens for filename template * @param {Object} meta - Character metadata from getCharacterMeta * @returns {Object} - Object with all available tokens */ async function getFilenameTokens(meta) { const charId = chatData?.character?.id || ""; let creatorName = meta?.creatorUrl ? meta.creatorUrl.split("/").pop() || "" : ""; // Extract data from character JSON let updatedDate = ""; let createdDate = ""; // Extract all data from character JSON let tagsString = ""; try { if (meta?.characterCardUrl) { const response = await fetch(meta.characterCardUrl); const html = await response.text(); const doc = new DOMParser().parseFromString(html, "text/html"); const scripts = Array.from(doc.querySelectorAll("script")); for (const script of scripts) { const text = script.textContent || ""; if (text.includes("window.mbxM.push(JSON.parse(")) { const match = text.match(/JSON\.parse\(\s*("([\s\S]*?)")\s*\)/); if (match && match[1]) { try { let innerStr = JSON.parse(match[1]); let obj = typeof innerStr === "string" ? JSON.parse(innerStr) : innerStr; const debugEnabled = localStorage.getItem("showDebugLogs") === "true"; if (debugEnabled) { console.log("[getFilenameTokens] Found JSON object:", obj); } // Find the character store (nested structure) let characterData = null; for (const key in obj) { if (key.includes("characterStore") && obj[key]?.character) { characterData = obj[key].character; break; } } if (!characterData) { if (debugEnabled) { console.log( "[getFilenameTokens] No character data found in nested structure", ); } continue; } if (debugEnabled) { console.log( "[getFilenameTokens] Found character data:", characterData, ); } // Extract tags from character section only (including custom_tags) const allTags = []; if (characterData?.tags) { const regularTags = characterData.tags .filter((tag) => typeof tag.id === "number" && tag.id >= 2) .map((tag) => tag.name); allTags.push(...regularTags); } if (characterData?.custom_tags) { allTags.push(...characterData.custom_tags); } // Remove duplicates, filter out unwanted emojis, and format properly const uniqueTags = allTags .filter((name, index, arr) => arr.indexOf(name) === index) .map((tag) => { // Remove unwanted emojis and clean up let cleanTag = tag .replace(/🦰/g, "") .replace(/_{2,}/g, "_") .replace(/\s{2,}/g, " ") .trim(); // Handle emoji + text format if (/^[\p{Emoji}]/u.test(cleanTag)) { // Handle complex emojis like "❤️ 🔥 Smut" -> "❤️🔥_Smut" // First, combine consecutive emojis without spaces between them cleanTag = cleanTag.replace( /([\p{Emoji}\uFE0F]+)\s+([\p{Emoji}\uFE0F]+)/gu, "$1$2", ); // Then replace the space after emoji group with underscore cleanTag = cleanTag.replace( /([\p{Emoji}\uFE0F]+)\s+/u, "$1_", ); // Remove any remaining spaces cleanTag = cleanTag.replace(/\s/g, ""); } else { // For non-emoji tags, just remove spaces cleanTag = cleanTag.replace(/\s/g, ""); } return cleanTag; }) .filter((tag) => tag.length > 0); tagsString = uniqueTags.join(" "); if (debugEnabled) { console.log( "[getFilenameTokens] Extracted tags:", tagsString, ); } // Extract dates and creator from character JSON if (characterData?.created_at) { const dateStr = characterData.created_at.split("T")[0]; const [year, month, day] = dateStr.split("-"); createdDate = `${parseInt(month)}-${parseInt(day)}-${year}`; if (debugEnabled) { console.log( "[getFilenameTokens] Extracted created date:", createdDate, ); } } if (characterData?.updated_at) { const dateStr = characterData.updated_at.split("T")[0]; const [year, month, day] = dateStr.split("-"); updatedDate = `${parseInt(month)}-${parseInt(day)}-${year}`; if (debugEnabled) { console.log( "[getFilenameTokens] Extracted updated date:", updatedDate, ); } } if (characterData?.creator_name) { creatorName = characterData.creator_name; if (debugEnabled) { console.log( "[getFilenameTokens] Extracted creator name:", creatorName, ); } } break; } catch (parseError) { console.warn( "[getFilenameTokens] JSON parse error:", parseError, ); continue; } } } } } } catch (err) { console.warn("[getFilenameTokens] Failed to extract from JSON:", err); } return { id: charId, creator: creatorName, name: meta?.name || chatData?.character?.name || "", chat_name: chatData?.character?.chat_name || "", created: createdDate, updated: updatedDate, tags: tagsString, }; } async function saveAsTxt(charBlock, scen, initMsg, exs, charName, userName) { const template = await buildTemplate(charBlock, scen, initMsg, exs); const tokenized = tokenizeNames(template, charName, userName); // Use filename template system const meta = await getCharacterMeta(); const tokens = await getFilenameTokens(meta); const currentTemplate = localStorage.getItem("filenameTemplate") || "{name}"; const fileName = buildFilenameFromTemplate(currentTemplate, tokens) || "export"; saveFile( `${fileName}.txt`, new Blob([tokenized], { type: "text/plain", }), ); } function tokenizeField(text, charName, userName) { if (!text) return text; const esc = (n) => escapeRegExp(n); const rules = []; if (applyCharToken && charName) { rules.push([ new RegExp(`\\b${esc(charName)}('s)?\\b`, "gi"), (_, sfx) => `{{char}}${sfx || ""}`, ]); } if (userName) { rules.push([ new RegExp(`\\b${esc(userName)}('s)?\\b`, "gi"), (_, sfx) => `{{user}}${sfx || ""}`, ]); } let out = rules.reduce((t, [rx, repl]) => t.replace(rx, repl), text); if (!applyCharToken && charName) { out = out.replace(/\{\{char\}\}/gi, charName); } return out; } /* ============================ == INTERCEPTORS == ============================ */ function extraction() { if (!exportFormat) return; if ( !document.querySelector("span.css-yhlqn1") && !document.querySelector("span.css-154nobl") ) return; (async () => { const meta = await getCharacterMeta(); // Method 1 if (meta.definitionExposed) { const charName = meta.name; const userName = document.documentElement.innerHTML.match( /\\"name\\":\\"([^\\"]+)\\"/, )?.[1] || ""; switch (exportFormat) { case "txt": saveAsTxt( meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, charName, userName, ); break; case "png": saveAsPng( charName, meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, userName, ); break; case "json": saveAsJson( charName, meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs, userName, ); break; } exportFormat = null; return; } shouldInterceptNext = true; interceptNetwork(); callApi(); })(); } function callApi() { try { const textarea = document.querySelector("textarea"); if (!textarea) return; Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, "value", ).set.call(textarea, "extract-char"); textarea.dispatchEvent( new Event("input", { bubbles: true, }), ); ["keydown", "keyup"].forEach((type) => textarea.dispatchEvent( new KeyboardEvent(type, { key: "Enter", code: "Enter", bubbles: true, }), ), ); } catch (err) { // ignore errors } } /* ============================ == CHARA CARD V2 == ============================ */ async function buildCharaCardV2( charName, charBlock, scen, initMsg, exs, userName, ) { const { creatorUrl, characterVersion, name: metaName, creatorNotes, } = await getCharacterMeta(); // Tokenize relevant fields const tokenizedDesc = tokenizeField(charBlock, charName, userName); const tokenizedScen = tokenizeField(scen, charName, userName); const tokenizedExs = tokenizeField(exs, charName, userName); /* ---------------------- Resolve display name ---------------------- */ let displayName; if (useChatNameForName) { // Prefer chat_name when toggle is enabled displayName = ( chatData?.character?.chat_name || metaName || chatData?.character?.name || "" ).trim(); } else { // Prefer canonical name first displayName = ( metaName || chatData?.character?.name || chatData?.character?.chat_name || "" ).trim(); } if (!displayName) displayName = "Unknown"; /* -------------------- Build version text -------------------- */ let versionText = characterVersion; if (useChatNameForName) { const canonicalName = ( metaName || chatData?.character?.name || "" ).trim(); if (canonicalName && canonicalName !== displayName) { versionText = `${characterVersion}\nName: ${canonicalName}`; } } const includeCreatorNotes = localStorage.getItem("includeCreatorNotes") !== "false"; return { spec: "chara_card_v2", spec_version: "2.0", data: { name: displayName, description: tokenizedDesc.trim(), personality: "", scenario: tokenizedScen.trim(), first_mes: initMsg.trim(), mes_example: tokenizedExs.trim(), creator_notes: includeCreatorNotes ? creatorNotes : "", system_prompt: "", post_history_instructions: "", alternate_greetings: [], character_book: null, tags: [], creator: creatorUrl, character_version: versionText, extensions: {}, }, }; } /* ============================ == EXPORTERS == ============================ */ async function saveAsJson(charName, charBlock, scen, initMsg, exs, userName) { const jsonData = await buildCharaCardV2( charName, charBlock, scen, initMsg, exs, userName, ); // Use filename template system const meta = await getCharacterMeta(); const tokens = await getFilenameTokens(meta); const currentTemplate = localStorage.getItem("filenameTemplate") || "{name}"; const fileName = buildFilenameFromTemplate(currentTemplate, tokens) || "export"; saveFile( `${fileName}.json`, new Blob([JSON.stringify(jsonData, null, 2)], { type: "application/json", }), ); } async function saveAsPng(charName, charBlock, scen, initMsg, exs, userName) { try { const avatarImg = document.querySelector('img[src*="/bot-avatars/"]'); if (!avatarImg) { alert("Character avatar not found."); return; } const cardData = await buildCharaCardV2( charName, charBlock, scen, initMsg, exs, userName, ); const avatarResponse = await fetch(avatarImg.src); const avatarBlob = await avatarResponse.blob(); const img = new Image(); img.onload = () => { const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0); canvas.toBlob(async (blob) => { try { const arrayBuffer = await blob.arrayBuffer(); const pngData = new Uint8Array(arrayBuffer); const jsonString = JSON.stringify(cardData); const base64Data = btoa(unescape(encodeURIComponent(jsonString))); const keyword = "chara"; const keywordBytes = new TextEncoder().encode(keyword); const nullByte = new Uint8Array([0]); const textBytes = new TextEncoder().encode(base64Data); const chunkType = new Uint8Array([116, 69, 88, 116]); // "tEXt" in ASCII const dataLength = keywordBytes.length + nullByte.length + textBytes.length; const lengthBytes = new Uint8Array(4); lengthBytes[0] = (dataLength >>> 24) & 0xff; lengthBytes[1] = (dataLength >>> 16) & 0xff; lengthBytes[2] = (dataLength >>> 8) & 0xff; lengthBytes[3] = dataLength & 0xff; const crcData = new Uint8Array( chunkType.length + keywordBytes.length + nullByte.length + textBytes.length, ); crcData.set(chunkType, 0); crcData.set(keywordBytes, chunkType.length); crcData.set(nullByte, chunkType.length + keywordBytes.length); crcData.set( textBytes, chunkType.length + keywordBytes.length + nullByte.length, ); const crc = computeCrc32(crcData, 0, crcData.length); const crcBytes = new Uint8Array(4); crcBytes[0] = (crc >>> 24) & 0xff; crcBytes[1] = (crc >>> 16) & 0xff; crcBytes[2] = (crc >>> 8) & 0xff; crcBytes[3] = crc & 0xff; let pos = 8; // Skip PNG signature while (pos < pngData.length - 12) { const length = (pngData[pos] << 24) | (pngData[pos + 1] << 16) | (pngData[pos + 2] << 8) | pngData[pos + 3]; const type = String.fromCharCode( pngData[pos + 4], pngData[pos + 5], pngData[pos + 6], pngData[pos + 7], ); if (type === "IEND") break; pos += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC) } const finalSize = pngData.length + lengthBytes.length + chunkType.length + dataLength + crcBytes.length; const finalPNG = new Uint8Array(finalSize); finalPNG.set(pngData.subarray(0, pos)); let writePos = pos; finalPNG.set(lengthBytes, writePos); writePos += lengthBytes.length; finalPNG.set(chunkType, writePos); writePos += chunkType.length; finalPNG.set(keywordBytes, writePos); writePos += keywordBytes.length; finalPNG.set(nullByte, writePos); writePos += nullByte.length; finalPNG.set(textBytes, writePos); writePos += textBytes.length; finalPNG.set(crcBytes, writePos); writePos += crcBytes.length; finalPNG.set(pngData.subarray(pos), writePos); // Use filename template system const meta = await getCharacterMeta(); const tokens = await getFilenameTokens(meta); const currentTemplate = localStorage.getItem("filenameTemplate") || "{name}"; const fileName = buildFilenameFromTemplate(currentTemplate, tokens) || "export"; saveFile( `${fileName}.png`, new Blob([finalPNG], { type: "image/png", }), ); console.log("Character card created successfully!"); } catch (err) { console.error("Error creating PNG:", err); alert("Failed to create PNG: " + err.message); } }, "image/png"); }; img.src = URL.createObjectURL(avatarBlob); } catch (err) { console.error("Error creating PNG:", err); alert("Failed to create PNG: " + err.message); } } function computeCrc32(data, start, length) { let crc = 0xffffffff; for (let i = 0; i < length; i++) { const byte = data[start + i]; crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xff]; } return ~crc >>> 0; // Invert and cast to unsigned 32-bit } const crc32Table = (() => { const table = new Uint32Array(256); for (let i = 0; i < 256; i++) { let crc = i; for (let j = 0; j < 8; j++) { crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; } table[i] = crc; } return table; })(); /* ============================ == ROUTING == ============================ */ function inChats() { const isInChat = /^\/chats\/\d+/.test(window.location.pathname); return isInChat; } function initialize() { if (hasInitialized || !inChats()) return; hasInitialized = true; shouldInterceptNext = false; networkInterceptActive = false; exportFormat = null; chatData = null; // Attempt to prefetch chat data before UI makes its own request (disabled) // prefetchChatData(); document.removeEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown); interceptNetwork(); } function handleKeyDown(e) { if (!inChats()) return; if ( e.key.toLowerCase() !== "t" || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey ) return; if ( ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) || document.activeElement.isContentEditable ) return; if (!chatData || !chatData.character || !chatData.character.allow_proxy) { if (chatData && chatData.character) { alert("Proxy disabled — extraction aborted."); } return; } viewActive = !viewActive; toggleUIState(); } function cleanup() { hasInitialized = false; const gui = document.getElementById("char-export-gui"); if (gui) gui.remove(); document.removeEventListener("keydown", handleKeyDown); viewActive = false; animationTimeouts.forEach((timeoutId) => clearTimeout(timeoutId)); animationTimeouts = []; } function handleRoute() { if (inChats()) { initialize(); } else { cleanup(); } } /* ============================ == EVENT LISTENERS == ============================ */ // Initialize unified auto-restore system function initializeAutoRestore() { // Debounced restoration for window events to prevent conflicts const debouncedRestore = () => { if (!restorationInProgress) { inputStateManager.restoreAll(false); } }; // Multiple event listeners for comprehensive coverage ["beforeunload", "pagehide"].forEach((event) => { window.addEventListener(event, debouncedRestore); }); // Separate handler for visibility changes window.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { debouncedRestore(); } }); } // Initialize the auto-restore system initializeAutoRestore(); // Enhanced tooltip prevention document.addEventListener("DOMContentLoaded", () => { const style = document.createElement("style"); style.textContent = ` /* Disable all browser tooltips inside the UI */ #char-export-gui input, #char-export-gui input:hover, #char-export-gui input:focus, #char-export-gui textarea, #char-export-gui textarea:hover, #char-export-gui textarea:focus { title: "" !important; } #char-export-gui input[title], #char-export-gui textarea[title] { title: "" !important; } /* Enhanced toggle animations */ .toggle-wrapper { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; } .toggle-wrapper:hover { transform: translateY(-1px) scale(1.005) !important; box-shadow: 0 6px 16px rgba(0,0,0,0.15) !important; } .toggle-wrapper.active { border-color: rgba(0, 128, 255, 0.4) !important; box-shadow: 0 4px 12px rgba(0, 128, 255, 0.15) !important; } `; document.head.appendChild(style); }); /* ============================ == ENTRYPOINT == ============================ */ window.addEventListener( "load", () => { handleRoute(); }, { once: true, }, ); window.addEventListener("popstate", () => { handleRoute(); }); ["pushState", "replaceState"].forEach((fn) => { const orig = history[fn]; history[fn] = function(...args) { const res = orig.apply(this, args); setTimeout(handleRoute, 50); return res; }; }); handleRoute(); })();