Files
enkly-ai/services/gateway/web/static/js/app.js
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

674 lines
28 KiB
JavaScript

/*
* enkly — app.js
*
* A small, dependency-free enhancement layer. It is served same-origin
* (the server's Content-Security-Policy is script-src 'self'), and none of
* it uses eval or inline handlers. Everything here is optional except the
* "clear the composer after sending" behaviour, which replaces what used to
* be an hx-on attribute (htmx evaluates hx-on with eval(), which the CSP
* blocks).
*/
(function () {
"use strict";
var chat = document.getElementById("chat-window");
var input = document.getElementById("composer-input");
/* ------------------------------------------------------------------
* Auto-scroll: follow the stream unless the user scrolled up to read.
* ------------------------------------------------------------------ */
var stickToBottom = true;
function nearBottom() {
return chat.scrollHeight - chat.scrollTop - chat.clientHeight < 120;
}
function scrollToBottom(smooth) {
chat.scrollTo({ top: chat.scrollHeight, behavior: smooth ? "smooth" : "auto" });
}
if (chat) {
scrollToBottom(false);
chat.addEventListener("scroll", function () { stickToBottom = nearBottom(); }, { passive: true });
new MutationObserver(function () {
if (stickToBottom) scrollToBottom(false);
}).observe(chat, { childList: true, subtree: true, characterData: true });
}
/* ------------------------------------------------------------------
* Composer: auto-resize, Enter to send, clear after a successful send.
* ------------------------------------------------------------------ */
function resizeInput() {
if (!input) return;
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 220) + "px";
}
// A send is valid once there's either typed text or at least one
// attachment that isn't still mid-upload (see "Composer attachments"
// below) — an attachment-only message is fine, same as Claude Code.
function canSend() {
if (input && input.value.trim() !== "") return true;
return attachmentsReadyCount() > 0;
}
if (input) {
input.addEventListener("input", resizeInput);
input.addEventListener("keydown", function (e) {
// Enter sends; Shift+Enter inserts a newline; ignore Enter while an
// IME composition (CJK input, etc.) is confirming a candidate.
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
e.preventDefault();
if (canSend() && input.form) input.form.requestSubmit();
}
});
input.focus();
// Covers the case where the browser restores a typed-but-unsent value
// on reload (bfcache / "preserve form on back"), so the box already
// matches its content's height instead of starting at one line.
resizeInput();
}
// Block the actual request (rather than just the Enter shortcut above)
// if an attachment is still uploading when Send is clicked directly, or
// let it through with nothing at all so the server's own "Type a
// message first." validation handles that case exactly as before.
document.body.addEventListener("htmx:beforeRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (!elt || !elt.classList || !elt.classList.contains("composer-form")) return;
if (attachmentsPendingCount() > 0) {
e.preventDefault();
toast("Still uploading \u2014 one moment.", true);
}
});
// htmx fires this after the /chat request completes. Clear the box and
// re-enable following the stream (sending a message means "I'm here").
document.body.addEventListener("htmx:afterRequest", function (e) {
var elt = e.detail && e.detail.elt;
if (elt && elt.classList && elt.classList.contains("composer-form") && e.detail.successful) {
elt.reset();
resizeInput();
clearAttachments();
stickToBottom = true;
if (chat) scrollToBottom(true);
if (input) input.focus();
}
});
/* ------------------------------------------------------------------
* Copy button on code blocks (event delegation: works for blocks that
* arrive later over SSE).
* ------------------------------------------------------------------ */
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy") ? resolve() : reject(new Error("copy failed"));
} catch (err) {
reject(err);
} finally {
document.body.removeChild(ta);
}
});
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".copy-btn");
if (!btn) return;
var block = btn.closest(".code-block");
var code = block && block.querySelector("pre code");
if (!code) return;
copyText(code.innerText).then(function () {
btn.textContent = "Copied";
}, function () {
btn.textContent = "Press Ctrl+C";
}).then(function () {
setTimeout(function () { btn.textContent = "Copy"; }, 1500);
});
});
/* ------------------------------------------------------------------
* Snippet download cards: build the file in the browser from the code
* block the card refers to (the Nth .code-block in the same reply), so
* no server round-trip or temp file is needed. File cards are plain
* links and need no JS.
* ------------------------------------------------------------------ */
// SNIPPET-DOWNLOAD:BEGIN (scripts/test_snippet_download.mjs extracts this block)
function downloadSnippet(btn) {
var bubble = btn.closest(".bubble");
var blocks = bubble ? bubble.querySelectorAll(".code-block pre code") : [];
var idx = parseInt(btn.getAttribute("data-snippet-index"), 10);
var code = isNaN(idx) ? null : blocks[idx];
if (!code) return false;
// textContent (not innerText) so the file is exactly the source text,
// regardless of the highlight <span>s or CSS.
var blob = new Blob([code.textContent], { type: "text/plain;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = btn.getAttribute("data-filename") || "snippet.txt";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
return true;
}
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".snippet-download");
if (!btn) return;
var ok = downloadSnippet(btn);
var label = btn.getAttribute("data-label") || btn.textContent;
btn.setAttribute("data-label", label);
btn.textContent = ok ? "Downloaded" : "Unavailable";
setTimeout(function () { btn.textContent = label; }, 1500);
});
// SNIPPET-DOWNLOAD:END
/* ------------------------------------------------------------------
* Stop button: cancel via fetch instead of navigating away from the
* live stream. (Without JS the form still works — it POSTs and the
* server redirects back to the conversation.)
* ------------------------------------------------------------------ */
document.addEventListener("submit", function (e) {
var form = e.target;
if (!form.classList || !form.classList.contains("stop-form")) return;
e.preventDefault();
var btn = form.querySelector("button");
if (btn) btn.disabled = true;
fetch(form.action, {
method: "POST",
headers: { "X-Requested-With": "fetch", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(form)),
credentials: "same-origin",
}).catch(function () {
if (btn) btn.disabled = false;
});
});
/* ------------------------------------------------------------------
* Thinking indicator: time it, then collapse it once the reply starts.
*
* The card is rendered open by default (see chat_response.html) so
* reasoning is visible live, the same way it streams in before the
* answer on claude.ai — no JS needed for that part. What JS adds is
* purely cosmetic: once the reply actually begins, collapse the card
* into a "Thought for Ns" summary instead of leaving it expanded
* under the finished answer. htmx-ext-sse fires a bubbling
* "htmx:sseBeforeMessage" for every named SSE event it receives, with
* detail.type set to that event's name ("thinking", "token", "done",
* "tool") and evt.target set to whichever sse-swap element matched —
* we don't touch the actual content swap here, htmx-ext-sse already
* does that; this only reads the event to time things.
* ------------------------------------------------------------------ */
var thinkingStartedAt = new WeakMap();
document.body.addEventListener("htmx:sseBeforeMessage", function (e) {
var type = e.detail && e.detail.type;
if (type !== "thinking" && type !== "token" && type !== "done") return;
var row = e.target.closest && e.target.closest(".message-row");
var card = row && row.querySelector(".thinking-card");
if (!card || card.dataset.settled) return;
if (type === "thinking") {
if (!thinkingStartedAt.has(card)) thinkingStartedAt.set(card, Date.now());
return;
}
// First "token" or "done" seen for this row: settle the card once,
// whether or not it ever actually thought out loud.
card.dataset.settled = "1";
if (thinkingStartedAt.has(card)) {
var seconds = Math.max(1, Math.round((Date.now() - thinkingStartedAt.get(card)) / 1000));
var label = card.querySelector(".thinking-elapsed");
if (label) label.textContent = "\u00B7 Thought for " + seconds + "s";
card.open = false;
}
});
/* ------------------------------------------------------------------
* Composer attachments — "Add files or photos", strongly inspired by
* Claude Code's chat input: click the paperclip, drop a file anywhere
* on the page (dropping right on the message box highlights it, like
* claude.ai), or paste a screenshot, and it becomes a chip in the tray
* above the textarea. Each chip uploads in the background the moment
* it appears — one fetch per file, to POST /files/attach (see
* internal/handlers/attachments.go) — so sending the message is just
* the composer form's normal htmx POST /chat; the uploaded workspace
* path already rides along as a hidden input (see addHiddenInput
* below). Remove a chip before sending to drop it from the message;
* the file stays in the workspace (nothing currently reaps unattached
* uploads — a known, accepted gap, same spirit as the ones called out
* in ARCHITECTURE.md).
* ------------------------------------------------------------------ */
var tray = document.getElementById("composer-attachments");
var attachFileInput = document.getElementById("composer-file-input");
var attachBtn = document.getElementById("attach-btn");
var composerForm = input && input.closest("form");
var attachments = Object.create(null); // clientId -> { status, path, controller }
var attachSeq = 0;
var maxAttachments = 8; // mirrors maxChatAttachments server-side
function toast(msg, isError) {
var el = document.createElement("div");
el.className = "drop-toast" + (isError ? " is-error" : "");
el.setAttribute("role", "status");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.classList.add("is-leaving"); }, 3200);
setTimeout(function () { el.remove(); }, 3700);
}
function attachmentsReadyCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "done") n++;
return n;
}
function attachmentsPendingCount() {
var n = 0;
for (var id in attachments) if (attachments[id].status === "uploading") n++;
return n;
}
function attachmentsCount() {
return Object.keys(attachments).length;
}
function fileIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/>' +
'<path d="M14 3v5h5"/></svg>';
}
// Shown for a .zip while it's uploading/extracting, and again on the
// sent message's chip (see attachments.html) once the server confirms
// it really was one — see isZipName / markChipAsArchive below.
function archiveIconSVG() {
return '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" ' +
'stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
}
function isZipName(name) {
return /\.zip$/i.test(name || "");
}
// Plain "path" for a regular file; "path|N" for an extracted zip's
// folder, so cleanAttachmentPaths (internal/handlers/attachments.go)
// can tell the two apart when the composer form is submitted.
function addHiddenInput(id, path, entryCount) {
var el = document.createElement("input");
el.type = "hidden";
el.name = "attachments";
el.value = entryCount ? path + "|" + entryCount : path;
el.dataset.attachId = id;
composerForm.appendChild(el);
}
// Swaps a settled chip's icon for the folder glyph and appends the
// "N files" count once /files/attach confirms the zip was extracted.
function markChipAsArchive(id, entryCount) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.add("is-archive");
var preview = chip.querySelector(".attach-chip-preview");
if (preview) preview.innerHTML = archiveIconSVG();
var name = chip.querySelector(".attach-chip-name");
if (name) name.textContent = name.textContent + " \u00B7 " + entryCount + (entryCount === 1 ? " file" : " files");
}
function removeAttachment(id) {
var rec = attachments[id];
if (!rec) return;
if (rec.controller) rec.controller.abort();
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (chip) chip.remove();
var hidden = composerForm && composerForm.querySelector('input[data-attach-id="' + id + '"]');
if (hidden) hidden.remove();
delete attachments[id];
}
function clearAttachments() {
for (var id in attachments) removeAttachment(id);
tray.innerHTML = "";
}
function settleChip(id, ok, label) {
var chip = tray.querySelector('[data-attach-id="' + id + '"]');
if (!chip) return;
chip.classList.remove("is-uploading");
chip.classList.toggle("is-error", !ok);
if (!ok) chip.title = label || "Upload failed \u2014 click to remove";
}
function renderChip(id, file) {
var chip = document.createElement("div");
chip.className = "attach-chip is-uploading";
chip.dataset.attachId = id;
var preview = document.createElement("div");
preview.className = "attach-chip-preview";
if (file.type && file.type.indexOf("image/") === 0 && typeof FileReader === "function") {
// A data: URL, not blob: — the page's img-src CSP (see
// internal/handlers/middleware.go) allows 'self' and data: only, so
// this is the one local-preview mechanism that actually renders.
var reader = new FileReader();
reader.onload = function () {
if (!attachments[id]) return; // chip was removed before this fired
var img = document.createElement("img");
img.src = reader.result;
img.alt = "";
preview.insertBefore(img, preview.firstChild);
};
reader.readAsDataURL(file);
} else if (isZipName(file.name)) {
preview.innerHTML = archiveIconSVG();
} else {
preview.innerHTML = fileIconSVG();
}
var spinner = document.createElement("span");
spinner.className = "attach-chip-spinner";
spinner.setAttribute("aria-hidden", "true");
preview.appendChild(spinner);
var name = document.createElement("span");
name.className = "attach-chip-name";
name.textContent = file.name;
var remove = document.createElement("button");
remove.type = "button";
remove.className = "attach-chip-remove";
remove.setAttribute("aria-label", "Remove " + file.name);
remove.textContent = "\u00D7";
remove.addEventListener("click", function (ev) {
ev.preventDefault();
removeAttachment(id);
});
chip.appendChild(preview);
chip.appendChild(name);
chip.appendChild(remove);
tray.appendChild(chip);
}
function uploadAttachment(id, file) {
var rec = attachments[id];
var controller = typeof AbortController === "function" ? new AbortController() : null;
if (controller) rec.controller = controller;
var body = new FormData();
body.append("csrf_token", composerForm.querySelector('input[name="csrf_token"]').value);
body.append("conversation_id", composerForm.querySelector('input[name="conversation_id"]').value);
body.append("files", file);
fetch("/files/attach", {
method: "POST",
body: body,
credentials: "same-origin",
signal: controller ? controller.signal : undefined,
})
.then(function (res) {
return res.json().then(function (data) { return { ok: res.ok, data: data }; });
})
.then(function (result) {
if (!attachments[id]) return; // removed while in flight
if (!result.ok) {
rec.status = "error";
settleChip(id, false, (result.data && result.data.error) || "Upload failed");
toast((result.data && result.data.error) || ("Couldn\u2019t attach " + file.name + "."), true);
return;
}
rec.status = "done";
rec.path = result.data.path;
addHiddenInput(id, result.data.path, result.data.entryCount);
if (result.data.isArchive) markChipAsArchive(id, result.data.entryCount);
settleChip(id, true);
})
.catch(function (err) {
if (!attachments[id] || (err && err.name === "AbortError")) return;
rec.status = "error";
settleChip(id, false, "Upload failed");
toast("Couldn\u2019t attach " + file.name + ".", true);
});
}
function stageFiles(fileList) {
if (!tray || !composerForm) return;
var files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
var room = maxAttachments - attachmentsCount();
if (room <= 0) {
toast("Up to " + maxAttachments + " files per message.", true);
return;
}
if (files.length > room) {
toast("Only the first " + room + " file" + (room === 1 ? "" : "s") + " were added (limit " + maxAttachments + ").", true);
files = files.slice(0, room);
}
files.forEach(function (file) {
var id = "a" + (++attachSeq);
attachments[id] = { status: "uploading" };
renderChip(id, file);
uploadAttachment(id, file);
});
}
if (attachBtn && attachFileInput) {
attachBtn.addEventListener("click", function () { attachFileInput.click(); });
attachFileInput.addEventListener("change", function () {
stageFiles(attachFileInput.files);
attachFileInput.value = ""; // allow re-picking the same file later
});
}
/* ---- Drag & drop (anywhere on the page) + paste ---- */
var overlay = null;
var dragDepth = 0;
function overComposer(e) {
return !!(composerForm && e.target && composerForm.contains(e.target));
}
function hasFiles(e) {
var t = e.dataTransfer && e.dataTransfer.types;
if (!t) return false;
for (var i = 0; i < t.length; i++) if (t[i] === "Files") return true;
return false;
}
function showOverlay(on) {
if (!overlay) {
overlay = document.createElement("div");
overlay.className = "drop-overlay";
overlay.setAttribute("aria-hidden", "true");
overlay.innerHTML = '<div class="drop-overlay-box"><strong>Drop files to attach</strong>' +
"<span>They\u2019ll be added to your next message</span></div>";
document.body.appendChild(overlay);
}
overlay.classList.toggle("is-visible", on);
}
document.addEventListener("dragenter", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth++;
showOverlay(true);
});
document.addEventListener("dragover", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// Hovering the message box: highlight it instead of the page overlay.
document.body.classList.toggle("drag-composer", overComposer(e));
});
document.addEventListener("dragleave", function (e) {
if (!hasFiles(e)) return;
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
showOverlay(false);
document.body.classList.remove("drag-composer");
}
});
document.addEventListener("drop", function (e) {
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth = 0;
showOverlay(false);
document.body.classList.remove("drag-composer");
if (e.dataTransfer.files && e.dataTransfer.files.length) stageFiles(e.dataTransfer.files);
});
// Pasting a file (e.g. a screenshot) into the message box attaches it too.
if (input) {
input.addEventListener("paste", function (e) {
var files = e.clipboardData && e.clipboardData.files;
if (!files || !files.length) return;
e.preventDefault();
stageFiles(files);
});
}
/* ------------------------------------------------------------------
* Send-button state: dim "Send" while there is nothing to send. Purely
* visual (data-empty on the form); the server still validates.
* ------------------------------------------------------------------ */
function syncSendState() {
if (composerForm) composerForm.setAttribute("data-empty", canSend() ? "false" : "true");
}
if (composerForm && input) {
input.addEventListener("input", syncSendState);
new MutationObserver(syncSendState).observe(composerForm, { childList: true });
if (tray) new MutationObserver(syncSendState).observe(tray, { childList: true });
document.body.addEventListener("htmx:afterRequest", function () { setTimeout(syncSendState, 0); });
syncSendState();
}
/* ------------------------------------------------------------------
* Empty-state starters and "More to explore" chips. A chip with
* data-send="true" is self-contained and submits straight away; the
* rest drop their text into the composer for the person to finish.
* ------------------------------------------------------------------ */
document.addEventListener("click", function (e) {
var el = e.target.closest && e.target.closest("[data-prompt]");
if (!el || !input) return;
input.value = el.getAttribute("data-prompt");
resizeInput();
syncSendState();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
if (el.getAttribute("data-send") === "true" && composerForm) composerForm.requestSubmit();
});
/* ------------------------------------------------------------------
* Command palette (Cmd/Ctrl+K or the top-bar search): jump to a
* conversation or run a quick action. Everything it lists is read from
* the sidebar DOM, so there is no new endpoint and it works offline.
* ------------------------------------------------------------------ */
(function palette() {
var trigger = document.getElementById("cmd-trigger");
var overlay, field, list, items = [], selected = 0;
function collect() {
var out = [];
var links = document.querySelectorAll(".conversation-link");
for (var i = 0; i < links.length; i++) {
out.push({ group: "Conversations", title: links[i].textContent.trim(), href: links[i].getAttribute("href"), glyph: "--i-file" });
}
out.push({ group: "Actions", title: "New chat", hint: "Start fresh", glyph: "--i-spark", run: function () {
var f = document.querySelector(".new-chat-form"); if (f) f.submit();
} });
out.push({ group: "Actions", title: "Manage subscription", href: "/subscription", glyph: "--i-cog" });
out.push({ group: "Actions", title: "Attach files", hint: "Add to next message", glyph: "--i-folder", run: function () {
if (attachBtn) attachBtn.click();
} });
return out;
}
function build() {
overlay = document.createElement("div");
overlay.className = "palette-overlay";
overlay.innerHTML =
'<div class="palette" role="dialog" aria-modal="true" aria-label="Search">' +
'<div class="palette-input-row"><span class="glyph" aria-hidden="true"></span>' +
'<input class="palette-input" type="text" placeholder="Search chats and actions\u2026" aria-label="Search" autocomplete="off" spellcheck="false" /></div>' +
'<div class="palette-list" role="listbox"></div>' +
'<div class="palette-foot"><span><kbd>\u2191</kbd> <kbd>\u2193</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span></div></div>';
document.body.appendChild(overlay);
field = overlay.querySelector(".palette-input");
list = overlay.querySelector(".palette-list");
field.addEventListener("input", function () { selected = 0; render(); });
field.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
else if (e.key === "Enter") { e.preventDefault(); choose(selected); }
});
overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) close(); });
list.addEventListener("click", function (e) {
var row = e.target.closest(".palette-item");
if (row) choose(parseInt(row.getAttribute("data-i"), 10));
});
}
var shown = [];
function render() {
var q = field.value.trim().toLowerCase();
shown = collect().filter(function (it) { return !q || it.title.toLowerCase().indexOf(q) !== -1; });
if (!shown.length) { list.innerHTML = '<div class="palette-empty">No matches</div>'; return; }
var html = "", last = "";
shown.forEach(function (it, i) {
if (it.group !== last) { html += '<div class="palette-group"></div>'; last = it.group; }
html += '<div class="palette-item' + (i === selected ? " is-selected" : "") + '" role="option" data-i="' + i + '">' +
'<span class="glyph" style="--glyph: var(' + it.glyph + ')"></span>' +
'<span class="palette-item-title"></span>' + (it.hint ? '<span class="palette-item-hint"></span>' : "") + "</div>";
});
list.innerHTML = html;
// Fill text via textContent so conversation titles can never inject markup.
var groups = list.querySelectorAll(".palette-group"), g = 0; last = "";
var rows = list.querySelectorAll(".palette-item");
shown.forEach(function (it, i) {
if (it.group !== last) { groups[g++].textContent = it.group; last = it.group; }
rows[i].querySelector(".palette-item-title").textContent = it.title;
var h = rows[i].querySelector(".palette-item-hint"); if (h) h.textContent = it.hint;
});
}
function move(d) {
if (!shown.length) return;
selected = (selected + d + shown.length) % shown.length;
render();
var el = list.querySelector(".is-selected"); if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
}
function choose(i) {
var it = shown[i]; if (!it) return;
close();
if (it.run) it.run(); else if (it.href) window.location.href = it.href;
}
function open() {
if (!overlay) build();
overlay.classList.add("is-open");
field.value = ""; selected = 0; render(); field.focus();
}
function close() {
if (overlay) overlay.classList.remove("is-open");
if (input) input.focus();
}
if (trigger) trigger.addEventListener("click", open);
document.addEventListener("keydown", function (e) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
overlay && overlay.classList.contains("is-open") ? close() : open();
} else if (e.key === "Escape" && overlay && overlay.classList.contains("is-open")) {
close();
}
});
})();
})();