// ==UserScript==
// @name JanitorAI Character Card Scraper
// @version 4.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
// @inject-into auto
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
function runInPageContext() {
"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;
if (localStorage.getItem("useChatNameForName") === null) {
useChatNameForName = true; // default for first-time users
localStorage.setItem("useChatNameForName", "true");
} else {
useChatNameForName = localStorage.getItem("useChatNameForName") === "true";
}
let applyCharToken;
if (localStorage.getItem("applyCharToken") === null) {
applyCharToken = false; // default for first-time users
localStorage.setItem("applyCharToken", "false");
} else {
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;
let settingsScrollbar = null;
let cachedUserName = "";
let cachedProxyUrl = "";
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,
};
let jaiStateObserver;
/**
* @param {HTMLScriptElement} scriptNode
*/
function extractAndCacheInitialData(scriptNode) {
if (cachedUserName && cachedProxyUrl) {
if (jaiStateObserver) {
jaiStateObserver.disconnect();
}
return;
}
try {
const match = scriptNode.textContent.match(/JSON\.parse\("([\s\S]*?)"\)/);
if (match && match[1]) {
const storeState = JSON.parse(JSON.parse(`"${match[1]}"`));
const profile = storeState?.user?.profile;
if (profile?.name && !cachedUserName) {
cachedUserName = profile.name.trim();
}
const config = storeState?.user?.config;
if (config?.open_ai_reverse_proxy && !cachedProxyUrl) {
cachedProxyUrl = config.open_ai_reverse_proxy;
}
if (cachedUserName && cachedProxyUrl && jaiStateObserver) {
jaiStateObserver.disconnect();
}
}
} catch (e) {}
}
// STRATEGY 1: Immediate Scan
// Look for the data right away in case it's already on the page.
document.querySelectorAll('script').forEach(script => {
if (script.textContent.includes('window._storeState_')) {
extractAndCacheInitialData(script);
}
});
// STRATEGY 2: Asynchronous Watcher
// Set up an observer to catch the data if it's added to the page later.
jaiStateObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.tagName === 'SCRIPT' && node.textContent.includes('window._storeState_')) {
extractAndCacheInitialData(node);
}
}
}
});
// Start observing the entire page. The helper function will handle disconnecting.
jaiStateObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
interceptNetwork();
/* ============================
== UTILITIES ==
============================ */
function debugLog(...args) {
if (localStorage.getItem("showDebugLogs") === "true") {
console.log(...args);
}
}
function debugWarn(...args) {
if (localStorage.getItem("showDebugLogs") === "true") {
console.warn(...args);
}
}
function debugError(...args) {
if (localStorage.getItem("showDebugLogs") === "true") {
console.error(...args);
}
}
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 {
const directoryHandle = window.selectedDirectoryHandle;
if (directoryHandle) {
const userChangedSavePath =
localStorage.getItem("userChangedSavePath") === "true";
const savePathTemplate = userChangedSavePath ?
localStorage.getItem("savePathTemplate") || "" :
"cards/{creator}";
let savePath = savePathTemplate;
debugLog("[DEBUG] Original save path template:", savePathTemplate);
debugLog("[DEBUG] Available chatData:", chatData);
const meta = await getCharacterMeta();
const tokens = await getFilenameTokens(meta);
debugLog("[DEBUG] Available tokens:", tokens);
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) {
debugLog(`[DEBUG] Replaced {${tokenKey}} with:`, tokenValue);
}
}
});
debugLog("[DEBUG] Final save path:", savePath);
savePath = savePath.replace(/^\/+|\/+$/g, "");
const pathSegments = savePath ?
savePath.split("/").filter((segment) => segment.length > 0) : [];
let currentDir = directoryHandle;
for (const segment of pathSegments) {
try {
currentDir = await currentDir.getDirectoryHandle(segment, {
create: true,
});
} catch (error) {
debugError(
`Failed to create/access directory ${segment}:`,
error,
);
regularDownload(filename, blob);
return;
}
}
let finalFilename = filename;
let counter = 1;
let fileHandle;
while (true) {
try {
await currentDir.getFileHandle(finalFilename);
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) {
break;
}
}
fileHandle = await currentDir.getFileHandle(finalFilename, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
const successMessage = `Saved to: ${pathSegments.length > 0 ? pathSegments.join("/") + "/" : ""}${finalFilename}`;
debugLog(successMessage);
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) {
debugError("FileSystemAccess API failed:", error);
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);
}
}
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() {
const direct = window.location.href.match(/\/chats\/(\d+)/);
if (direct) return direct[1];
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() {
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);
if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) {
debugLog("[getAuthHeader] using raw JWT");
return `Bearer ${raw}`;
}
try {
const json = JSON.parse(atob(raw));
if (json && json.access_token) {
debugLog("[getAuthHeader] using access_token from JSON");
return `Bearer ${json.access_token}`;
}
} catch (err) {
/* ignore */
}
return null;
}
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) {
if (/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(raw)) {
return `Bearer ${raw}`;
}
}
return null;
}
function getAppVersion() {
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];
}
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();
debugLog("[prefetchChatData] auth:", auth);
const baseHeaders = {
"x-app-version": appVer,
accept: "application/json, text/plain, */*",
};
if (auth) baseHeaders["Authorization"] = auth;
debugLog("[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;
debugLog("[prefetchChatData] chatData pre-fetched");
}
}
} catch (err) {
debugWarn("[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],
)
) {
// Always tokenize {{user}} with the corrected regex
if (uRx) {
const userRegex = new RegExp(`\\b${uRx}('s?)?(?!\\w)`, "gi");
parts[i] = parts[i].replace(userRegex, (match, suffix) => {
return `{{user}}${suffix || ""}`;
});
}
// Conditionally tokenize {{char}} based on the toggle
if (applyCharToken) {
if (cRx) {
const charRegex = new RegExp(`\\b${cRx}('s?)?(?!\\w)`, "gi");
parts[i] = parts[i].replace(charRegex, (match, suffix) => {
return `{{char}}${suffix || ""}`;
});
}
} 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;
let out = text;
const esc = (n) => escapeRegExp(n);
// Always apply {{user}} tokenization with the corrected regex
if (userName) {
const userRegex = new RegExp(`\\b${esc(userName)}('s?)?(?!\\w)`, "gi");
out = out.replace(userRegex, (match, suffix) => `{{user}}${suffix || ""}`);
}
// Conditionally apply {{char}} tokenization
if (applyCharToken && charName) {
const charRegex = new RegExp(`\\b${esc(charName)}('s?)?(?!\\w)`, "gi");
out = out.replace(charRegex, (match, suffix) => `{{char}}${suffix || ""}`);
} else if (charName) {
// De-tokenize if setting is off
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) {
// Get the actual value from localStorage, with defaultValue as fallback
const storedValue = localStorage.getItem(key);
const actualValue = storedValue !== null ? storedValue === "true" : defaultValue;
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",
position: "relative",
},
);
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: actualValue ? ACTIVE_TAB_COLOR : "#ccc",
transition: `all ${TOGGLE_ANIMATION}ms cubic-bezier(0.34, 1.56, 0.64, 1)`,
borderRadius: "24px",
overflow: "hidden",
boxShadow: actualValue ? "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: actualValue ? "translateX(16px)" : "translateX(0)",
boxShadow: actualValue ?
"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: actualValue,
"data-key": key, // Store key for easy access
}, {
opacity: "0",
width: "0",
height: "0",
position: "absolute",
},
);
input.addEventListener("change", (e) => {
const isChecked = e.target.checked;
localStorage.setItem(key, isChecked);
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);
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);
// Set initial visual state based on actualValue
if (actualValue) {
wrapper.classList.add("active");
container.style.borderColor = "rgba(0, 128, 255, 0.4)";
container.style.boxShadow = "0 4px 12px rgba(0, 128, 255, 0.15)";
}
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);
// Add reset icon
const resetButton = makeElement(
"button", {
innerHTML: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>`,
title: "Reset to default",
}, {
position: "absolute",
right: "10px",
top: "50%",
transform: "translateY(-50%)",
background: "transparent",
border: "none",
color: "#666",
cursor: "pointer",
padding: "4px",
borderRadius: "4px",
transition: "all 200ms ease",
opacity: "0.5",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
);
resetButton.addEventListener("mouseenter", () => {
resetButton.style.opacity = "1";
resetButton.style.color = "#fff";
resetButton.style.background = "rgba(255, 255, 255, 0.1)";
});
resetButton.addEventListener("mouseleave", () => {
resetButton.style.opacity = "0.5";
resetButton.style.color = "#666";
resetButton.style.background = "transparent";
});
resetButton.addEventListener("click", (e) => {
e.stopPropagation();
// Set to default value
input.checked = defaultValue;
localStorage.setItem(key, defaultValue);
// Update visual state
slider.style.backgroundColor = defaultValue ? ACTIVE_TAB_COLOR : "#ccc";
slider.style.boxShadow = defaultValue ?
"0 0 8px rgba(0, 128, 255, 0.3)" :
"none";
sliderBefore.style.transform = defaultValue ?
"translateX(16px)" :
"translateX(0)";
sliderBefore.style.boxShadow = defaultValue ?
"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", defaultValue);
// Flash effect
resetButton.style.transform = "translateY(-50%) rotate(360deg)";
resetButton.style.color = "#4CAF50";
setTimeout(() => {
resetButton.style.transform = "translateY(-50%) rotate(0deg)";
resetButton.style.color = "#666";
}, 400);
});
container.appendChild(resetButton);
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);
}
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);
}
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);
// Character Preview Section (for showdefinition=true)
// Replace the existing character preview section in your userscript with this updated version
const previewSection = makeElement(
"div", {
id: "character-preview",
}, {
marginTop: "20px",
padding: "20px",
background: "linear-gradient(135deg, #2a2a2a 0%, #2e2e2e 100%)",
borderRadius: "10px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
border: "1px solid #444",
display: "block",
opacity: "1",
transform: "translateY(0)",
transition: "all 300ms ease",
},
);
const previewTitle = makeElement(
"h3", {
textContent: "CHARACTER PREVIEW",
}, {
margin: "0 0 20px 0",
fontSize: "18px",
color: "#fff",
fontWeight: "bold",
textAlign: "center",
textTransform: "uppercase",
letterSpacing: "2px",
borderBottom: "2px solid #0080ff",
paddingBottom: "10px",
},
);
previewSection.appendChild(previewTitle);
// Character Info Header
const characterHeader = makeElement(
"div", {}, {
display: "flex",
alignItems: "flex-start",
gap: "20px",
marginBottom: "25px",
padding: "15px",
background: "rgba(0, 128, 255, 0.1)",
borderRadius: "8px",
border: "1px solid rgba(0, 128, 255, 0.3)",
},
);
// Avatar Section
const avatarFrame = makeElement(
"div", {}, {
width: "80px",
height: "80px",
borderRadius: "12px",
overflow: "hidden",
background: "linear-gradient(145deg, #333, #1a1a1a)",
border: "2px solid #555",
boxShadow: "0 4px 12px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.1)",
position: "relative",
flexShrink: "0",
},
);
const avatarImg = makeElement(
"img", {
id: "preview-avatar",
alt: "Character Avatar",
}, {
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
},
);
avatarFrame.appendChild(avatarImg);
// Character Info
const characterInfo = makeElement(
"div", {}, {
flex: "1",
minWidth: "0",
},
);
const characterName = makeElement(
"h4", {
id: "preview-character-name",
}, {
margin: "0 0 8px 0",
fontSize: "20px",
color: "#fff",
fontWeight: "bold",
wordBreak: "break-word",
},
);
const characterChatName = makeElement(
"div", {
id: "preview-chat-name",
}, {
fontSize: "14px",
color: "#4CAF50",
marginBottom: "8px",
display: "none",
},
);
const characterCreator = makeElement(
"div", {
id: "preview-creator-info",
}, {
fontSize: "13px",
color: "#888",
marginBottom: "5px",
},
);
const characterTokens = makeElement(
"div", {
id: "preview-tokens",
}, {
fontSize: "12px",
color: "#4CAF50",
fontWeight: "bold",
},
);
characterInfo.appendChild(characterName);
characterInfo.appendChild(characterChatName);
characterInfo.appendChild(characterCreator);
characterInfo.appendChild(characterTokens);
characterHeader.appendChild(avatarFrame);
characterHeader.appendChild(characterInfo);
previewSection.appendChild(characterHeader);
// Collapsible sections container
const sectionsContainer = makeElement(
"div", {
id: "collapsible-sections",
}, {
display: "flex",
flexDirection: "column",
gap: "15px",
},
);
// Custom SVG Icons
const customIcons = {
description: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10,9 9,9 8,9"/>
</svg>`,
scenario: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
<path d="M12 8v4l-2-1"/>
</svg>`,
firstMessage: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M8 10h8"/>
<path d="M8 14h4"/>
</svg>`,
examples: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
<path d="M13 8H7"/>
<path d="M17 12H7"/>
<circle cx="17" cy="8" r="1"/>
</svg>`
};
// Dynamic theme variables with enhanced color palette
let currentTheme = {
primary: '#0080ff',
secondary: '#4CAF50',
accent: '#ff6b6b',
tertiary: '#9c27b0',
quaternary: '#ff9800',
background: 'linear-gradient(135deg, #2a2a2a 0%, #2e2e2e 100%)',
headerBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)',
panelBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)'
};
// Enhanced color analysis function using ColorThief
// Lightweight color extraction with CORS bypass
function analyzeImageColors(imageElement) {
return new Promise((resolve) => {
try {
// Fetch the image as a blob to bypass CORS
fetch(imageElement.src)
.then(response => response.blob())
.then(blob => {
const blobUrl = URL.createObjectURL(blob);
extractColorsFromBlob(blobUrl, resolve);
})
.catch(error => {
console.warn('Failed to fetch image:', error);
resolve(getDefaultTheme());
});
} catch (error) {
console.warn('Color extraction setup failed:', error);
resolve(getDefaultTheme());
}
});
}
function extractColorsFromBlob(blobUrl, resolve) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
// Set canvas size (smaller for performance)
canvas.width = 150;
canvas.height = 150;
img.onload = () => {
try {
// Draw image to canvas
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Get image data
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Color quantization - group similar colors
const colorMap = new Map();
const step = 4; // Sample every pixel
for (let i = 0; i < data.length; i += step * 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const alpha = data[i + 3];
// Skip transparent pixels
if (alpha < 128) continue;
// Quantize colors to reduce similar shades
const qr = Math.floor(r / 16) * 16;
const qg = Math.floor(g / 16) * 16;
const qb = Math.floor(b / 16) * 16;
const key = `${qr},${qg},${qb}`;
if (colorMap.has(key)) {
const existing = colorMap.get(key);
existing.count++;
existing.totalR += r;
existing.totalG += g;
existing.totalB += b;
} else {
colorMap.set(key, {
count: 1,
totalR: r,
totalG: g,
totalB: b
});
}
}
// Convert to array and calculate averages
const colors = Array.from(colorMap.values())
.map(color => ({
r: Math.round(color.totalR / color.count),
g: Math.round(color.totalG / color.count),
b: Math.round(color.totalB / color.count),
count: color.count
}))
.filter(color => {
// Filter out very dark, very light, or very gray colors
const brightness = (color.r + color.g + color.b) / 3;
const saturation = Math.abs(Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b));
return brightness > 30 && brightness < 220 && saturation > 25;
})
.sort((a, b) => b.count - a.count) // Sort by frequency
.slice(0, 8); // Take top 8 colors
if (colors.length === 0) {
// If no colors pass the filter, use less strict filtering
const fallbackColors = Array.from(colorMap.values())
.map(color => ({
r: Math.round(color.totalR / color.count),
g: Math.round(color.totalG / color.count),
b: Math.round(color.totalB / color.count),
count: color.count
}))
.filter(color => {
const brightness = (color.r + color.g + color.b) / 3;
return brightness > 20 && brightness < 235;
})
.sort((a, b) => b.count - a.count)
.slice(0, 6);
resolve(generateThemeFromColors(fallbackColors));
} else {
resolve(generateThemeFromColors(colors));
}
} catch (error) {
console.warn('Color extraction failed:', error);
resolve(getDefaultTheme());
} finally {
// Clean up blob URL
URL.revokeObjectURL(blobUrl);
}
};
img.onerror = () => {
console.warn('Image loading failed for blob URL');
resolve(getDefaultTheme());
URL.revokeObjectURL(blobUrl);
};
// Load the blob URL
img.src = blobUrl;
} catch (error) {
console.warn('Blob color extraction failed:', error);
resolve(getDefaultTheme());
URL.revokeObjectURL(blobUrl);
}
}
function generateThemeFromColors(colors) {
if (!colors || colors.length === 0) {
return getDefaultTheme();
}
// Convert RGB to hex
const toHex = (r, g, b) => `#${[r, g, b].map(x => Math.round(x).toString(16).padStart(2, '0')).join('')}`;
// Create gradient from multiple colors
const createGradient = (colorArray, direction = '90deg') => {
if (colorArray.length === 1) {
return toHex(colorArray[0].r, colorArray[0].g, colorArray[0].b);
}
const colorStops = colorArray.slice(0, 6).map((color, index) => {
const percentage = (index / Math.max(1, colorArray.length - 1)) * 100;
return `${toHex(color.r, color.g, color.b)} ${percentage}%`;
}).join(', ');
return `linear-gradient(${direction}, ${colorStops})`;
};
// Calculate color vibrancy (saturation * brightness)
const calculateVibrancy = (color) => {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
const saturation = max === 0 ? 0 : (max - min) / max;
const brightness = (color.r + color.g + color.b) / (3 * 255);
return saturation * brightness;
};
// Sort colors by vibrancy for better theme selection
const sortedColors = colors.slice().sort((a, b) => {
const vibrancyA = calculateVibrancy(a);
const vibrancyB = calculateVibrancy(b);
return vibrancyB - vibrancyA;
});
// Select diverse colors for theme roles
const themeColors = [];
// Primary: Most vibrant color
if (sortedColors[0]) {
themeColors.push(toHex(sortedColors[0].r, sortedColors[0].g, sortedColors[0].b));
}
// Secondary: Find a contrasting color
let secondaryIndex = 1;
if (sortedColors.length > 2) {
for (let i = 1; i < Math.min(sortedColors.length, 4); i++) {
const colorDiff = Math.abs(sortedColors[0].r - sortedColors[i].r) +
Math.abs(sortedColors[0].g - sortedColors[i].g) +
Math.abs(sortedColors[0].b - sortedColors[i].b);
if (colorDiff > 80) {
secondaryIndex = i;
break;
}
}
}
if (sortedColors[secondaryIndex]) {
themeColors.push(toHex(sortedColors[secondaryIndex].r, sortedColors[secondaryIndex].g, sortedColors[secondaryIndex].b));
}
// Add remaining colors for accent, tertiary, quaternary
for (let i = 0; i < sortedColors.length && themeColors.length < 5; i++) {
const hex = toHex(sortedColors[i].r, sortedColors[i].g, sortedColors[i].b);
if (!themeColors.includes(hex)) {
themeColors.push(hex);
}
}
// Fill with defaults if needed
const defaults = ['#0080ff', '#4CAF50', '#ff6b6b', '#9c27b0', '#ff9800'];
while (themeColors.length < 5) {
themeColors.push(defaults[themeColors.length]);
}
// Create background gradient using darker versions of the colors
const darkenColor = (hex, amount = 0.7) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const newR = Math.round(r * (1 - amount));
const newG = Math.round(g * (1 - amount));
const newB = Math.round(b * (1 - amount));
return `#${[newR, newG, newB].map(x => Math.max(0, x).toString(16).padStart(2, '0')).join('')}`;
};
const theme = {
name: 'dynamic',
primary: themeColors[0],
secondary: themeColors[1] || '#4CAF50',
accent: themeColors[2] || '#ff6b6b',
tertiary: themeColors[3] || '#9c27b0',
quaternary: themeColors[4] || '#ff9800',
background: `linear-gradient(135deg, ${darkenColor(themeColors[0], 0.8)} 0%, ${darkenColor(themeColors[1] || themeColors[0], 0.8)} 100%)`,
headerBorder: createGradient(sortedColors.slice(0, 4), '90deg'),
panelBorder: createGradient(sortedColors.slice().reverse().slice(0, 4), '90deg')
};
return [theme];
}
// Helper to return the default theme
function getDefaultTheme() {
return [{
name: 'default',
primary: '#0080ff',
secondary: '#4CAF50',
accent: '#ff6b6b',
tertiary: '#9c27b0',
quaternary: '#ff9800',
background: 'linear-gradient(135deg, #2a2a2a 0%, #2e2e2e 100%)',
headerBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)',
panelBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)'
}];
}
function generateThemeFromColors(colors) {
if (!colors || colors.length === 0) {
return [{
name: 'default',
primary: '#0080ff',
secondary: '#4CAF50',
accent: '#ff6b6b',
tertiary: '#9c27b0',
quaternary: '#ff9800',
background: 'linear-gradient(135deg, #2a2a2a 0%, #2e2e2e 100%)',
headerBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)',
panelBorder: 'linear-gradient(90deg, #0080ff 0%, #4CAF50 50%, #ff6b6b 100%)'
}];
}
// Convert RGB to hex
const toHex = (r, g, b) => `#${[r, g, b].map(x => Math.round(x).toString(16).padStart(2, '0')).join('')}`;
// Create gradient from multiple colors
const createGradient = (colorArray, direction = '90deg') => {
if (colorArray.length === 1) {
return toHex(colorArray[0].r, colorArray[0].g, colorArray[0].b);
}
const colorStops = colorArray.slice(0, 6).map((color, index) => {
const percentage = (index / Math.max(1, colorArray.length - 1)) * 100;
return `${toHex(color.r, color.g, color.b)} ${percentage}%`;
}).join(', ');
return `linear-gradient(${direction}, ${colorStops})`;
};
// Sort colors by vibrancy (saturation * brightness)
const sortedColors = colors.slice().sort((a, b) => {
const vibrancyA = Math.abs(Math.max(a.r, a.g, a.b) - Math.min(a.r, a.g, a.b)) * ((a.r + a.g + a.b) / 3);
const vibrancyB = Math.abs(Math.max(b.r, b.g, b.b) - Math.min(b.r, b.g, b.b)) * ((b.r + b.g + b.b) / 3);
return vibrancyB - vibrancyA;
});
// Select diverse colors for theme roles
const themeColors = [];
// Primary: Most vibrant color
if (sortedColors[0]) themeColors.push(toHex(sortedColors[0].r, sortedColors[0].g, sortedColors[0].b));
// Secondary: Find a contrasting color
let secondaryIndex = 1;
if (sortedColors.length > 2) {
// Look for a color that's different enough from primary
for (let i = 1; i < Math.min(sortedColors.length, 4); i++) {
const colorDiff = Math.abs(sortedColors[0].r - sortedColors[i].r) +
Math.abs(sortedColors[0].g - sortedColors[i].g) +
Math.abs(sortedColors[0].b - sortedColors[i].b);
if (colorDiff > 80) {
secondaryIndex = i;
break;
}
}
}
if (sortedColors[secondaryIndex]) themeColors.push(toHex(sortedColors[secondaryIndex].r, sortedColors[secondaryIndex].g, sortedColors[secondaryIndex].b));
// Add remaining colors for accent, tertiary, quaternary
for (let i = 0; i < sortedColors.length && themeColors.length < 5; i++) {
const hex = toHex(sortedColors[i].r, sortedColors[i].g, sortedColors[i].b);
if (!themeColors.includes(hex)) {
themeColors.push(hex);
}
}
// Fill with defaults if needed
const defaults = ['#0080ff', '#4CAF50', '#ff6b6b', '#9c27b0', '#ff9800'];
while (themeColors.length < 5) {
themeColors.push(defaults[themeColors.length]);
}
// Create background gradient using darker versions of the colors
const darkenColor = (hex, amount = 0.3) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const newR = Math.round(r * (1 - amount));
const newG = Math.round(g * (1 - amount));
const newB = Math.round(b * (1 - amount));
return `#${[newR, newG, newB].map(x => x.toString(16).padStart(2, '0')).join('')}`;
};
const theme = {
name: 'dynamic',
primary: themeColors[0],
secondary: themeColors[1] || '#4CAF50',
accent: themeColors[2] || '#ff6b6b',
tertiary: themeColors[3] || '#9c27b0',
quaternary: themeColors[4] || '#ff9800',
background: `linear-gradient(135deg, ${darkenColor(themeColors[0], 0.8)} 0%, ${darkenColor(themeColors[1] || themeColors[0], 0.8)} 100%)`,
headerBorder: createGradient(sortedColors.slice(0, 4), '90deg'),
panelBorder: createGradient(sortedColors.slice().reverse().slice(0, 4), '90deg')
};
return [theme];
}
function applyTheme(theme) {
currentTheme = theme;
// Update preview section background and border
previewSection.style.background = theme.background;
previewSection.style.border = `2px solid transparent`;
previewSection.style.backgroundImage = `${theme.background}, ${theme.panelBorder}`;
previewSection.style.backgroundOrigin = 'border-box';
previewSection.style.backgroundClip = 'padding-box, border-box';
// Calculate brightness of primary color to determine theme approach
const primaryRgb = hexToRgb(theme.primary);
const brightness = (primaryRgb.r * 299 + primaryRgb.g * 587 + primaryRgb.b * 114) / 1000;
const isVeryBright = brightness > 180;
// Enhanced header with dual approach: vibrant vs readable
const secondaryRgb = hexToRgb(theme.secondary);
const accentRgb = hexToRgb(theme.accent);
if (isVeryBright) {
// For bright colors: Use vibrant background with strong text contrast
const vibrantGradient = `linear-gradient(135deg,
rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.85) 0%,
rgba(${secondaryRgb.r}, ${secondaryRgb.g}, ${secondaryRgb.b}, 0.75) 50%,
rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.85) 100%)`;
characterHeader.style.background = vibrantGradient;
characterHeader.style.border = `3px solid ${theme.primary}`;
characterHeader.style.boxShadow = `
0 6px 25px rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.4),
inset 0 1px 0 rgba(255,255,255,0.3),
inset 0 -1px 0 rgba(0,0,0,0.3)`;
// Strong text contrast for bright backgrounds
applyTextStyles('#000000', '0 1px 2px rgba(255,255,255,0.8)', '#1a5d1a', '#1a5d1a');
} else {
// For darker colors: Use subtle overlay with good contrast
const subtleGradient = `linear-gradient(135deg,
rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.25) 0%,
rgba(${secondaryRgb.r}, ${secondaryRgb.g}, ${secondaryRgb.b}, 0.20) 50%,
rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.25) 100%)`;
const patternOverlay = `radial-gradient(circle at 20% 80%, rgba(255,255,255,0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255,255,255,0.05) 0%, transparent 50%)`;
characterHeader.style.background = `${subtleGradient}, ${patternOverlay}, rgba(0,0,0,0.7)`;
characterHeader.style.border = `2px solid transparent`;
characterHeader.style.backgroundImage = `${subtleGradient}, ${patternOverlay}, rgba(0,0,0,0.7), ${theme.headerBorder}`;
characterHeader.style.backgroundOrigin = 'border-box';
characterHeader.style.backgroundClip = 'padding-box, border-box';
characterHeader.style.boxShadow = `
0 4px 20px rgba(${primaryRgb.r}, ${primaryRgb.g}, ${primaryRgb.b}, 0.3),
inset 0 1px 0 rgba(255,255,255,0.1),
inset 0 -1px 0 rgba(0,0,0,0.2)`;
// Light text for darker backgrounds
applyTextStyles('#ffffff', '0 2px 4px rgba(0,0,0,0.8)', '#4CAF50', '#4CAF50');
}
// Update title border and background
previewTitle.style.borderBottomColor = theme.primary;
previewTitle.style.background = `linear-gradient(90deg, ${theme.primary}20, ${theme.secondary}20, ${theme.accent}20)`;
previewTitle.style.borderRadius = '4px';
previewTitle.style.padding = '10px';
// Update section icons to use primary color
const sectionIcons = document.querySelectorAll('.collapsible-section svg');
sectionIcons.forEach(icon => {
icon.style.color = theme.primary;
});
// Update all copy button colors to match new theme
const copyButtons = document.querySelectorAll('.copy-btn');
copyButtons.forEach(button => {
button.style.borderColor = `${theme.primary}40`;
button.style.color = theme.primary;
button.style.boxShadow = `0 2px 8px ${theme.primary}20`;
// Update SVG stroke color
const svg = button.querySelector('svg');
if (svg) svg.setAttribute('stroke', theme.primary);
});
}
function applyTextStyles(nameColor, textShadow, tokenColor, chatNameColor) {
const nameElement = document.getElementById('preview-character-name');
const creatorElement = document.getElementById('preview-creator-info');
const tokensElement = document.getElementById('preview-tokens');
const chatNameElement = document.getElementById('preview-chat-name');
if (nameElement) {
nameElement.style.color = nameColor;
nameElement.style.textShadow = textShadow;
nameElement.style.fontWeight = 'bold';
}
if (creatorElement) {
creatorElement.style.color = nameColor === '#000000' ? '#333333' : '#e0e0e0';
creatorElement.style.textShadow = textShadow;
}
if (tokensElement) {
tokensElement.style.color = tokenColor;
tokensElement.style.textShadow = textShadow;
tokensElement.style.fontWeight = 'bold';
}
if (chatNameElement) {
chatNameElement.style.color = chatNameColor;
chatNameElement.style.textShadow = textShadow;
}
}
// Helper function to convert hex to RGB
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : {
r: 0,
g: 128,
b: 255
}; // fallback to blue
}
// Function to create collapsible section with enhanced bars and copy functionality (NO BOTTOM BARS)
function createCollapsibleSection(id, title, iconSvg) {
const sectionWrapper = makeElement(
"div", {
id: `section-${id}`,
className: "collapsible-section",
}, {
marginBottom: "12px",
},
);
// --- FIX: State management for copy button to prevent hover/click bugs ---
let revertIconTimeout = null;
let isSuccessState = false;
let copyButtonAnimationTimeout = null;
// Title above the panel (centered)
const sectionTitle = makeElement(
"div", {}, {
textAlign: "center",
marginBottom: "8px",
},
);
const titleContent = makeElement(
"div", {}, {
display: "inline-flex",
alignItems: "center",
gap: "8px",
color: "#fff",
fontSize: "13px",
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: "1px",
},
);
const titleIcon = makeElement(
"div", {
innerHTML: iconSvg,
}, {
display: "flex",
alignItems: "center",
color: currentTheme.primary,
},
);
const titleText = makeElement(
"span", {
textContent: title,
}, {},
);
titleContent.appendChild(titleIcon);
titleContent.appendChild(titleText);
sectionTitle.appendChild(titleContent);
sectionWrapper.appendChild(sectionTitle);
// Panel container
const panelContainer = makeElement(
"div", {
className: "panel-container",
}, {
background: "#1a1a1a",
borderRadius: "6px",
border: "1px solid #444",
overflow: "hidden",
transition: "all 200ms ease",
position: "relative",
},
);
// Top bar (clickable) - enhanced with better gradient
const topBar = makeElement(
"div", {
className: "panel-bar top-bar",
}, {
height: "12px",
background: "linear-gradient(90deg, #444 0%, #555 50%, #444 100%)",
cursor: "pointer",
transition: "all 200ms ease",
position: "relative",
},
);
// Content area
const contentArea = makeElement(
"div", {
className: "content-area",
}, {
maxHeight: "0px",
overflow: "hidden",
transition: "max-height 350ms cubic-bezier(0.4, 0, 0.2, 1)",
background: "#222",
position: "relative", // Important for copy button positioning
},
);
const contentText = makeElement(
"div", {
className: "content-text",
id: `content-${id}`,
}, {
padding: "16px 50px 16px 20px", // Extra right padding for copy button
fontSize: "13px",
lineHeight: "1.6",
color: "#ddd",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
position: "relative",
userSelect: "text", // Make text selectable
cursor: "text", // Show text cursor
},
);
// Copy button - positioned as proper overlay with theme colors
const copyButton = makeElement(
"button", {
className: "copy-btn",
innerHTML: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2 2v1"/></svg>',
title: "Copy to clipboard",
}, {
position: "absolute",
top: "8px", // Moved down from top bar
right: "8px",
background: "transparent",
border: `1px solid ${currentTheme.primary}40`, // Use theme with opacity
borderRadius: "6px",
color: currentTheme.primary, // Use theme color
padding: "6px 8px",
cursor: "pointer",
fontSize: "12px",
opacity: "0.6", // More subtle idle state
transform: "scale(1)",
transition: "all 200ms ease",
zIndex: "1000", // High z-index for overlay
display: "none",
alignItems: "center",
justifyContent: "center",
minWidth: "28px",
minHeight: "28px",
boxShadow: `0 2px 8px ${currentTheme.primary}20`, // Theme-based shadow
},
);
// --- FIXED: Hover listeners now check the success state ---
copyButton.addEventListener('mouseenter', () => {
if (isSuccessState) return; // Do nothing if in success state
copyButton.style.opacity = "1";
copyButton.style.transform = "scale(1.05)";
copyButton.style.borderColor = currentTheme.secondary;
copyButton.style.boxShadow = `0 4px 12px ${currentTheme.primary}40, 0 0 0 2px ${currentTheme.accent}30`;
copyButton.style.color = currentTheme.secondary;
const svg = copyButton.querySelector('svg');
if (svg) svg.setAttribute('stroke', currentTheme.secondary);
});
copyButton.addEventListener('mouseleave', () => {
if (isSuccessState) return; // Do nothing if in success state
copyButton.style.opacity = "0.6";
copyButton.style.transform = "scale(1)";
copyButton.style.borderColor = `${currentTheme.primary}40`;
copyButton.style.boxShadow = `0 2px 8px ${currentTheme.primary}20`;
copyButton.style.color = currentTheme.primary;
const svg = copyButton.querySelector('svg');
if (svg) svg.setAttribute('stroke', currentTheme.primary);
});
copyButton.addEventListener('click', (e) => {
e.stopPropagation();
clearTimeout(revertIconTimeout);
isSuccessState = false;
const content = contentText.textContent;
const placeholders = ["No description available", "No scenario available", "No first message available", "No examples available", "Definition not exposed"];
const isPlaceholder = placeholders.some(placeholder => content && content.trim() === placeholder);
if (content && content.trim() !== "" && !isPlaceholder) {
navigator.clipboard.writeText(content).then(() => {
showCopyNotification();
isSuccessState = true; // Enter success state
copyButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><polyline points="20,6 9,17 4,12"/></svg>';
copyButton.style.background = currentTheme.secondary || "#4CAF50";
copyButton.style.borderColor = currentTheme.secondary || "#66BB6A";
copyButton.style.color = "white";
revertIconTimeout = setTimeout(() => {
isSuccessState = false; // Exit success state
copyButton.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2 2v1"/></svg>';
copyButton.style.background = "transparent";
copyButton.style.borderColor = `${currentTheme.primary}40`;
copyButton.style.color = currentTheme.primary;
copyButton.style.boxShadow = `0 2px 8px ${currentTheme.primary}20`;
const svg = copyButton.querySelector('svg');
if (svg) svg.setAttribute('stroke', currentTheme.primary);
}, 1500);
}).catch((err) => {
debugError("Clipboard write failed:", err);
});
}
});
panelContainer.appendChild(topBar);
panelContainer.appendChild(contentArea);
panelContainer.appendChild(copyButton);
contentArea.appendChild(contentText);
contentArea.appendChild(copyButton);
let isExpanded = sessionStorage.getItem(`section-${id}-expanded`) === "true";
// This is the animated toggle for user clicks
function toggleSection() {
clearTimeout(copyButtonAnimationTimeout);
isExpanded = !isExpanded;
sessionStorage.setItem(`section-${id}-expanded`, isExpanded);
if (isExpanded) {
const scrollHeight = contentText.scrollHeight + 20;
contentArea.style.maxHeight = scrollHeight + "px";
panelContainer.style.borderColor = currentTheme.primary;
topBar.style.background = `linear-gradient(90deg, ${currentTheme.primary} 0%, ${currentTheme.secondary} 50%, ${currentTheme.accent} 100%)`;
const hasValidContent = contentText.textContent && !isPlaceholderText(contentText.textContent.trim());
if (hasValidContent) {
copyButton.style.display = "flex";
requestAnimationFrame(() => {
copyButton.style.opacity = "0.6";
copyButton.style.transform = "scale(1)";
});
}
} else {
contentArea.style.maxHeight = "0px";
panelContainer.style.borderColor = "#444";
topBar.style.background = "linear-gradient(90deg, #444 0%, #555 50%, #444 100%)";
// Fade out copy button before hiding it
copyButton.style.opacity = "0";
copyButton.style.transform = "scale(0.8)";
copyButtonAnimationTimeout = setTimeout(() => {
copyButton.style.display = "none";
}, 200); // Match the fade transition duration
}
}
// --- FIXED: Set initial state ONCE on load, without animation ---
// We wait for the DOM to settle to get the correct scrollHeight
setTimeout(() => {
if (isExpanded) {
contentArea.style.transition = 'none'; // Temporarily disable animation
const scrollHeight = contentText.scrollHeight + 20;
contentArea.style.maxHeight = scrollHeight + "px";
panelContainer.style.borderColor = currentTheme.primary;
topBar.style.background = `linear-gradient(90deg, ${currentTheme.primary} 0%, ${currentTheme.secondary} 50%, ${currentTheme.accent} 100%)`;
const hasValidContent = contentText.textContent && !isPlaceholderText(contentText.textContent.trim());
if (hasValidContent) {
copyButton.style.display = "flex";
copyButton.style.opacity = "0.6";
}
// Re-enable animation for subsequent user clicks
requestAnimationFrame(() => {
contentArea.style.transition = 'max-height 350ms cubic-bezier(0.4, 0, 0.2, 1)';
// Restore scroll position after collapsibles are ready
if (typeof restoreScrollPosition === 'function') {
restoreScrollPosition();
}
});
} else {
// Also restore scroll for non-expanded sections
requestAnimationFrame(() => {
if (typeof restoreScrollPosition === 'function') {
restoreScrollPosition();
}
});
}
}, 100);
topBar.addEventListener('mouseenter', () => {
if (!isExpanded) {
topBar.style.background = "linear-gradient(90deg, #555 0%, #666 50%, #555 100%)";
}
});
topBar.addEventListener('mouseleave', () => {
if (!isExpanded) {
topBar.style.background = "linear-gradient(90deg, #444 0%, #555 50%, #444 100%)";
}
});
topBar.addEventListener('click', toggleSection);
sectionWrapper.appendChild(panelContainer);
return {
element: sectionWrapper,
setContent: (text) => {
const hasContent = text && text.trim() !== "" && !isPlaceholderText(text.trim());
if (hasContent) {
contentText.textContent = text;
contentText.style.color = "#ddd";
contentText.style.fontStyle = "normal";
contentText.style.textAlign = "center";
contentText.style.background = "transparent";
contentText.style.border = "none";
contentText.style.opacity = "1";
contentText.style.userSelect = "text";
contentText.style.cursor = "text";
contentText.style.padding = "16px 50px 16px 20px";
} else {
const displayText = text && text.trim() !== "" ? text : "No content available";
contentText.textContent = displayText;
let placeholderColor = currentTheme.accent || "#ff6b6b";
let placeholderBg = `${currentTheme.accent}15` || "#ff6b6b15";
let placeholderBorder = `${currentTheme.accent}40` || "#ff6b6b40";
if (displayText.includes("Definition not exposed")) {
placeholderColor = currentTheme.quaternary || "#ff9800";
placeholderBg = `${currentTheme.quaternary}15` || "#ff980015";
placeholderBorder = `${currentTheme.quaternary}40` || "#ff980040";
} else if (displayText.includes("No") && displayText.includes("available")) {
placeholderColor = currentTheme.tertiary || "#9e9e9e";
placeholderBg = `${currentTheme.tertiary}15` || "#9e9e9e15";
placeholderBorder = `${currentTheme.tertiary}40` || "#9e9e9e40";
}
contentText.style.color = placeholderColor;
contentText.style.fontStyle = "italic";
contentText.style.opacity = "0.8";
contentText.style.textAlign = "center";
contentText.style.background = `linear-gradient(135deg, ${placeholderBg} 0%, transparent 100%)`;
contentText.style.borderRadius = "4px";
contentText.style.border = `1px dashed ${placeholderBorder}`;
contentText.style.fontSize = "12px";
contentText.style.userSelect = "none";
contentText.style.cursor = "default";
contentText.style.padding = "16px 20px";
}
// This part is for when content is set AFTER initial render.
// It helps update the state correctly.
if (isExpanded) {
if (hasContent) {
copyButton.style.display = "flex";
} else {
copyButton.style.display = "none";
}
// A small delay to ensure `scrollHeight` is updated in the DOM
setTimeout(() => {
contentArea.style.maxHeight = (contentText.scrollHeight + 20) + "px";
}, 50);
}
}
};
}
// Simplified notification system - no queue, just replace
let notificationTimeout = null;
function showCopyNotification(message = "Copied to clipboard!") {
// Clear any existing timeout
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
// Remove any existing notification immediately
const existingNotification = document.getElementById('copy-notification');
if (existingNotification) {
existingNotification.remove();
}
const notification = makeElement(
"div", {
id: "copy-notification",
textContent: message,
}, {
position: "fixed",
top: "20px",
right: "20px",
background: `linear-gradient(135deg, ${currentTheme.primary}dd, ${currentTheme.secondary}dd)`,
color: "white",
padding: "12px 20px",
borderRadius: "6px",
fontSize: "14px",
fontWeight: "500",
zIndex: "99999",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
transform: "translateX(100%)",
transition: "transform 300ms ease",
border: `2px solid ${currentTheme.accent}`,
maxWidth: "300px",
wordBreak: "break-word",
},
);
document.body.appendChild(notification);
// Animate in
setTimeout(() => {
notification.style.transform = "translateX(0)";
}, 10);
// Animate out and remove
notificationTimeout = setTimeout(() => {
notification.style.transform = "translateX(100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}, 2500);
}
// Create sections with custom SVG icons (NO FontAwesome)
const descriptionSection = createCollapsibleSection('description', 'Description', customIcons.description);
const scenarioSection = createCollapsibleSection('scenario', 'Scenario', customIcons.scenario);
const firstMessageSection = createCollapsibleSection('firstMessage', 'First Message', customIcons.firstMessage);
const examplesSection = createCollapsibleSection('examples', 'Examples', customIcons.examples);
sectionsContainer.appendChild(descriptionSection.element);
sectionsContainer.appendChild(scenarioSection.element);
sectionsContainer.appendChild(firstMessageSection.element);
sectionsContainer.appendChild(examplesSection.element);
previewSection.appendChild(sectionsContainer);
// Cache for character preview data
let previewDataCache = {
characterId: null,
data: null,
avatarUrl: null,
themeApplied: false
};
// Function to calculate approximate token count
function calculateTokenCount(text) {
if (!text) return 0;
// Rough estimation: 1 token ≈ 4 characters for English text
return Math.round(text.length / 4);
}
// Function to modify avatar URL to use width=200
function modifyAvatarUrl(originalUrl) {
if (!originalUrl) return null;
return originalUrl.replace(/width=\d+/, 'width=200');
}
// Extract token info from DOM when definition is not exposed
// Extract token info from DOM when definition is not exposed
function extractTokenInfoFromDOM() {
try {
// Look for the specific character info header with token information
const tokenHeaders = document.querySelectorAll('h4._characterInfoHeader_1tqgq_38.glow-on-hover, h4[class*="characterInfoHeader"]');
for (const header of tokenHeaders) {
const headerText = header.textContent || '';
if (headerText.includes('Character Definition is hidden') && headerText.includes('Total') && headerText.includes('tokens')) {
// Extract: "Character Definition is hidden, Total 1889 tokens, Permanent 1075"
const tokenMatch = headerText.match(/Total (\d+(?:,\d+)*) tokens/i);
if (tokenMatch) {
const tokenCount = tokenMatch[1].replace(/,/g, '');
return `Total ${parseInt(tokenCount).toLocaleString()} tokens`;
}
}
}
return "Token info not available";
} catch (error) {
debugLog("Error extracting token info from DOM:", error);
return "Token info not available";
}
}
// Enhanced stats extraction function - FINALIZED AND STREAMLINED
async function getEnhancedCharacterStats(characterId, characterCardUrl) {
// Initialize stats object with only the fields we can reliably fetch.
const stats = {
totalChats: "0",
totalMessages: "0",
tokenInfo: null
};
try {
// Use the simple, reliable fetch method for static content.
const response = await fetch(characterCardUrl);
const html = await response.text();
const doc = new DOMParser().parseFromString(html, "text/html");
// Extract token info for hidden definitions
try {
const tokenHeaders = doc.querySelectorAll('h4[class*="characterInfoHeader"]');
for (const header of tokenHeaders) {
const headerText = header.textContent || '';
if (headerText.includes('Character Definition is hidden') && headerText.includes('Total') && headerText.includes('tokens')) {
const tokenMatch = headerText.match(/Total (\d+(?:,\d+)*) tokens/i);
if (tokenMatch) {
const tokenCount = tokenMatch[1].replace(/,/g, '');
stats.tokenInfo = `Total ${parseInt(tokenCount).toLocaleString()} tokens`;
}
break;
}
}
} catch (error) {
debugLog("Error extracting token info:", error);
}
// Extract total chats and messages
try {
// This selector has proven to be reliable for the static content.
const statsContainer = doc.querySelector('div.chakra-stack.css-1qoq49l');
if (statsContainer) {
const statElements = statsContainer.querySelectorAll('p.chakra-text.css-0');
if (statElements.length >= 2) {
stats.totalChats = statElements[0].textContent.trim();
stats.totalMessages = statElements[1].textContent.trim();
debugLog(`Successfully fetched Total Chats: ${stats.totalChats}, Total Messages: ${stats.totalMessages}`);
}
} else {
debugWarn("Could not find the container for Total Chats/Messages.");
}
} catch (error) {
debugLog("Error extracting chat/message stats:", error);
}
} catch (error) {
debugError("An error occurred while fetching character page stats:", error);
}
return stats;
}
// Function to update character preview with proper data retrieval and caching
// Function to update character preview with proper data retrieval and caching
async function updateCharacterPreview() {
if (!chatData || !chatData.character) {
debugLog("Preview update stopped: No character data available.");
return;
}
const characterId = chatData.character.id;
const cacheKey = `char_preview_cache_${characterId}`;
// --- MODIFIED: Check sessionStorage for cached data first ---
try {
const cachedDataJSON = sessionStorage.getItem(cacheKey);
if (cachedDataJSON) {
debugLog("Using cached character preview data from sessionStorage for ID:", characterId);
const cachedData = JSON.parse(cachedDataJSON);
updatePreviewUI(cachedData); // Update UI from cache
return; // Stop execution, we don't need to fetch again
}
} catch (e) {
debugWarn("Could not parse cached preview data, fetching fresh.", e);
sessionStorage.removeItem(cacheKey); // Clear corrupted data
}
debugLog("No cached data found. Fetching fresh character data for ID:", characterId);
try {
const meta = await getCharacterMeta();
const tokens = await getFilenameTokens(meta);
const charAvatar = document.querySelector('img[src*="/bot-avatars/"]');
const avatarUrl = charAvatar ? modifyAvatarUrl(charAvatar.src) : null;
const enhancedStats = await getEnhancedCharacterStats(characterId, meta.characterCardUrl);
// Compile all data into one object
const characterData = {
id: characterId,
name: meta.name || chatData.character.name || "Unknown",
chatName: chatData.character.chat_name,
creator: tokens.creator || "Unknown",
definitionExposed: meta.definitionExposed,
avatarUrl: avatarUrl,
meta: meta,
description: meta.definitionExposed ? (meta.personality || chatData.character.description || "No description available") : "Definition not exposed",
scenario: meta.definitionExposed ? (meta.scenario || "No scenario available") : "Definition not exposed",
firstMessage: meta.firstMessage || chatData.chatMessages?.[0]?.message || "No first message available",
examples: meta.definitionExposed ? (meta.exampleDialogs || "No examples available") : "Definition not exposed",
tokenCount: meta.definitionExposed ? calculateTokenCount([meta.personality, meta.scenario, meta.firstMessage, meta.exampleDialogs].filter(Boolean).join(" ")) : 0,
...enhancedStats // Add stats from the fetched data
};
// --- MODIFIED: Save the freshly fetched data to sessionStorage ---
try {
sessionStorage.setItem(cacheKey, JSON.stringify(characterData));
debugLog("Character data cached in sessionStorage for ID:", characterId);
} catch (e) {
debugWarn("Could not save character data to sessionStorage, it may be too large.", e);
}
// Update the UI with the new data
updatePreviewUI(characterData);
} catch (err) {
debugLog("Failed to fetch and update character preview:", err);
}
}
// Function to update the preview UI with character data
// Function to update the preview UI with character data
// Function to update the preview UI with character data
function updatePreviewUI(characterData) {
// Update basic info
const displayName = useChatNameForName && characterData.chatName ?
characterData.chatName : characterData.name;
// Make character name clickable to character page
const characterUrl = characterData.meta?.characterCardUrl || '';
if (characterUrl) {
characterName.innerHTML = `<a href="${characterUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none; cursor: pointer; transition: opacity 0.2s ease;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">${displayName}</a>`;
} else {
characterName.textContent = displayName;
}
// Show chat name if different from display name
if (characterData.chatName &&
characterData.chatName !== displayName &&
characterData.chatName !== characterData.name) {
characterChatName.textContent = `Chat Name: ${characterData.chatName}`;
characterChatName.style.display = 'block';
} else {
characterChatName.style.display = 'none';
}
// Update creator info with clickable link
const creatorUrl = characterData.meta?.creatorUrl || '';
if (creatorUrl) {
characterCreator.innerHTML = `Creator: <a href="${creatorUrl}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none; cursor: pointer; transition: opacity 0.2s ease;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">${characterData.creator}</a>`;
} else {
characterCreator.textContent = `Creator: ${characterData.creator}`;
}
// Stats display - only shows what is reliably available
const statsHTML = [];
if (characterData.totalChats && characterData.totalChats !== "0") {
statsHTML.push(`💬 ${characterData.totalChats} chats`);
}
if (characterData.totalMessages && characterData.totalMessages !== "0") {
statsHTML.push(`📧 ${characterData.totalMessages} messages`);
}
// Update avatar with modified URL (width=200) and apply theme
if (characterData.avatarUrl) {
avatarImg.src = characterData.avatarUrl;
// Apply dynamic theme based on avatar (only once per character)
if (!previewDataCache.themeApplied) {
avatarImg.onload = async () => {
try {
const themes = await analyzeImageColors(avatarImg);
const selectedTheme = themes[0];
applyTheme(selectedTheme);
previewDataCache.themeApplied = true;
} catch (error) {
debugLog("Theme analysis failed:", error);
}
};
}
}
// Update token count and status
if (characterData.definitionExposed && characterData.tokenCount !== null) {
let tokenText = `~${characterData.tokenCount.toLocaleString()} tokens`;
if (statsHTML.length > 0) {
tokenText += ` • ${statsHTML.join(' • ')}`;
}
characterTokens.innerHTML = tokenText;
characterTokens.style.color = "#4CAF50";
// Update section content with actual data
descriptionSection.setContent(characterData.description);
scenarioSection.setContent(characterData.scenario);
firstMessageSection.setContent(characterData.firstMessage);
examplesSection.setContent(characterData.examples);
} else {
// Show limited preview with token info if available
let limitedText = "⚠️ Limited preview - definition not exposed";
if (characterData.tokenInfo) {
limitedText = `⚠️ ${characterData.tokenInfo} - definition not exposed`;
}
if (statsHTML.length > 0) {
limitedText += ` • ${statsHTML.join(' • ')}`;
}
characterTokens.innerHTML = limitedText;
characterTokens.style.color = "#ff9800";
// Use limited data
descriptionSection.setContent(characterData.description);
scenarioSection.setContent(characterData.scenario);
firstMessageSection.setContent(characterData.firstMessage);
examplesSection.setContent(characterData.examples);
}
// Show preview section with animation
previewSection.style.display = "block";
requestAnimationFrame(() => {
previewSection.style.opacity = "1";
previewSection.style.transform = "translateY(0)";
});
}
// Helper function to check if text is a placeholder
function isPlaceholderText(text) {
const placeholders = [
"No description available",
"No scenario available",
"No first message available",
"No examples available",
"Definition not exposed",
"No content available"
];
return placeholders.includes(text);
}
// Add the preview section to the export content
exportContent.appendChild(previewSection);
// Add scroll memory for export tab
exportContent.addEventListener("scroll", () => {
sessionStorage.setItem("char_export_scroll", exportContent.scrollTop);
});
// // Function to restore scroll position after collapsibles are ready
// function restoreScrollPosition() {
// const savedExportScroll = parseInt(
// sessionStorage.getItem("char_export_scroll") || "0",
// 10,
// );
// if (savedExportScroll > 0) {
// exportContent.scrollTop = savedExportScroll;
// }
// }
// Initialize preview update - only call once when UI is created
if (chatData && chatData.character && viewActive) {
updateCharacterPreview();
}
// // Restore export tab scroll on tab switch
// function switchTab(tabKey) {
// if (tabKey === "export") {
// // Restore export tab scroll position
// requestAnimationFrame(() => {
// const savedExportScroll = parseInt(
// sessionStorage.getItem("char_export_scroll") || "0",
// 10,
// );
// exportContent.scrollTop = savedExportScroll;
// });
// }
// }
// Add CSS styles for the enhanced collapsible sections
if (!document.getElementById("char-preview-styles")) {
const previewStyle = document.createElement("style");
previewStyle.id = "char-preview-styles";
// --- MODIFIED: Removed the 'background' property from the .copy-btn:hover rule ---
previewStyle.textContent = `
.collapsible-section .panel-container:hover {
border-color: #666;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.panel-bar {
position: relative;
}
.panel-bar:hover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.1);
pointer-events: none;
}
.content-area {
background: linear-gradient(135deg, #222 0%, #1a1a1a 100%);
}
.content-text:empty::before {
content: "No content available";
color: #666;
font-style: italic;
}
.copy-btn:hover {
/* The blue background color has been removed from this rule. */
transform: scale(1.1) !important;
}
.copy-btn:active {
transform: scale(0.95) !important;
}
`;
document.head.appendChild(previewStyle);
}
// Add content border and alignment fixes
if (!document.getElementById("char-content-border-fix")) {
const contentBorderStyle = document.createElement("style");
contentBorderStyle.id = "char-content-border-fix";
// --- FIXED: Removed the 'border-right: none' rules ---
contentBorderStyle.textContent = `
.content-text {
text-align: center !important;
padding: 16px 50px 16px 20px;
}
`;
document.head.appendChild(contentBorderStyle);
}
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 createStaticScrollbar = (element) => {
let track, thumb;
let isDragging = false;
let startY = 0;
let startScrollTop = 0;
let isVisible = false;
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);
}
// Initialize in hidden state
track.style.visibility = "hidden";
track.style.opacity = "0";
};
const updateThumbSize = () => {
if (!track || !thumb) return;
const containerHeight = element.clientHeight;
const contentHeight = element.scrollHeight;
const scrollRatio = containerHeight / contentHeight;
if (scrollRatio >= 1 || contentHeight <= containerHeight) {
track.style.visibility = "hidden";
track.style.opacity = "0";
isVisible = false;
return;
}
track.style.visibility = "visible";
track.style.opacity = "0.6";
isVisible = true;
const thumbHeight = Math.max(20, containerHeight * scrollRatio);
thumb.style.height = `${thumbHeight}px`;
updateThumbPosition();
};
const show = () => {
if (track) {
updateThumbSize();
}
};
const hide = () => {
if (track) {
track.style.visibility = "hidden";
track.style.opacity = "0";
isVisible = false;
}
};
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();
};
// Initialize immediately
(() => {
createScrollbarElements();
const onWheel = () => {
updateThumbPosition();
};
// 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);
// Start hidden
hide();
})();
return {
show,
hide,
updateThumbSize,
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();
}
};
};
// Initialize custom scrollbar only when needed
settingsScrollbar = createStaticScrollbar(settingsContent);
// 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") === null ? false : 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";
debugLog(
"[Directory] Restored from IndexedDB:",
storedHandle.name,
);
} else {
debugLog("[Directory] Handle exists but permission denied");
}
} catch (error) {
debugLog("[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) {
debugLog("[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(() => {
debugLog(
"[Directory] Stored in IndexedDB:",
selectedDirectory.name,
);
})
.catch((error) => {
debugLog("[Directory] Failed to store in IndexedDB:", error);
});
} catch (err) {
debugLog("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 chat name",
"Uses chat name for the character name instead of the card's main name.",
useChatNameForName,
);
chatNameToggle.input.addEventListener("change", (e) => {
useChatNameForName = e.target.checked;
});
exportOptionsSection.appendChild(chatNameToggle.container);
// Apply {{char}} tokenization toggle
// Apply {{char}} tokenization toggle
const charTokenToggle = createToggle(
"applyCharToken",
"Tokenize char name",
"Replaces the character's name with {{char}}.",
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 the creator's notes in exported files.",
localStorage.getItem("includeCreatorNotes") === null ? true : localStorage.getItem("includeCreatorNotes") !== "false",
);
exportOptionsSection.appendChild(creatorNotesToggle.container);
// Include Tags toggle
const includeTagsToggle = createToggle(
"includeTags",
"Include tags",
"Include character tags in exported files (PNG/JSON only). Emojis will be removed.",
localStorage.getItem("includeTags") === null ? false : localStorage.getItem("includeTags") !== "false",
);
exportOptionsSection.appendChild(includeTagsToggle.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") === null ? false : 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);
}
}
// Only restore when switching TO settings (not from settings)
if (tabKey === "settings" && previousTab !== "settings") {
// Immediate restoration before settings tab loads
inputStateManager.restoreAll(false);
// 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
});
}
// // Clean up scrollbar when switching AWAY from settings
// if (previousTab === "settings" && tabKey !== "settings") {
// if (settingsScrollbar && settingsScrollbar.destroy) {
// settingsScrollbar.destroy();
// settingsScrollbar = null;
// }
// // Clean up any remaining scrollbar elements
// const existingTrack = settingsContent.parentElement?.querySelector(
// ".custom-scrollbar-track",
// );
// if (existingTrack) {
// existingTrack.remove();
// }
// }
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)";
// Show scrollbar when settings tab becomes active
if (key === "settings") {
settingsScrollbar.show();
}
});
} else {
requestAnimationFrame(() => {
content.style.opacity = "0";
content.style.transform = "scale(0.95)";
// Hide scrollbar when settings tab becomes inactive
if (key === "settings") {
settingsScrollbar.hide();
}
});
const hideTimeout = setTimeout(() => {
if (!tabs[key].active) {
content.style.display = "none";
}
}, TAB_ANIMATION_DURATION);
animationTimeouts.push(hideTimeout);
}
tabs[key].active = isActive;
});
currentTab = tabKey;
try {
sessionStorage.setItem("lastActiveTab", tabKey);
} catch (e) {
debugWarn("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);
// Show scrollbar if settings tab is initially active
if (currentTab === "settings") {
settingsScrollbar.show();
}
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() {
debugLog('[Debug] toggleUIState called, viewActive:', viewActive);
animationTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
animationTimeouts = [];
if (guiElement && document.body.contains(guiElement)) {
debugLog('[Debug] GUI element exists in document');
if (viewActive) {
const backdrop = document.getElementById("char-export-backdrop");
const currentGuiOpacity =
parseFloat(getComputedStyle(guiElement).opacity) || 0;
const currentBackdropOpacity = backdrop ?
parseFloat(getComputedStyle(backdrop).opacity) || 0 :
0;
guiElement.style.display = "flex";
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");
const currentGuiOpacity =
parseFloat(getComputedStyle(guiElement).opacity) || 1;
const currentBackdropOpacity = backdrop ?
parseFloat(getComputedStyle(backdrop).opacity) || 1 :
0;
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]);
}
}
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) {
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;
if (type === "filename") {
window.filenameTemplate = savedValue;
filenameTemplate = savedValue;
}
input.dispatchEvent(new Event("input", {
bubbles: true
}));
}
}
return {
restoreAll: (force = false) => {
if (restorationInProgress) return;
restorationInProgress = true;
restoreInput("filename", force);
setTimeout(() => {
restorationInProgress = false;
}, 50);
},
restoreFilename: (force = false) => restoreInput("filename", force),
};
}
const inputStateManager = createInputStateManager();
function closeV() {
if (!viewActive) return;
const templateInput = document.querySelector(
'input[placeholder="Enter filename template"]',
);
debugLog('[Debug] Current chatData state:', {
exists: !!chatData,
character: chatData?.character,
messages: chatData?.chatMessages?.length
});
if (templateInput && templateInput.value) {
localStorage.setItem("filenameTemplateDraft", templateInput.value);
}
// Hide scrollbar but don't destroy it (it will be destroyed with the GUI)
settingsScrollbar.hide();
inputStateManager.restoreAll(false);
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() {
debugLog('[Debug] interceptNetwork called, networkInterceptActive:', networkInterceptActive);
if (networkInterceptActive) {
debugLog('[Debug] Network intercept already active, returning');
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 = async function(...args) {
const requestUrl = typeof args[0] === 'string' ? args[0] : args[0]?.url;
if (shouldInterceptNext && cachedProxyUrl && requestUrl === cachedProxyUrl) {
return new Response('{"choices":[]}', {
status: 200,
headers: {
'Content-Type': 'application/json'
}
});
}
try {
const res = await origFetch(...args);
if (res.ok) {
if (res.url?.includes("generateAlpha")) {
res.clone().text().then(modifyResponse).catch(() => {});
}
if (res.url?.includes("/hampter/chats/")) {
res.clone().text().then(modifyChatResponse).catch(() => {});
}
}
return res;
} catch (error) {
return new Response(null, {
status: 500,
statusText: "Userscript Handled Fetch"
});
}
};
}
function modifyResponse(text) {
debugLog('[Debug] modifyResponse called, shouldInterceptNext:', shouldInterceptNext);
if (!shouldInterceptNext) {
debugLog('[Debug] shouldInterceptNext is false, returning');
return;
}
shouldInterceptNext = false;
try {
debugLog('[Debug] Attempting to parse response');
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 = "";
let scen = "";
// --- Start of New Unified Parsing Logic ---
// 1. Isolate the main definition content, removing all system/safety prefixes.
let definitionContent = sys;
const systemTagEnd = sys.indexOf("</system>");
if (systemTagEnd !== -1) {
definitionContent = sys.substring(systemTagEnd + 9).trimStart();
} else {
// Fallback for when </system> isn't present, remove known prefixes.
const safetyNoteEnd = sys.indexOf("\n[System note:");
if (safetyNoteEnd !== -1) {
definitionContent = sys.substring(safetyNoteEnd).trimStart();
}
}
const systemNoteMatch = definitionContent.match(/^\[System note:.*?\]\s*/);
if (systemNoteMatch) {
definitionContent = definitionContent.substring(systemNoteMatch[0].length);
}
// 2. Find the boundary of the definition block (before UserPersona or example_dialogs).
const userPersonaIndex = definitionContent.indexOf("<UserPersona>");
const exampleDialogsIndex = definitionContent.indexOf("<example_dialogs>");
let boundaryIndex = -1;
if (userPersonaIndex !== -1 && exampleDialogsIndex !== -1) {
boundaryIndex = Math.min(userPersonaIndex, exampleDialogsIndex);
} else {
boundaryIndex = Math.max(userPersonaIndex, exampleDialogsIndex);
}
const definitionBlock = (boundaryIndex !== -1 ?
definitionContent.substring(0, boundaryIndex) :
definitionContent
).trim();
// 3. Split the block by the last newline to separate description and scenario.
const lastNewline = definitionBlock.lastIndexOf('\n');
if (lastNewline !== -1 && lastNewline < definitionBlock.length - 1) {
// If a newline exists and isn't the last character...
charBlock = definitionBlock.substring(0, lastNewline).trim();
scen = definitionBlock.substring(lastNewline + 1).trim();
} else {
// If there's no newline, the whole block is the description.
charBlock = definitionBlock;
scen = ""; // No scenario found.
}
// --- End of New Unified Parsing Logic ---
const rawExs = extractTagContent(sys, "example_dialogs");
const exs = rawExs.replace(
/^\s*Example conversations between[^:]*:\s*/,
"",
);
const userName = cachedUserName;
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) {
debugError("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, visited = new Set()) => {
// Base case: If obj is not a searchable object or has already been visited, stop.
if (!obj || typeof obj !== 'object' || visited.has(obj)) {
return null;
}
// Add the current object to the visited set to prevent infinite recursion.
visited.add(obj);
// Success condition: Check if the current object looks like a valid character object.
// We use a combination of keys that are highly likely to be unique to the character definition.
const isCharacterObject =
Object.prototype.hasOwnProperty.call(obj, "showdefinition") &&
Object.prototype.hasOwnProperty.call(obj, "id") &&
Object.prototype.hasOwnProperty.call(obj, "name") &&
Object.prototype.hasOwnProperty.call(obj, "personality") &&
Object.prototype.hasOwnProperty.call(obj, "first_message");
if (isCharacterObject) {
return obj; // Found it!
}
// Recursive step: Iterate over all properties of the current object.
for (const key in obj) {
// We only check own properties whose values are also objects.
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === 'object') {
const result = findCharacter(obj[key], visited);
// If a nested call finds the character, propagate the result up immediately.
if (result) {
return result;
}
}
}
// Not found in this branch of the JSON tree.
return null;
};
const extractFromJson = (json) => {
if (!json) return null;
const charObj = findCharacter(json);
if (!charObj) {
if (localStorage.getItem("showDebugLogs") === "true") {
debugLog("[getCharacterMeta] Method 1: no character object found");
}
return null;
}
if (localStorage.getItem("showDebugLogs") === "true") {
debugLog(
"[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 = (rawName || chatName).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)";
debugLog("[getCharacterMeta] name", name, `<= ${src}`);
debugLog(
"[getCharacterMeta] personality",
personalityRaw,
`<= ${src}`,
);
debugLog("[getCharacterMeta] scenario", scenarioRaw, `<= ${src}`);
debugLog(
"[getCharacterMeta] first_message",
firstMsgRaw,
`<= ${src}`,
);
debugLog(
"[getCharacterMeta] example_dialogs",
exDialogsRaw,
`<= ${src}`,
);
debugLog(
"[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.
// ---------- 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") {
debugLog(
"[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") {
debugLog("[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;
// Start searching after the "JSON.parse(" part of the string.
const startIndex = txt.indexOf("JSON.parse(") + "JSON.parse(".length;
if (startIndex === "JSON.parse(".length - 1) continue;
let openParenCount = 1;
let endIndex = -1;
// Manually scan for the matching closing parenthesis to correctly
// capture the entire argument, even with nested structures.
for (let i = startIndex; i < txt.length; i++) {
if (txt[i] === '(') {
openParenCount++;
} else if (txt[i] === ')') {
openParenCount--;
if (openParenCount === 0) {
endIndex = i;
break;
}
}
}
if (endIndex === -1) continue;
// Extract the full argument string, e.g., '"{\\"key\\":\\"value\\"}"'
const jsonArgString = txt.substring(startIndex, endIndex).trim();
try {
// The site uses a double-encoded JSON string. We must parse it twice.
// 1. Parse the JavaScript string literal to get the raw JSON content.
const jsonContent = JSON.parse(jsonArgString);
// 2. Parse the raw JSON content to get the final JavaScript object.
const dataObject = JSON.parse(jsonContent);
// Now, search for the character object within the parsed data.
metaFromJson = extractFromJson(dataObject);
if (metaFromJson) {
break; // Success! Exit the loop.
}
} catch (e) {
if (localStorage.getItem("showDebugLogs") === "true") {
debugWarn("[getCharacterMeta] Failed to parse JSON from a script tag. Skipping.");
}
continue; // This JSON wasn't what we wanted, try the next script.
}
}
} catch (parseErr) {
debugError(
"[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") {
debugLog(
"[getCharacterMeta] creator_url",
meta.creatorUrl,
"<= getCreatorUrlFromDoc",
);
if (!meta.definitionExposed) {
const src = "generateAlpha/chatData (showdefinition=false)";
debugLog(
"[getCharacterMeta] name",
meta.name ||
chatData?.character?.name ||
chatData?.character?.chat_name,
`<= ${src}`,
);
debugLog(
"[getCharacterMeta] personality",
chatData?.character?.personality || "extracted from generateAlpha",
`<= ${src}`,
);
debugLog(
"[getCharacterMeta] scenario",
chatData?.character?.scenario || "extracted from generateAlpha",
`<= ${src}`,
);
debugLog(
"[getCharacterMeta] first_message",
chatData?.character?.first_message || "extracted from generateAlpha",
`<= ${src}`,
);
debugLog(
"[getCharacterMeta] example_dialogs",
chatData?.character?.example_dialogs ||
"extracted from generateAlpha",
`<= ${src}`,
);
debugLog(
"[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") === null ? true : 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");
}
/**
* 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();
// Strip all double quotes from the final filename to ensure compatibility
result = result.replace(/"/g, '');
// 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() || "" :
"";
let updatedDate = "";
let createdDate = "";
let tagsForCard = [];
let tagsString = "";
const isDebug = localStorage.getItem("showDebugLogs") === "true";
try {
if (isDebug) debugLog(`[getFilenameTokens] Fetching character page: ${meta.characterCardUrl}`);
const response = await fetch(meta.characterCardUrl);
const html = await response.text();
if (isDebug) debugLog(`[getFilenameTokens] Successfully fetched HTML content.`);
const match = html.match(/window\.mbxM\.push\(JSON\.parse\("([\s\S]*?)"\)\)/);
if (isDebug) debugLog(`[getFilenameTokens] Regex search for 'window.mbxM.push' was ${match ? 'SUCCESSFUL' : 'FAILED'}.`);
if (match && match[1]) {
try {
if (isDebug) debugLog(`[getFilenameTokens] Match found. Attempting to parse JSON...`);
const decoded = JSON.parse(`"${match[1]}"`);
const storeState = JSON.parse(decoded);
if (isDebug) debugLog(`[getFilenameTokens] Successfully parsed storeState object.`);
let characterData = null;
for (const key in storeState) {
if (storeState[key]?.character?.tags) {
characterData = storeState[key].character;
if (isDebug) debugLog(`[getFilenameTokens] Found character data object under key: ${key}`);
break;
}
}
if (characterData) {
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);
}
const rawUniqueTags = [...new Set(allTags)];
if (isDebug) debugLog(`[getFilenameTokens] Found raw unique tags with filtering:`, rawUniqueTags);
tagsForCard = rawUniqueTags
.map((tag) =>
tag
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F\u200D]/gu, "")
.trim(),
)
.filter((tag) => tag.length > 0)
.filter((name, index, arr) => arr.indexOf(name) === index);
const tagsForFilename = rawUniqueTags
.map((tag) => {
let cleanTag = tag
.replace(/🦰/g, "")
.replace(/_{2,}/g, "_")
.replace(/\s{2,}/g, " ")
.trim();
if (/^[\p{Emoji}]/u.test(cleanTag)) {
cleanTag = cleanTag.replace(
/([\p{Emoji}\uFE0F]+)\s+([\p{Emoji}\uFE0F]+)/gu,
"$1$2",
);
cleanTag = cleanTag.replace(
/([\p{Emoji}\uFE0F]+)\s+/u,
"$1_",
);
cleanTag = cleanTag.replace(/\s/g, "");
} else {
cleanTag = cleanTag.replace(/\s/g, "");
}
return cleanTag;
})
.filter((tag) => tag.length > 0);
tagsString = tagsForFilename.join(" ");
if (isDebug) {
debugLog("[getFilenameTokens] Extracted tags for card:", tagsForCard);
debugLog("[getFilenameTokens] Extracted tags for filename:", tagsString);
}
if (characterData.created_at) {
const [year, month, day] = characterData.created_at.split("T")[0].split("-");
createdDate = `${parseInt(month)}-${parseInt(day)}-${year}`;
}
if (characterData.updated_at) {
const [year, month, day] = characterData.updated_at.split("T")[0].split("-");
updatedDate = `${parseInt(month)}-${parseInt(day)}-${year}`;
}
if (characterData.creator_name) {
creatorName = characterData.creator_name;
}
} else {
if (isDebug) debugWarn(`[getFilenameTokens] Could not find character data object within the parsed storeState.`);
}
} catch (parseError) {
if (isDebug) debugError("[getFilenameTokens] JSON parse error:", parseError);
}
}
} catch (err) {
if (isDebug) debugError("[getFilenameTokens] Failed to fetch or process character page:", err);
}
return {
id: charId,
creator: creatorName,
name: meta?.name || chatData?.character?.name || "",
chat_name: chatData?.character?.chat_name || "",
created: createdDate,
updated: updatedDate,
tags: tagsString,
tagsArray: tagsForCard,
};
}
async function saveAsTxt(charBlock, scen, initMsg, exs, charName, userName) {
debugLog('[Debug] saveAsTxt called with:', {
charBlock,
scen,
initMsg,
exs,
charName,
userName
});
const template = await buildTemplate(charBlock, scen, initMsg, exs);
debugLog('[Debug] Template built:', template);
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",
}),
);
}
/* ============================
== INTERCEPTORS ==
============================ */
function extraction() {
debugLog('[Debug] extraction() called, exportFormat:', exportFormat);
if (!exportFormat) {
debugLog('[Debug] No exportFormat set, returning');
return;
}
// Debug current page state
debugLog('[Debug] Checking page elements:', {
chatData: chatData,
hasCharacter: !!chatData?.character,
characterName: chatData?.character?.name,
bodyContent: document.body.innerHTML.substring(0, 200) // First 200 chars for debugging
});
// Remove the span check since it's failing and might not be necessary
if (!chatData || !chatData.character) {
debugLog('[Debug] No chat data or character data available');
return;
}
(async () => {
const meta = await getCharacterMeta();
// Method 1
if (meta.definitionExposed) {
const charName = meta.name;
const userName = cachedUserName;
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() {
debugLog('[Debug] callApi called');
try {
const textarea = document.querySelector("textarea");
if (!textarea) {
debugLog('[Debug] No textarea found');
return;
}
debugLog('[Debug] Found textarea:', textarea.value);
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,
tagsArray,
avatarUrl,
) {
const {
creatorUrl,
characterVersion,
name: metaName,
creatorNotes
} =
await getCharacterMeta();
const tokenizedDesc = tokenizeField(charBlock, charName, userName);
const tokenizedScen = tokenizeField(scen, charName, userName);
const tokenizedExs = tokenizeField(exs, charName, userName);
let displayName;
if (useChatNameForName) {
displayName =
(
chatData?.character?.chat_name ||
metaName ||
chatData?.character?.name ||
""
).trim();
} else {
displayName =
(
metaName ||
chatData?.character?.name ||
chatData?.character?.chat_name ||
""
).trim();
}
if (displayName) {
displayName = displayName.replace(/"/g, '');
}
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}`;
}
}
let includeCreatorNotes;
if (localStorage.getItem("includeCreatorNotes") === null) {
includeCreatorNotes = true; // default for first-time users
localStorage.setItem("includeCreatorNotes", "true");
} else {
includeCreatorNotes = localStorage.getItem("includeCreatorNotes") !== "false";
}
let includeTags;
if (localStorage.getItem("includeTags") === null) {
includeTags = false; // default for first-time users
localStorage.setItem("includeTags", "false");
} else {
includeTags = localStorage.getItem("includeTags") !== "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: includeTags ? tagsArray || [] : [],
creator: creatorUrl,
character_version: versionText,
avatar: avatarUrl,
extensions: {},
},
};
}
/* ============================
== EXPORTERS ==
============================ */
async function saveAsJson(charName, charBlock, scen, initMsg, exs, userName) {
const meta = await getCharacterMeta();
const tokens = await getFilenameTokens(meta);
const avatarImg = document.querySelector('img[src*="/bot-avatars/"]');
const avatarUrl = avatarImg ? avatarImg.src : 'none';
const jsonData = await buildCharaCardV2(
charName,
charBlock,
scen,
initMsg,
exs,
userName,
tokens.tagsArray
// avatarUrl,
);
// Use filename template system
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 meta = await getCharacterMeta();
const tokens = await getFilenameTokens(meta);
const cardData = await buildCharaCardV2(
charName,
charBlock,
scen,
initMsg,
exs,
userName,
tokens.tagsArray,
);
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 currentTemplate =
localStorage.getItem("filenameTemplate") || "{name}";
const fileName =
buildFilenameFromTemplate(currentTemplate, tokens) || "export";
saveFile(
`${fileName}.png`,
new Blob([finalPNG], {
type: "image/png",
}),
);
debugLog("Character card created successfully!");
} catch (err) {
debugError("Error creating PNG:", err);
alert("Failed to create PNG: " + err.message);
}
}, "image/png");
};
img.src = URL.createObjectURL(avatarBlob);
} catch (err) {
debugError("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;
document.removeEventListener("keydown", handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
interceptNetwork();
}
function handleKeyDown(e) {
debugLog('[Debug] handleKeyDown called', e.key);
if (!inChats()) {
debugLog('[Debug] Not in chats, returning');
return;
}
if (
e.key.toLowerCase() !== "t" ||
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey
) {
debugLog('[Debug] Key combination not matched');
return;
}
if (
["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) ||
document.activeElement.isContentEditable
) {
debugLog('[Debug] Active element is input/textarea, returning');
return;
}
if (!chatData || !chatData.character || !chatData.character.allow_proxy) {
debugLog('[Debug] Chat data validation failed:', {
hasChatData: !!chatData,
hasCharacter: chatData?.character,
allowProxy: chatData?.character?.allow_proxy
});
if (chatData && chatData.character) {
alert("Proxy disabled — extraction aborted.");
}
return;
}
viewActive = !viewActive;
debugLog('[Debug] viewActive toggled to:', viewActive);
toggleUIState();
}
function cleanup() {
hasInitialized = false;
const gui = document.getElementById("char-export-gui");
if (gui) gui.remove();
const backdrop = document.getElementById("char-export-backdrop");
if (backdrop) backdrop.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();
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();
}
try {
if (typeof unsafeWindow === 'undefined' || unsafeWindow === window) {
runInPageContext();
} else {
const s = document.createElement('script');
s.textContent = '(' + runInPageContext.toString() + ')();';
document.documentElement.appendChild(s);
s.remove();
}
} catch (e) {
const s = document.createElement('script');
s.textContent = '(' + runInPageContext.toString() + ')();';
document.documentElement.appendChild(s);
s.remove();
}
})();