Merge pull request #2 from comfyuiattic-989/claude/lora-metadata-display-RlFd6
Add LoRA metadata popup to file browser
This commit is contained in:
@@ -17,6 +17,7 @@ External LoRA Loader solves this by letting you browse to any path directly from
|
||||
- **Drive auto-detection** — Automatically detects all mounted drives on Windows, macOS, and Linux at startup
|
||||
- **Tree-style file browser** — Click Browse to open a modal with a full expandable drive/folder tree; navigate to any location without typing paths
|
||||
- **Extension filter** — Filter the browser to Safetensors only, all LoRA types, PyTorch files, or all files
|
||||
- **LoRA metadata popup** — Single-click any `.safetensors` file to open a tabbed info panel showing base model, rank/alpha, training stats, trigger tags, and author notes without loading the file
|
||||
- **Draggable and resizable modal** — Drag the browser by its header; resize from the bottom-right corner
|
||||
- **Keyboard navigation** — Press Enter to confirm a selection, Escape to close
|
||||
- **System RAM caching** — LoRAs are loaded into memory on first use; subsequent runs skip disk I/O entirely
|
||||
@@ -81,12 +82,27 @@ Click the **Browse…** button on the node. A file browser modal opens, showing
|
||||
- **Checkpoint** — `.ckpt` only
|
||||
- **PyTorch** — `.pt` and `.pth`
|
||||
- **All files** — every non-hidden file
|
||||
- Click a LoRA file to select it; the filename appears in the status bar
|
||||
- Click a LoRA file to select it and open the **metadata popup** (see below)
|
||||
- Click **Select**, double-click the file, or press **Enter** to confirm
|
||||
- Press **Escape** or click **✕** to cancel
|
||||
|
||||
The modal is draggable (grab the title bar) and resizable (drag the bottom-right corner). It remembers its position and size for the session.
|
||||
|
||||
#### Metadata popup
|
||||
|
||||
Single-clicking any `.safetensors` file opens a floating info panel to the right of the browser. It reads the file's embedded header without loading the full model into memory and displays four tabs:
|
||||
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| **Overview** | File size, last modified, base model version, LoRA rank (dim), alpha, effective scale (alpha ÷ dim) |
|
||||
| **Training** | Step count, epochs, number of training images, resolution, UNet and text-encoder learning rates |
|
||||
| **Tags** | Top trigger tags extracted from `ss_tag_frequency`, sorted by frequency and shown as chips |
|
||||
| **About** | Title, author, license, and training notes / trigger-word hints left by the creator |
|
||||
|
||||
Files without embedded metadata (non-safetensors formats, or safetensors files that omit `__metadata__`) still show filesystem info in the Overview tab; the remaining tabs display a "No embedded metadata" message.
|
||||
|
||||
The popup closes when you click a folder, confirm a selection, or close the browser.
|
||||
|
||||
After confirming, the selected path is shown in the **selected_path** display on the node canvas.
|
||||
|
||||
### 3. Set strength
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""
|
||||
api.py — aiohttp route handlers for the External LoRA Loader custom node.
|
||||
|
||||
Registers six routes on ComfyUI's aiohttp server:
|
||||
Registers seven routes on ComfyUI's aiohttp server:
|
||||
POST /external_lora/list_files — list .safetensors/.ckpt files in a directory
|
||||
POST /external_lora/clear_cache — flush the in-memory LoRA tensor cache
|
||||
GET /external_lora/cache_stats — return current and max cache usage in MB
|
||||
GET /external_lora/loras_folder — return ComfyUI's configured loras folder
|
||||
GET /external_lora/drives — return detected drive/mount-point list
|
||||
POST /external_lora/browse — browse directories and files interactively
|
||||
GET /external_lora/metadata — return file stats and embedded safetensors metadata
|
||||
"""
|
||||
|
||||
import os
|
||||
import glob
|
||||
import json as _json
|
||||
import platform
|
||||
import struct
|
||||
from datetime import datetime
|
||||
|
||||
import psutil
|
||||
from aiohttp import web
|
||||
@@ -222,6 +226,59 @@ async def _browse_handler(request: web.Request) -> web.Response:
|
||||
return web.json_response({"dirs": dirs, "files": files, "error": None})
|
||||
|
||||
|
||||
async def _metadata_handler(request: web.Request) -> web.Response:
|
||||
"""GET /external_lora/metadata?drive=<drive>&path=<relative_path>
|
||||
|
||||
Returns file system stats and, for .safetensors files, the embedded
|
||||
__metadata__ dict parsed from the safetensors header.
|
||||
|
||||
Response JSON:
|
||||
{ "file_size_mb": float, "modified": "YYYY-MM-DDTHH:MM:SS",
|
||||
"metadata": dict | null, "error": str | null }
|
||||
"""
|
||||
drive = request.rel_url.query.get("drive", "")
|
||||
path = request.rel_url.query.get("path", "")
|
||||
|
||||
if not drive and not path:
|
||||
return web.json_response({"file_size_mb": None, "modified": None,
|
||||
"metadata": None, "error": "missing path"})
|
||||
|
||||
full_path = os.path.normpath(os.path.join(drive, path))
|
||||
|
||||
if not _is_within_drive(full_path, DRIVE_LIST):
|
||||
return web.json_response({"file_size_mb": None, "modified": None,
|
||||
"metadata": None, "error": "forbidden"})
|
||||
|
||||
if not os.path.isfile(full_path):
|
||||
return web.json_response({"file_size_mb": None, "modified": None,
|
||||
"metadata": None, "error": "not_found"})
|
||||
|
||||
stat = os.stat(full_path)
|
||||
size_mb = round(stat.st_size / (1024 * 1024), 2)
|
||||
modified = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
metadata = None
|
||||
if full_path.lower().endswith(".safetensors"):
|
||||
try:
|
||||
with open(full_path, "rb") as f:
|
||||
raw = f.read(8)
|
||||
if len(raw) == 8:
|
||||
header_len = struct.unpack_from("<Q", raw)[0]
|
||||
# Sanity-check: reject absurdly large headers (>64 MB)
|
||||
if header_len <= 64 * 1024 * 1024:
|
||||
header = _json.loads(f.read(header_len))
|
||||
metadata = header.get("__metadata__", {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({
|
||||
"file_size_mb": size_mb,
|
||||
"modified": modified,
|
||||
"metadata": metadata,
|
||||
"error": None,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route registration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -237,3 +294,4 @@ def register_routes(routes) -> None:
|
||||
routes.get("/external_lora/loras_folder")(_loras_folder_handler)
|
||||
routes.get("/external_lora/drives")(_drives_handler)
|
||||
routes.post("/external_lora/browse")(_browse_handler)
|
||||
routes.get("/external_lora/metadata")(_metadata_handler)
|
||||
|
||||
+326
@@ -139,6 +139,99 @@ function injectStyles() {
|
||||
transparent 80%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.ell-meta-popup {
|
||||
position: fixed;
|
||||
background: var(--comfy-menu-bg, #353535);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.6);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 10000;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--fg-color, #ddd);
|
||||
min-width: 200px;
|
||||
}
|
||||
.ell-meta-popup-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ell-meta-popup-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--input-text, #aaa);
|
||||
font-weight: bold;
|
||||
}
|
||||
.ell-meta-popup-close {
|
||||
background: none; border: none; color: var(--fg-color, #fff);
|
||||
cursor: pointer; font-size: 16px; line-height: 1; padding: 2px 4px;
|
||||
}
|
||||
.ell-meta-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #4e4e4e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ell-meta-tab {
|
||||
flex: 1;
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--input-text, #aaa); cursor: pointer;
|
||||
padding: 6px 4px; font-size: 11px;
|
||||
}
|
||||
.ell-meta-tab:hover { color: var(--fg-color, #ddd); }
|
||||
.ell-meta-tab-active {
|
||||
color: #fff !important;
|
||||
border-bottom-color: #0078d4 !important;
|
||||
}
|
||||
.ell-meta-body {
|
||||
flex: 1; overflow-y: auto; padding: 8px;
|
||||
background: var(--comfy-input-bg, #1a1a1a);
|
||||
}
|
||||
.ell-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.ell-meta-key {
|
||||
color: var(--input-text, #888);
|
||||
font-size: 11px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ell-meta-val {
|
||||
color: var(--fg-color, #ddd);
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ell-meta-empty {
|
||||
color: var(--input-text, #666);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 24px 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
.ell-meta-loading {
|
||||
color: var(--input-text, #888);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.ell-meta-tag-list {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.ell-meta-tag {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #ddd);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -153,6 +246,11 @@ let _lorasFolderCache = undefined;
|
||||
// Last extension filter the user chose — persists across opens and nodes
|
||||
let _lastExtFilter = ".safetensors"; // default: Safetensors only
|
||||
|
||||
// Metadata popup state (shared across all node instances; only one popup shown at a time)
|
||||
let metaPopupEl = null;
|
||||
let _metaActiveTab = "overview";
|
||||
let _metaFetchSeq = 0;
|
||||
|
||||
async function _fetchLorasFolder() {
|
||||
if (_lorasFolderCache) return _lorasFolderCache; // only cache successes; failures retry next open
|
||||
try {
|
||||
@@ -174,6 +272,229 @@ function _extParam(filter) {
|
||||
return filter.split(","); // e.g. ".pt,.pth"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata popup (module-level; shared across nodes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildMetaPopup() {
|
||||
if (metaPopupEl) return;
|
||||
metaPopupEl = document.createElement("div");
|
||||
metaPopupEl.className = "ell-meta-popup";
|
||||
metaPopupEl.style.display = "none";
|
||||
|
||||
const popHeader = document.createElement("div");
|
||||
popHeader.className = "ell-meta-popup-header";
|
||||
const popTitle = document.createElement("span");
|
||||
popTitle.className = "ell-meta-popup-title";
|
||||
popTitle.textContent = "LoRA Info";
|
||||
const popClose = document.createElement("button");
|
||||
popClose.className = "ell-meta-popup-close";
|
||||
popClose.textContent = "\u2715";
|
||||
popClose.onclick = hideMetaPopup;
|
||||
popHeader.appendChild(popTitle);
|
||||
popHeader.appendChild(popClose);
|
||||
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.className = "ell-meta-tabs";
|
||||
for (const [id, label] of [["overview","Overview"],["training","Training"],["tags","Tags"],["about","About"]]) {
|
||||
const tab = document.createElement("button");
|
||||
tab.className = "ell-meta-tab" + (id === _metaActiveTab ? " ell-meta-tab-active" : "");
|
||||
tab.textContent = label;
|
||||
tab.dataset.tab = id;
|
||||
tab.onclick = () => {
|
||||
_metaActiveTab = id;
|
||||
metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t =>
|
||||
t.classList.toggle("ell-meta-tab-active", t.dataset.tab === id));
|
||||
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p =>
|
||||
p.style.display = p.dataset.panel === id ? "" : "none");
|
||||
};
|
||||
tabBar.appendChild(tab);
|
||||
}
|
||||
|
||||
const popBody = document.createElement("div");
|
||||
popBody.className = "ell-meta-body";
|
||||
for (const id of ["overview","training","tags","about"]) {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "ell-meta-panel";
|
||||
panel.dataset.panel = id;
|
||||
panel.style.display = id === _metaActiveTab ? "" : "none";
|
||||
popBody.appendChild(panel);
|
||||
}
|
||||
|
||||
metaPopupEl.appendChild(popHeader);
|
||||
metaPopupEl.appendChild(tabBar);
|
||||
metaPopupEl.appendChild(popBody);
|
||||
document.body.appendChild(metaPopupEl);
|
||||
}
|
||||
|
||||
function positionMetaPopup(boxEl) {
|
||||
if (!metaPopupEl || !boxEl) return;
|
||||
const rect = boxEl.getBoundingClientRect();
|
||||
const popW = 280;
|
||||
const gap = 8;
|
||||
const vpW = window.innerWidth;
|
||||
|
||||
let left;
|
||||
if (rect.right + gap + popW <= vpW) {
|
||||
left = rect.right + gap;
|
||||
metaPopupEl.style.opacity = "1";
|
||||
} else {
|
||||
left = Math.max(rect.right - popW - 4, rect.left);
|
||||
metaPopupEl.style.opacity = "0.96";
|
||||
}
|
||||
metaPopupEl.style.left = left + "px";
|
||||
metaPopupEl.style.top = rect.top + "px";
|
||||
metaPopupEl.style.width = popW + "px";
|
||||
metaPopupEl.style.height = rect.height + "px";
|
||||
}
|
||||
|
||||
function _renderMetaPanels(result) {
|
||||
if (!metaPopupEl) return;
|
||||
const meta = (result && result.metadata) || {};
|
||||
const noMeta = !(result && result.metadata);
|
||||
|
||||
function grid(rows) {
|
||||
if (!rows.length) return '<div class="ell-meta-empty">No data</div>';
|
||||
return '<div class="ell-meta-grid">' +
|
||||
rows.map(([k, v]) =>
|
||||
`<span class="ell-meta-key">${_escHtml(k)}</span>` +
|
||||
`<span class="ell-meta-val">${v ?? "\u2014"}</span>`
|
||||
).join("") + "</div>";
|
||||
}
|
||||
|
||||
const noEmbed = '<div class="ell-meta-empty">No embedded metadata</div>';
|
||||
|
||||
// --- Overview ---
|
||||
const ov = [];
|
||||
if (result && result.file_size_mb != null) ov.push(["Size", result.file_size_mb + " MB"]);
|
||||
if (result && result.modified) ov.push(["Modified", result.modified.replace("T", " ")]);
|
||||
if (meta.ss_base_model_version) ov.push(["Base model", _escHtml(meta.ss_base_model_version)]);
|
||||
const dim = meta.ss_network_dim;
|
||||
const alpha = meta.ss_network_alpha;
|
||||
if (dim) ov.push(["Rank (dim)", _escHtml(dim)]);
|
||||
if (alpha) ov.push(["Alpha", _escHtml(alpha)]);
|
||||
if (dim && alpha) {
|
||||
const d = parseFloat(dim), a = parseFloat(alpha);
|
||||
if (d > 0) ov.push(["Eff. scale", (a / d).toFixed(3)]);
|
||||
}
|
||||
if (meta.ss_network_module)
|
||||
ov.push(["Network", _escHtml(meta.ss_network_module.split(".").pop())]);
|
||||
|
||||
// --- Training ---
|
||||
const tr = [];
|
||||
if (!noMeta) {
|
||||
if (meta.ss_steps) tr.push(["Steps", _escHtml(meta.ss_steps)]);
|
||||
if (meta.ss_epoch) tr.push(["Epochs", _escHtml(meta.ss_epoch)]);
|
||||
if (meta.ss_num_train_images) tr.push(["Train images", _escHtml(meta.ss_num_train_images)]);
|
||||
if (meta.ss_num_reg_images) tr.push(["Reg images", _escHtml(meta.ss_num_reg_images)]);
|
||||
if (meta.ss_resolution) tr.push(["Resolution", _escHtml(meta.ss_resolution)]);
|
||||
if (meta.ss_unet_lr) tr.push(["UNet LR", _escHtml(meta.ss_unet_lr)]);
|
||||
if (meta.ss_text_encoder_lr) tr.push(["TE LR", _escHtml(meta.ss_text_encoder_lr)]);
|
||||
else if (meta.ss_learning_rate) tr.push(["LR", _escHtml(meta.ss_learning_rate)]);
|
||||
}
|
||||
|
||||
// --- Tags ---
|
||||
let tagHtml = noMeta ? noEmbed : "";
|
||||
if (!noMeta) {
|
||||
let tagFreq = meta.ss_tag_frequency;
|
||||
if (typeof tagFreq === "string") {
|
||||
try { tagFreq = JSON.parse(tagFreq); } catch { tagFreq = null; }
|
||||
}
|
||||
if (tagFreq && typeof tagFreq === "object" && !Array.isArray(tagFreq)) {
|
||||
const merged = {};
|
||||
for (const dir of Object.values(tagFreq)) {
|
||||
if (dir && typeof dir === "object") {
|
||||
for (const [tag, count] of Object.entries(dir)) {
|
||||
merged[tag] = (merged[tag] || 0) + Number(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sorted = Object.entries(merged).sort((a, b) => b[1] - a[1]).slice(0, 40);
|
||||
tagHtml = sorted.length
|
||||
? '<div class="ell-meta-tag-list">' +
|
||||
sorted.map(([tag, cnt]) =>
|
||||
`<span class="ell-meta-tag" title="${cnt} occurrences">${_escHtml(tag)}</span>`
|
||||
).join("") + "</div>"
|
||||
: '<div class="ell-meta-empty">No tags</div>';
|
||||
} else {
|
||||
tagHtml = '<div class="ell-meta-empty">No tag data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- About ---
|
||||
const ab = [];
|
||||
if (!noMeta) {
|
||||
const title = meta["modelspec.title"] || meta.ss_output_name;
|
||||
const author = meta["modelspec.author"];
|
||||
const desc = meta["modelspec.description"] || meta.ss_training_comment;
|
||||
const license = meta["modelspec.license"];
|
||||
const tags = meta["modelspec.tags"];
|
||||
if (title) ab.push(["Title", _escHtml(title)]);
|
||||
if (author) ab.push(["Author", _escHtml(author)]);
|
||||
if (license) ab.push(["License", _escHtml(license)]);
|
||||
if (tags) ab.push(["Tags", _escHtml(tags)]);
|
||||
if (desc) ab.push(["Notes",
|
||||
`<span style="white-space:pre-wrap;display:block">${_escHtml(desc)}</span>`]);
|
||||
}
|
||||
|
||||
const content = {
|
||||
overview: grid(ov),
|
||||
training: noMeta ? noEmbed : (tr.length ? grid(tr) : '<div class="ell-meta-empty">No training data</div>'),
|
||||
tags: tagHtml,
|
||||
about: noMeta ? noEmbed : (ab.length ? grid(ab) : '<div class="ell-meta-empty">No description</div>'),
|
||||
};
|
||||
|
||||
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => {
|
||||
p.innerHTML = content[p.dataset.panel] || "";
|
||||
});
|
||||
}
|
||||
|
||||
async function showMetaPopup(data, boxEl) {
|
||||
buildMetaPopup();
|
||||
positionMetaPopup(boxEl);
|
||||
metaPopupEl.style.display = "flex";
|
||||
|
||||
// Sync tab active state in case it changed since last open
|
||||
metaPopupEl.querySelectorAll(".ell-meta-tab").forEach(t =>
|
||||
t.classList.toggle("ell-meta-tab-active", t.dataset.tab === _metaActiveTab));
|
||||
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p =>
|
||||
p.style.display = p.dataset.panel === _metaActiveTab ? "" : "none");
|
||||
|
||||
// Loading state in every panel
|
||||
metaPopupEl.querySelectorAll(".ell-meta-panel").forEach(p => {
|
||||
p.innerHTML = '<div class="ell-meta-loading">Loading\u2026</div>';
|
||||
});
|
||||
|
||||
const seq = ++_metaFetchSeq;
|
||||
const params = new URLSearchParams({
|
||||
drive: data.drive,
|
||||
path: data.segments.join("/"),
|
||||
});
|
||||
|
||||
let result = null;
|
||||
try {
|
||||
const resp = await fetch("/external_lora/metadata?" + params);
|
||||
if (resp.ok) result = await resp.json();
|
||||
} catch {}
|
||||
|
||||
if (seq !== _metaFetchSeq) return; // a newer file was clicked
|
||||
if (!metaPopupEl || metaPopupEl.style.display === "none") return;
|
||||
|
||||
_renderMetaPanels(result || {});
|
||||
}
|
||||
|
||||
function hideMetaPopup() {
|
||||
if (metaPopupEl) metaPopupEl.style.display = "none";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.registerExtension({
|
||||
name: "ExternalLoraLoader",
|
||||
|
||||
@@ -289,6 +610,7 @@ app.registerExtension({
|
||||
|
||||
// 6. Modal DOM (built lazily on first open)
|
||||
let modalOverlay = null;
|
||||
let modalBoxEl = null;
|
||||
let treeContainerEl = null;
|
||||
let statusEl = null;
|
||||
let selectBtn = null;
|
||||
@@ -412,10 +734,12 @@ app.registerExtension({
|
||||
}
|
||||
if (statusEl) statusEl.textContent = data.name;
|
||||
renderTree();
|
||||
showMetaPopup(data, modalBoxEl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Folder / drive — lazy load on first expand, then toggle
|
||||
hideMetaPopup();
|
||||
if (!data._loaded) {
|
||||
if (statusEl) statusEl.textContent = "Loading\u2026";
|
||||
const path = data.segments.join("/");
|
||||
@@ -601,6 +925,7 @@ app.registerExtension({
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "ell-modal-box";
|
||||
modalBoxEl = box;
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
@@ -766,6 +1091,7 @@ app.registerExtension({
|
||||
|
||||
function closeModal() {
|
||||
if (modalOverlay) modalOverlay.style.display = "none";
|
||||
hideMetaPopup();
|
||||
// Remove Escape key handler to avoid memory leak
|
||||
if (onKeyDown) {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
|
||||
Reference in New Issue
Block a user