2026-10-23 19:47:05 +00:00
|
|
|
|
// split.js: on a wide screen, the line between the list (chats / characters / harbor) and the chat can be dragged, or
|
2026-10-23 20:18:29 +00:00
|
|
|
|
// moved with the arrow keys once focused. Double-click (or Home) puts it back. The list can also be hidden, like a
|
|
|
|
|
|
// sidebar: the ‹ button on the line, dragging it most of the way to the left, or Ctrl/Cmd+B; › brings it back.
|
|
|
|
|
|
// The width and whether it's hidden are kept in this browser. Below 1040px the two take turns on screen, so there is
|
|
|
|
|
|
// nothing to drag and the divider hides (style.css).
|
2026-10-23 19:47:05 +00:00
|
|
|
|
(() => {
|
2026-10-23 20:18:29 +00:00
|
|
|
|
const KEY = "havre.split", HID = "havre.split.hidden", MIN = 320, CHAT_MIN = 380, SNAP = 200;
|
|
|
|
|
|
const get = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
|
|
|
|
|
|
const put = (k, v) => { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, JSON.stringify(v)); } catch { /* not kept */ } };
|
2026-10-23 19:47:05 +00:00
|
|
|
|
function start() {
|
|
|
|
|
|
const studio = document.getElementById("studio"), chars = document.getElementById("chars"), chat = document.getElementById("chat");
|
|
|
|
|
|
if (!studio || !chars || !chat) return;
|
|
|
|
|
|
const bar = document.createElement("div");
|
|
|
|
|
|
bar.className = "splitter";
|
|
|
|
|
|
bar.tabIndex = 0;
|
|
|
|
|
|
bar.setAttribute("role", "separator");
|
|
|
|
|
|
bar.setAttribute("aria-orientation", "vertical");
|
|
|
|
|
|
bar.setAttribute("aria-controls", "chars");
|
|
|
|
|
|
bar.setAttribute("aria-label", "Resize the list and the chat");
|
|
|
|
|
|
bar.title = "drag to resize · double-click to reset";
|
2026-10-23 20:18:29 +00:00
|
|
|
|
const btn = document.createElement("button");
|
|
|
|
|
|
btn.type = "button";
|
|
|
|
|
|
btn.className = "splitbtn";
|
|
|
|
|
|
bar.append(btn);
|
2026-10-23 19:47:05 +00:00
|
|
|
|
chat.before(bar);
|
|
|
|
|
|
const wide = () => window.matchMedia("(min-width: 1041px)").matches;
|
|
|
|
|
|
const clamp = (w) => Math.round(Math.max(MIN, Math.min(w, studio.clientWidth - CHAT_MIN - bar.offsetWidth)));
|
2026-10-23 20:18:29 +00:00
|
|
|
|
let hidden = get(HID) === true;
|
|
|
|
|
|
function show() {
|
|
|
|
|
|
const off = hidden && wide();
|
|
|
|
|
|
studio.classList.toggle("list-hidden", off);
|
|
|
|
|
|
btn.textContent = off ? "›" : "‹";
|
|
|
|
|
|
btn.setAttribute("aria-label", off ? "Show the list" : "Hide the list");
|
|
|
|
|
|
btn.title = off ? "show the list (ctrl+b)" : "hide the list (ctrl+b)";
|
|
|
|
|
|
btn.setAttribute("aria-expanded", off ? "false" : "true");
|
|
|
|
|
|
}
|
|
|
|
|
|
function hide(on) { hidden = on; put(HID, on || null); show(); }
|
2026-10-23 19:47:05 +00:00
|
|
|
|
function set(w, keep = true) {
|
2026-10-23 20:18:29 +00:00
|
|
|
|
if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(KEY, null); return; }
|
2026-10-23 19:47:05 +00:00
|
|
|
|
w = clamp(w);
|
|
|
|
|
|
chars.style.flex = `0 0 ${w}px`;
|
|
|
|
|
|
bar.setAttribute("aria-valuenow", String(w));
|
2026-10-23 20:18:29 +00:00
|
|
|
|
if (keep) put(KEY, w);
|
2026-10-23 19:47:05 +00:00
|
|
|
|
}
|
2026-10-23 20:18:29 +00:00
|
|
|
|
set(get(KEY), false);
|
|
|
|
|
|
show();
|
|
|
|
|
|
window.addEventListener("resize", () => { set(get(KEY), false); show(); });
|
|
|
|
|
|
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
|
|
|
|
btn.addEventListener("dblclick", (e) => e.stopPropagation());
|
|
|
|
|
|
btn.addEventListener("click", () => hide(!hidden));
|
2026-10-23 19:47:05 +00:00
|
|
|
|
bar.addEventListener("pointerdown", (e) => {
|
|
|
|
|
|
if (!wide() || e.button !== 0) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
bar.setPointerCapture(e.pointerId);
|
2026-10-23 20:18:29 +00:00
|
|
|
|
const left = studio.getBoundingClientRect().left;
|
2026-10-23 19:47:05 +00:00
|
|
|
|
document.body.classList.add("splitting");
|
2026-10-23 20:18:29 +00:00
|
|
|
|
const move = (ev) => {
|
|
|
|
|
|
const w = ev.clientX - left;
|
|
|
|
|
|
if (w < SNAP) { if (!hidden) hide(true); return; } // most of the way left: tuck it away
|
|
|
|
|
|
if (hidden) hide(false);
|
|
|
|
|
|
set(w);
|
|
|
|
|
|
};
|
2026-10-23 19:47:05 +00:00
|
|
|
|
const up = () => { bar.removeEventListener("pointermove", move); bar.removeEventListener("pointerup", up); bar.removeEventListener("pointercancel", up); document.body.classList.remove("splitting"); };
|
|
|
|
|
|
bar.addEventListener("pointermove", move);
|
|
|
|
|
|
bar.addEventListener("pointerup", up);
|
|
|
|
|
|
bar.addEventListener("pointercancel", up);
|
|
|
|
|
|
});
|
2026-10-23 20:18:29 +00:00
|
|
|
|
bar.addEventListener("dblclick", () => { hide(false); set(null); });
|
2026-10-23 19:47:05 +00:00
|
|
|
|
bar.addEventListener("keydown", (e) => {
|
2026-10-23 20:18:29 +00:00
|
|
|
|
if (e.target !== bar) return;
|
2026-10-23 19:47:05 +00:00
|
|
|
|
const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20;
|
2026-10-23 20:18:29 +00:00
|
|
|
|
if (e.key === "ArrowLeft") { if (hidden) return e.preventDefault(); if (now - step < MIN) hide(true); else set(now - step); }
|
|
|
|
|
|
else if (e.key === "ArrowRight") { if (hidden) hide(false); else set(now + step); }
|
|
|
|
|
|
else if (e.key === "Home") { hide(false); set(null); }
|
|
|
|
|
|
else if (e.key === "Enter" || e.key === " ") hide(!hidden);
|
2026-10-23 19:47:05 +00:00
|
|
|
|
else return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
});
|
2026-10-23 20:18:29 +00:00
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
|
|
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "b" && wide()) { e.preventDefault(); hide(!hidden); }
|
|
|
|
|
|
});
|
2026-10-23 19:47:05 +00:00
|
|
|
|
}
|
|
|
|
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
|
|
|
|
|
|
})();
|