// ==UserScript==
// @name Chat Deck — 5x3 (images)
// @namespace aravvn.tools
// @version 3.4.0
// @description 5x3 square deck;
// @author aravvn
// @match https://chaturbate.com/*
// @match https://*.chaturbate.com/*
// @match https://www.testbed.cb.dev/*
// @exclude https://chaturbate.com/
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @icon https://chaturbate.com/favicon.ico
// @license CC-BY-NC-SA-4.0
// ==/UserScript==
(() => {
"use strict";
/* ---------- storage keys ---------- */
const K_ROOMS = 'chatdeck.rooms.v3'; // bump: now includes {img}
const K_UI = 'chatdeck.ui.v3';
const K_KEYS = 'chatdeck.keys.v1';
/* ---------- utils ---------- */
const S = {
get: (k,d)=>Promise.resolve(GM_getValue(k,d)),
set: (k,v)=>Promise.resolve(GM_setValue(k,v)),
el: (t,o={})=>Object.assign(document.createElement(t), o),
clamp:(n,a,b)=>Math.max(a,Math.min(b,n)),
nowTime:()=>new Date().toLocaleTimeString()
};
const qs = (sel,root=document)=>root.querySelector(sel);
const esc = (s)=> (s??'').toString().replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const EMPTY15 = () => Array.from({length:15}, ()=>({label:'', text:'', img:''}));
/* ---------- image helpers ---------- */
async function fileToDataUrl(file){
const buf = await file.arrayBuffer();
const blob = new Blob([buf], {type: file.type || 'application/octet-stream'});
return await new Promise(res=>{
const r = new FileReader();
r.onload = () => res(r.result);
r.readAsDataURL(blob);
});
}
async function resizeImageDataURL(dataURL, maxSide=320, quality=0.85){
// draw to canvas and export JPEG (unless original is webp and smaller side)
const img = new Image();
img.crossOrigin = 'anonymous';
const p = new Promise((resolve, reject)=>{
img.onload = () => resolve();
img.onerror = reject;
});
img.src = dataURL;
await p;
const {width:w, height:h} = img;
const scale = Math.min(1, maxSide / Math.max(w,h));
const tw = Math.max(1, Math.round(w * scale));
const th = Math.max(1, Math.round(h * scale));
const can = document.createElement('canvas');
can.width = tw; can.height = th;
const ctx = can.getContext('2d', {alpha: false});
ctx.drawImage(img, 0, 0, tw, th);
// prefer JPEG to keep size tiny; if you want WebP, switch mime below
return can.toDataURL('image/jpeg', quality);
}
async function fileToSmallDataUrl(file){
const raw = await fileToDataUrl(file);
// if already tiny (<150KB), keep; else downscale
const estSize = Math.ceil((raw.length * 3) / 4); // base64 to bytes approx
if (estSize < 150 * 1024) return raw;
return await resizeImageDataURL(raw, 320, 0.85);
}
/* ---------- defaults ---------- */
const DEFAULT_UI = {
x: 20, y: 80,
opacity: 0.20,
hoverOpacity: 0.99,
zIndex: 2147483646,
gap: 6,
cellMin: 96,
fontSize: 12,
minimized: false,
idleScale: 0.90,
hoverScale: 1.00,
hotkeysEnabled: true
};
const DEFAULT_KEYS = Array.from({ length: 15 }, () => null);
/* ---------- state ---------- */
let UI, ROOMS, KEYS, ROOM, DATA, HOTKEYS;
let root, shadow;
/* ---------- hotkey helpers ---------- */
function hk(code, ctrl=false, alt=false, shift=false, meta=false){
return { code, ctrl, alt, shift, meta };
}
function codeToHuman(code){ if(!code) return ''; return code.replace(/^Key/,'').replace(/^Digit/,''); }
function hotkeyToText(h){
if(!h) return '—';
const mods=[];
if(h.ctrl) mods.push('Ctrl');
if(h.alt) mods.push('Alt');
if(h.shift) mods.push('Shift');
if(h.meta) mods.push(navigator.platform.includes('Mac')?'⌘':'Meta');
const base = h.code ? codeToHuman(h.code) : '';
return (mods.concat([base]).filter(Boolean)).join('+') || '—';
}
function matchHotkey(e, h){
if(!h) return false;
return (
!!e.code && e.code === h.code &&
(!!e.ctrlKey) === !!h.ctrl &&
(!!e.altKey) === !!h.alt &&
(!!e.shiftKey) === !!h.shift &&
(!!e.metaKey) === !!h.meta
);
}
/* ---------- room detection ---------- */
function detectRoom(){
const parts = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
if (!parts[0]) return '';
if (parts[0]==='b') return parts[1]||'';
return parts[0];
}
/* ---------- CSRF + username ---------- */
function getCSRF(){
const m = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
if (m) return decodeURIComponent(m[1]);
const inp = document.querySelector('input[name="csrfmiddlewaretoken"]');
if (inp?.value) return inp.value;
const meta = document.querySelector('meta[name="csrf-token"], meta[name="csrfmiddlewaretoken"]');
if (meta?.content) return meta.content;
return '';
}
function getUsername(){
const cookieMap = Object.fromEntries(document.cookie.split(';').map(s=>{
const i=s.indexOf('='); if(i<0) return [s.trim(),''];
return [decodeURIComponent(s.slice(0,i).trim()), decodeURIComponent(s.slice(i+1))];
}));
for (const k of ['username','cb_username','cb_user']) if (cookieMap[k]) return cookieMap[k];
try{
const w=window;
if (w.CB?.user?.username) return String(w.CB.user.username);
if (w.user?.username) return String(w.user.username);
if (w.username) return String(w.username);
if (w.currentUser?.username) return String(w.currentUser.username);
}catch{}
const el = document.querySelector('[data-username],[data-user-name]');
if (el?.dataset?.username) return el.dataset.username;
if (el?.dataset?.userName) return el.dataset.userName;
return '';
}
/* ---------- API send (push_service only) ---------- */
async function sendMessage(text){
if (!text) return;
const csrf = getCSRF();
ROOM = detectRoom();
if (!ROOM) { alert('ChatDeck: cannot detect room from URL.'); return; }
if (!csrf) { alert('ChatDeck: CSRF token not found.'); return; }
const body = new URLSearchParams();
body.set('room', ROOM);
body.set('message', JSON.stringify({ m: text.replace(/\{time\}/g, S.nowTime()) }));
const user = getUsername(); if (user) body.set('username', user);
body.set('csrfmiddlewaretoken', csrf);
const res = await fetch('/push_service/publish_chat_message_live/', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-CSRFToken': csrf,
'X-Requested-With': 'XMLHttpRequest',
},
body
});
if (!res.ok){
const t=await res.text().catch(()=>String(res.status));
alert('ChatDeck: push_service error: ' + res.status);
console.error('[ChatDeck] push_service error:', res.status, t?.slice(0,300));
}
}
/* ---------- UI ---------- */
function ensureUI(){
if (root) return;
root=S.el('div'); root.attachShadow({mode:'open'}); shadow=root.shadowRoot; document.documentElement.appendChild(root);
const style=S.el('style'); shadow.append(style);
const wrap=S.el('div',{id:'deck'}); shadow.append(wrap);
const fab=S.el('button',{id:'fab', title:'Open Chat Deck'}); fab.textContent='Chat Deck';
shadow.append(fab);
wrap.innerHTML = `
<div id="hdr">
<span class="title">Chat Deck</span>
<div class="actions">
<button id="hotkeysBtn" title="Edit hotkeys for this room">Hotkeys</button>
<button id="editRoom" title="Edit labels/texts/images for this room">Edit Room</button>
<button id="copyDefault" title="Copy default into this room">Copy Default Here</button>
<button id="saveDefault" title="Use this room setup as default">Use as Default</button>
<button id="minBtn" title="Minimize">—</button>
</div>
</div>
<div id="grid"></div>
`;
wireDrag(wrap, qs('#hdr', shadow));
// handlers
qs('#hotkeysBtn', shadow).onclick = openHotkeyEditor;
qs('#editRoom', shadow).onclick = openEditorForRoom;
qs('#copyDefault',shadow).onclick = copyDefaultHere;
qs('#saveDefault',shadow).onclick = saveAsDefault;
qs('#minBtn', shadow).onclick = minimize;
fab.onclick = restore;
renderAll();
applyMinimized();
registerMenu();
window.addEventListener('keydown', onKeyDown, true);
}
function css(){
const u=UI, gap=u.gap, fs=u.fontSize, cellMin=u.cellMin;
return `
:host{ all:initial }
#deck{
position:fixed; top:${u.y}px; left:${u.x}px;
z-index:${u.zIndex};
font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;
color:#e5e7eb; user-select:none;
border-radius:12px;
opacity:${u.opacity};
transform: scale(${u.idleScale});
transform-origin: top left;
background: rgba(0,0,0,.06);
border: 1px dashed rgba(255,255,255,.14);
transition: opacity .14s ease, transform .14s ease, box-shadow .14s ease, background .14s ease, border-color .14s ease;
}
#deck:hover{
opacity:${u.hoverOpacity};
transform: scale(${u.hoverScale});
background: rgba(0,0,0,.65);
border-color: rgba(255,255,255,.22);
box-shadow: 0 16px 44px rgba(0,0,0,.38);
}
:host([data-min]) #deck{ display:none; }
/* FAB */
#fab{
position:fixed; right:16px; bottom:16px;
display:none;
padding:8px 12px; font-size:12px;
background: rgba(0,0,0,.70);
color:#e5e7eb; border:1px solid rgba(255,255,255,.28);
border-radius:999px; cursor:pointer; z-index:${u.zIndex};
box-shadow: 0 8px 24px rgba(0,0,0,.35);
}
#fab:hover{ background: rgba(0,0,0,.84); }
:host([data-min]) #fab{ display:block; }
/* Header */
#hdr{
display:flex; align-items:center; justify-content:space-between;
padding:6px 8px; margin-bottom:${gap}px;
background: rgba(0,0,0,.14);
border: 1px solid rgba(255,255,255,.12);
border-radius:10px;
cursor:move;
}
#deck:hover #hdr{
background: rgba(255,255,255,.06);
border-color: rgba(255,255,255,.24);
}
/* Controls */
#hdr .actions button{
margin-left:6px; padding:3px 8px; font-size:12px;
background: rgba(0,0,0,.35); color:#e5e7eb;
border:1px solid rgba(255,255,255,.18); border-radius:8px; cursor:pointer;
}
#hdr .actions button:hover{ background: rgba(0,0,0,.55); }
/* Grid */
#grid{
display:grid;
grid-template-columns: repeat(5, minmax(${cellMin}px, 1fr));
gap:${gap}px;
padding:${gap}px;
background: rgba(255,255,255,.04);
border: 1px dashed rgba(255,255,255,.12);
border-radius:12px;
width: max-content;
max-width: 95vw;
transition: background .14s ease, border-color .14s ease;
}
#deck:hover #grid{
background: rgba(255,255,255,.08);
border-color: rgba(255,255,255,.22);
}
/* Buttons */
.btn{
position:relative;
display:flex; align-items:center; justify-content:center;
aspect-ratio: 1 / 1;
min-width:${cellMin}px;
font-size:${fs}px; text-align:center;
background: rgba(51,65,85,.22);
color:#f1f5f9;
border:1px solid rgba(255,255,255,.12);
border-radius:10px;
padding:6px; box-sizing:border-box;
cursor:pointer;
transition: background .12s ease, border-color .12s ease, transform .06s, box-shadow .12s ease;
overflow:hidden;
-webkit-mask-image:-webkit-radial-gradient(white, black);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
#deck:hover .btn{
background-color: rgba(51,65,85,.34);
border-color: rgba(255,255,255,.20);
}
.btn:hover{
background-color: rgba(51,65,85,.78);
border-color: rgba(255,255,255,.42);
box-shadow: 0 6px 16px rgba(0,0,0,.35);
}
.btn:active{ transform: scale(.98); }
.btn.hasimg{
color:#f8fafc;
}
.btn::before{
content:"";
position:absolute; inset:0;
background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,.10));
pointer-events:none;
opacity:0; transition:opacity .12s ease;
}
#deck:hover .btn.hasimg::before{ opacity:1; }
.btn .name{
position:relative;
z-index:1;
pointer-events:none;
display:block;
line-height:1.15;
max-width:100%;
max-height:90%;
word-break:break-word;
overflow:hidden;
text-shadow: 0 1px 2px rgba(0,0,0,.6);
}
/* Modal (shared) */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:2147483647}
.modal{ width:min(920px,96vw);max-height:90vh;overflow:auto;background:#0b1324;color:#e5e7eb;border:1px solid #334155;border-radius:12px;padding:16px }
.modal h2{margin:0 0 10px}
.modal .grid15{ display:grid; grid-template-columns: repeat(5, 1fr); gap:8px; }
.tile{ background:#0b1426; border:1px solid #1f2940; border-radius:10px; padding:8px; }
.tile label{ display:block; font-size:11px; opacity:.8; margin-bottom:4px }
.tile input, .tile textarea{ width:100%; box-sizing:border-box; background:#091021; color:#e5e7eb; border:1px solid #334155; border-radius:8px; padding:6px 8px; font-family:inherit }
.tile input{ margin-bottom:6px }
.tile textarea{ height:64px; resize:vertical }
.modal .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
.modal button{background:#0b1324;color:#e5e7eb;border:1px solid #334155;border-radius:8px;padding:6px 10px;cursor:pointer}
.modal .danger{border-color:#7f1d1d;color:#fecaca}
/* Hotkey editor */
.hkrow{ display:flex; align-items:center; justify-content:space-between; background:#0b1426; border:1px solid #1f2940; border-radius:10px; padding:8px; }
.hkname{ font-weight:600; opacity:.9 }
.hklabel{ font-family:monospace; background:#111827; border:1px solid #374151; border-radius:6px; padding:4px 8px; font-size:12px; }
/* Image picker in tiles */
.imgpick{ display:flex; align-items:center; gap:6px; margin-top:6px; }
.imgpick input[type="file"]{ padding:4px 6px; }
.thumb{ width:100%; height:72px; object-fit:cover; border:1px solid #334155; border-radius:8px; background:#0a0f1e; }
.clearbtn{ margin-left:auto; }
`;
}
function ensureDataShape(arr){
if (!Array.isArray(arr)) return EMPTY15();
if (arr.length !== 15) arr = Array.from({length:15}, (_,i)=>arr[i] ?? {label:'', text:'', img:''});
if (typeof arr[0] === 'string'){
return arr.map((t,i)=>({label: t ? `Button ${i+1}` : '', text: String(t), img:''}));
}
return arr.map((o,i)=>({
label: o?.label ?? (o?.text ? `Button ${i+1}` : ''),
text: o?.text ?? '',
img: o?.img ?? ''
}));
}
function ensureKeysShape(arr){
if (!Array.isArray(arr)) return Array.from({length:15}, (_,i)=>DEFAULT_KEYS[i] ?? null);
const out = Array.from({length:15}, (_,i)=>arr[i] ?? null);
return out.map(h=>{
if (!h) return null;
if (typeof h.code !== 'string') return null;
return { code: h.code, ctrl:!!h.ctrl, alt:!!h.alt, shift:!!h.shift, meta:!!h.meta };
});
}
function renderAll(){
const style = qs('style', shadow); style.textContent = css();
const deck = qs('#deck', shadow);
deck.style.top = UI.y+'px';
deck.style.left= UI.x+'px';
renderGrid();
}
function renderGrid(){
const grid = qs('#grid', shadow); grid.innerHTML='';
DATA.forEach((it, i)=>{
const el=S.el('div',{className:'btn', title:`${hotkeyToText(HOTKEYS[i])} — Click=send • Shift+Click=edit`});
const span=S.el('span',{className:'name'});
span.textContent = it.label || `Button ${i+1}`;
if (it.img){
el.classList.add('hasimg');
// two-layer bg: gradient handled by ::before, here we just set the image
el.style.backgroundImage = `url(${it.img})`;
}else{
el.style.backgroundImage = '';
}
el.append(span);
el.addEventListener('click', (e)=>{
if (e.shiftKey){ openSingleEditor(i); return; }
sendMessage(it.text);
});
grid.append(el);
});
}
/* ---------- modal base ---------- */
function modal(){
const back=S.el('div',{className:'modal-backdrop'});
const box=S.el('div',{className:'modal'});
back.append(box);
return { box, mount(){ shadow.append(back); }, unmount(){ back.remove(); } };
}
/* ---------- single editor ---------- */
function openSingleEditor(idx){
const {box, mount, unmount} = modal();
const it = DATA[idx] || {label:'', text:'', img:''};
box.innerHTML = `
<h2>Edit Button ${idx+1} — Room “${esc(ROOM)}”</h2>
<div class="tile">
<label>Label (shown on button)</label>
<input id="lbl" value="${esc(it.label)}" placeholder="Button ${idx+1}">
<label>Text to send (supports {time})</label>
<textarea id="txt" rows="4" placeholder="Enter message...">${esc(it.text)}</textarea>
<label>Background image (optional, stored locally)</label>
<img id="thumb" class="thumb" src="${it.img?esc(it.img):''}" alt="">
<div class="imgpick">
<input id="file" type="file" accept="image/*">
<button id="clearImg" class="danger clearbtn" ${it.img?'':'disabled'}>Clear Image</button>
</div>
</div>
<div class="actions">
<button id="cancel">Cancel</button>
<button id="save">Save</button>
</div>
`;
mount();
const $file = box.querySelector('#file');
const $thumb= box.querySelector('#thumb');
const $clear= box.querySelector('#clearImg');
let newImg = it.img || '';
$file.onchange = async (e)=>{
const f = e.target.files?.[0];
if (!f) return;
try{
const small = await fileToSmallDataUrl(f);
newImg = small;
$thumb.src = small;
$clear.disabled = false;
}catch(err){
alert('Failed to load image');
console.error(err);
}
};
$clear.onclick = ()=>{
newImg = '';
$thumb.removeAttribute('src');
$clear.disabled = true;
};
box.querySelector('#cancel').onclick = unmount;
box.querySelector('#save').onclick = async ()=>{
const lbl = box.querySelector('#lbl').value.trim();
const txt = box.querySelector('#txt').value;
DATA[idx] = { label: lbl, text: txt, img: newImg };
ROOMS[ROOM] = DATA.slice();
await S.set(K_ROOMS, ROOMS);
renderGrid(); unmount();
};
}
/* ---------- room editor ---------- */
function openEditorForRoom(){
const {box, mount, unmount} = modal();
const tiles = DATA.map((it,i)=>`
<div class="tile" data-i="${i}">
<label>Button ${i+1} — Label</label>
<input data-k="label" value="${esc(it.label)}" placeholder="Button ${i+1}">
<label>Text (supports {time})</label>
<textarea data-k="text" placeholder="Message to send...">${esc(it.text)}</textarea>
<label>Background image (optional)</label>
<img class="thumb" data-role="thumb" src="${it.img?esc(it.img):''}" alt="">
<div class="imgpick">
<input type="file" accept="image/*" data-role="file">
<button class="danger clearbtn" data-role="clear" ${it.img?'':'disabled'}>Clear</button>
</div>
</div>
`).join('');
box.innerHTML = `
<h2>Edit Room “${esc(ROOM)}”</h2>
<div class="grid15">${tiles}</div>
<div class="actions">
<button id="cancel">Cancel</button>
<button id="clear" class="danger">Clear All</button>
<button id="save">Save</button>
</div>
`;
mount();
// image handlers per tile
box.querySelectorAll('.tile').forEach(tile=>{
const i = +tile.dataset.i;
const file = tile.querySelector('[data-role="file"]');
const clear= tile.querySelector('[data-role="clear"]');
const thumb= tile.querySelector('[data-role="thumb"]');
file.onchange = async (e)=>{
const f = e.target.files?.[0];
if (!f) return;
try{
const small = await fileToSmallDataUrl(f);
thumb.src = small;
clear.disabled = false;
// keep in temp Data until Save
DATA[i] = {...DATA[i], img: small};
}catch(err){
alert('Failed to load image');
console.error(err);
}
};
clear.onclick = ()=>{
thumb.removeAttribute('src');
clear.disabled = true;
DATA[i] = {...DATA[i], img: ''};
};
});
box.querySelector('#cancel').onclick = unmount;
box.querySelector('#clear').onclick = ()=> {
box.querySelectorAll('input[data-k="label"]').forEach(i=>i.value='');
box.querySelectorAll('textarea[data-k="text"]').forEach(t=>t.value='');
box.querySelectorAll('[data-role="thumb"]').forEach(img=>img.removeAttribute('src'));
box.querySelectorAll('[data-role="clear"]').forEach(b=>b.disabled=true);
DATA = DATA.map(()=>({label:'', text:'', img:''}));
};
box.querySelector('#save').onclick = async ()=>{
const next = DATA.map(x=>({...x}));
box.querySelectorAll('.tile').forEach(tile=>{
const i = +tile.dataset.i;
const lbl = tile.querySelector('input[data-k="label"]').value.trim();
const txt = tile.querySelector('textarea[data-k="text"]').value;
const imgEl = tile.querySelector('[data-role="thumb"]');
next[i].label = lbl;
next[i].text = txt;
next[i].img = imgEl?.getAttribute('src') || next[i].img || '';
});
DATA = next;
ROOMS[ROOM] = DATA.slice();
await S.set(K_ROOMS, ROOMS);
renderGrid(); unmount();
};
}
/* ---------- hotkey editor ---------- */
function openHotkeyEditor(){
const {box, mount, unmount} = modal();
const rows = HOTKEYS.map((h,i)=>`
<div class="hkrow" data-i="${i}">
<span class="hkname">Btn ${i+1}</span>
<span class="hklabel" data-role="label">${esc(hotkeyToText(h))}</span>
<span>
<button data-role="set">Set</button>
<button data-role="clr">Clear</button>
</span>
</div>
`).join('');
box.innerHTML = `
<h2>Hotkeys — Room “${esc(ROOM)}”</h2>
<div class="grid15">${rows}</div>
<div class="actions">
<button id="cancel">Close</button>
<button id="defaults">Use Default Mapping</button>
<button id="save">Save</button>
</div>
<div class="small" style="opacity:.8;margin-top:6px">
Tip: Hold modifiers (Ctrl/Alt/Shift/${navigator.platform.includes('Mac')?'⌘':'Meta'}) and press a key. Press Esc to cancel capture.
</div>
`;
mount();
let capturing = null;
function onCapKey(e){
if (!capturing) return;
e.preventDefault(); e.stopPropagation();
const {row, label} = capturing;
if (e.key === 'Escape'){ stopCap(); return; }
if (/^(Shift|Control|Alt|Meta)/.test(e.code)) return;
const h = { code: e.code, ctrl:e.ctrlKey, alt:e.altKey, shift:e.shiftKey, meta:e.metaKey };
HOTKEYS[row] = h;
label.textContent = hotkeyToText(h);
stopCap();
}
function stopCap(){
window.removeEventListener('keydown', onCapKey, true);
capturing = null;
}
box.querySelectorAll('.hkrow').forEach(rowEl=>{
const i = +rowEl.dataset.i;
const setBtn = rowEl.querySelector('[data-role="set"]');
const clrBtn = rowEl.querySelector('[data-role="clr"]');
const label = rowEl.querySelector('[data-role="label"]');
setBtn.onclick = ()=>{
if (capturing) return;
capturing = { row: i, label };
label.textContent = 'Press keys… (Esc cancel)';
window.addEventListener('keydown', onCapKey, true);
};
clrBtn.onclick = ()=>{
HOTKEYS[i] = null;
label.textContent = '—';
};
});
box.querySelector('#defaults').onclick = ()=>{
HOTKEYS = DEFAULT_KEYS.slice();
box.querySelectorAll('.hkrow').forEach((rowEl,idx)=>{
rowEl.querySelector('[data-role="label"]').textContent = hotkeyToText(HOTKEYS[idx]);
});
};
box.querySelector('#cancel').onclick = ()=>{ stopCap(); unmount(); };
box.querySelector('#save').onclick = async ()=>{
stopCap();
KEYS[ROOM] = HOTKEYS.slice();
await S.set(K_KEYS, KEYS);
renderGrid();
unmount();
};
}
/* ---------- default copy/save ---------- */
async function copyDefaultHere(){
const defData = ensureDataShape(ROOMS._default ?? EMPTY15());
const defKeys = ensureKeysShape(KEYS._default ?? DEFAULT_KEYS);
ROOMS[ROOM] = defData.slice();
DATA = ensureDataShape(ROOMS[ROOM]);
KEYS[ROOM] = defKeys.slice();
HOTKEYS = ensureKeysShape(KEYS[ROOM]);
await S.set(K_ROOMS, ROOMS);
await S.set(K_KEYS, KEYS);
renderGrid();
}
async function saveAsDefault(){
ROOMS._default = DATA.slice();
KEYS._default = HOTKEYS.slice();
await S.set(K_ROOMS, ROOMS);
await S.set(K_KEYS, KEYS);
alert('Saved this room as default (labels/texts/images + hotkeys).');
}
/* ---------- hotkey runtime ---------- */
function onKeyDown(e){
if (!UI?.hotkeysEnabled) return;
const t = e.target;
const insideShadow = shadow && shadow.contains(t);
const isEditable = t?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/i.test(t?.tagName||'');
if (isEditable && !insideShadow) return;
for (let i=0;i<15;i++){
const h = HOTKEYS[i];
if (h && matchHotkey(e, h)){
e.preventDefault(); e.stopPropagation();
const it = DATA[i];
if (it) sendMessage(it.text);
break;
}
}
}
/* ---------- minimize / restore ---------- */
function minimize(){ UI.minimized = true; saveUI(); applyMinimized(); }
function restore(){ UI.minimized = false; saveUI(); applyMinimized(); }
function applyMinimized(){
if (UI.minimized) shadow.host.setAttribute('data-min','');
else shadow.host.removeAttribute('data-min');
}
/* ---------- drag move ---------- */
function wireDrag(container, handle){
let sx=0, sy=0, ox=0, oy=0, dragging=false;
handle.addEventListener('mousedown', e=>{
dragging=true; sx=e.clientX; sy=e.clientY; ox=UI.x; oy=UI.y; e.preventDefault();
});
window.addEventListener('mousemove', e=>{
if(!dragging) return;
UI.x = S.clamp(ox+(e.clientX-sx), 0, window.innerWidth-100);
UI.y = S.clamp(oy+(e.clientY-sy), 0, window.innerHeight-60);
const deck = qs('#deck', shadow);
deck.style.left = UI.x+'px';
deck.style.top = UI.y+'px';
});
window.addEventListener('mouseup', async()=>{ if(dragging){ dragging=false; await saveUI(); } });
}
/* ---------- storage ---------- */
async function loadAll(){
UI = await S.get(K_UI, DEFAULT_UI);
// migrate old rooms without img to new shape
const oldRooms = await S.get('chatdeck.rooms.v2', null);
const fallback = oldRooms ? ensureDataShape(oldRooms) : EMPTY15();
ROOMS = await S.get(K_ROOMS, { _default: fallback });
KEYS = await S.get(K_KEYS, { _default: DEFAULT_KEYS.slice() });
ROOM = detectRoom() || '_site';
DATA = ensureDataShape(ROOMS[ROOM] ?? ROOMS._default ?? EMPTY15());
HOTKEYS = ensureKeysShape(KEYS[ROOM] ?? KEYS._default ?? DEFAULT_KEYS);
// if we migrated, persist v3 so we stop reading v2 next time
if (oldRooms && !await S.get(K_ROOMS, null)) {
await S.set(K_ROOMS, { _default: fallback, [ROOM]: DATA });
}
}
async function saveUI(){ await S.set(K_UI, UI); }
/* ---------- menu ---------- */
function registerMenu(){
try{
GM_registerMenuCommand('Show Chat Deck', ()=>{ UI.minimized=false; applyMinimized(); }, 's');
GM_registerMenuCommand('Edit current room', ()=>openEditorForRoom(), 'e');
GM_registerMenuCommand('Edit hotkeys', ()=>openHotkeyEditor(), 'h');
GM_registerMenuCommand('Use this room as default', ()=>saveAsDefault(), 'd');
GM_registerMenuCommand('Copy default here', ()=>copyDefaultHere(), 'c');
GM_registerMenuCommand(UI.hotkeysEnabled?'Disable hotkeys':'Enable hotkeys', async()=>{
UI.hotkeysEnabled = !UI.hotkeysEnabled; await saveUI();
alert('Hotkeys ' + (UI.hotkeysEnabled?'enabled':'disabled'));
}, 'k');
GM_registerMenuCommand('Idle scale 0.85', async()=>{ UI.idleScale=0.85; await saveUI(); renderAll(); }, '5');
GM_registerMenuCommand('Idle scale 0.90', async()=>{ UI.idleScale=0.90; await saveUI(); renderAll(); }, '6');
GM_registerMenuCommand('Idle scale 0.95', async()=>{ UI.idleScale=0.95; await saveUI(); renderAll(); }, '7');
GM_registerMenuCommand('Squares 90px', async()=>{ UI.cellMin=90; await saveUI(); renderAll(); }, '1');
GM_registerMenuCommand('Squares 96px', async()=>{ UI.cellMin=96; await saveUI(); renderAll(); }, '2');
GM_registerMenuCommand('Squares 110px', async()=>{ UI.cellMin=110; await saveUI(); renderAll(); }, '3');
GM_registerMenuCommand('Increase idle opacity', async()=>{ UI.opacity = S.clamp(UI.opacity+0.05, 0.05, 1); await saveUI(); renderAll(); }, '+');
GM_registerMenuCommand('Decrease idle opacity', async()=>{ UI.opacity = S.clamp(UI.opacity-0.05, 0.05, 1); await saveUI(); renderAll(); }, '-');
}catch{}
}
/* ---------- init ---------- */
(async function init(){
await loadAll();
ensureUI();
})();
})();