diff --git a/ventures/havre/split.js b/ventures/havre/split.js index 7762609..46af0d3 100644 --- a/ventures/havre/split.js +++ b/ventures/havre/split.js @@ -1,10 +1,12 @@ // split.js: on a wide screen, the line between the list (chats / characters / harbor) and the chat can be dragged, or -// moved with the arrow keys once focused. Double-click (or Home) puts it back. The width is kept in this browser. -// Below 1040px the two take turns on screen, so there is nothing to drag and the divider hides (style.css). +// 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). (() => { - const KEY = "havre.split", MIN = 320, CHAT_MIN = 380; - const get = () => { try { return JSON.parse(localStorage.getItem(KEY)); } catch { return null; } }; - const put = (v) => { try { v == null ? localStorage.removeItem(KEY) : localStorage.setItem(KEY, JSON.stringify(v)); } catch { /* not kept */ } }; + 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 */ } }; function start() { const studio = document.getElementById("studio"), chars = document.getElementById("chars"), chat = document.getElementById("chat"); if (!studio || !chars || !chat) return; @@ -16,39 +18,67 @@ bar.setAttribute("aria-controls", "chars"); bar.setAttribute("aria-label", "Resize the list and the chat"); bar.title = "drag to resize · double-click to reset"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "splitbtn"; + bar.append(btn); 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))); + 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(); } function set(w, keep = true) { - if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(null); return; } + if (w == null || !wide()) { chars.style.flex = ""; bar.removeAttribute("aria-valuenow"); if (w == null && keep) put(KEY, null); return; } w = clamp(w); chars.style.flex = `0 0 ${w}px`; bar.setAttribute("aria-valuenow", String(w)); - if (keep) put(w); + if (keep) put(KEY, w); } - set(get(), false); - window.addEventListener("resize", () => set(get(), false)); + 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)); bar.addEventListener("pointerdown", (e) => { if (!wide() || e.button !== 0) return; e.preventDefault(); bar.setPointerCapture(e.pointerId); - const left = chars.getBoundingClientRect().left; + const left = studio.getBoundingClientRect().left; document.body.classList.add("splitting"); - const move = (ev) => set(ev.clientX - left); + 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); + }; 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); }); - bar.addEventListener("dblclick", () => set(null)); + bar.addEventListener("dblclick", () => { hide(false); set(null); }); bar.addEventListener("keydown", (e) => { + if (e.target !== bar) return; const now = chars.getBoundingClientRect().width, step = e.shiftKey ? 80 : 20; - if (e.key === "ArrowLeft") set(now - step); - else if (e.key === "ArrowRight") set(now + step); - else if (e.key === "Home") set(null); + 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); else return; e.preventDefault(); }); + document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "b" && wide()) { e.preventDefault(); hide(!hidden); } + }); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); })(); diff --git a/ventures/havre/style.css b/ventures/havre/style.css index b39c110..4b7e7c4 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -148,6 +148,15 @@ input:disabled, textarea:disabled, select:disabled { background: var(--well); co .splitter { flex: 0 0 7px; align-self: stretch; margin-left: -4px; margin-right: -3px; position: relative; z-index: 2; cursor: col-resize; touch-action: none; } .splitter:hover, .splitter:focus-visible, .splitting .splitter { background: var(--hi); outline: none; } body.splitting { cursor: col-resize; user-select: none; } +.splitbtn { + position: sticky; top: 12px; left: 0; margin: 12px 0 0 -9px; width: 26px; height: 32px; padding: 0; + border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); font: 700 16px/1 var(--mono); cursor: pointer; +} +.splitbtn:hover, .splitbtn:focus-visible { background: var(--hi); } +/* the list hidden, like a sidebar: the chat takes the width, the line stays to bring it back */ +.studio.list-hidden .chars { display: none; } +.studio.list-hidden .splitter { flex-basis: 30px; margin: 0; border-right: 1px solid var(--ink); } +.studio.list-hidden .splitbtn { margin-left: 1px; } .chat { flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column; position: sticky; top: 0; max-height: 100vh;