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