Files
Haven/public/js/modmode.js
T

411 lines
15 KiB
JavaScript

// ═══════════════════════════════════════════════════════════
// Haven: Mod Mode (layout customisation)
// Lets users drag sidebar sections and snap any panel to
// any edge: left, right, top, bottom, or center (float)
// ═══════════════════════════════════════════════════════════
class ModMode {
constructor() {
this.active = false;
this.container = null;
this.sections = [];
this.dragSrc = null;
this.savedLayout = null;
this.panelLayout = {
'server-bar': 'left',
'sidebar': 'left',
'right-sidebar': 'right',
'status-bar': 'bottom',
'voice-panel': 'right-sidebar'
};
this.panelDefs = {
'server-bar': { selector: '#server-bar', positions: ['left', 'right'] },
'sidebar': { selector: '.sidebar', positions: ['left', 'right'] },
'right-sidebar': { selector: '.right-sidebar', positions: ['left', 'right', 'center'] },
'status-bar': { selector: '#status-bar', positions: ['bottom', 'top'] },
'voice-panel': { selector: '#voice-panel', positions: ['right-sidebar', 'left-sidebar', 'bottom', 'center'] }
};
this.panelHandles = new Map();
this.snapZones = [];
this.draggingPanelKey = null;
this.editingLayoutOwner = null;
this._boundDragStart = this._onDragStart.bind(this);
this._boundDragOver = this._onDragOver.bind(this);
this._boundDragEnter = this._onDragEnter.bind(this);
this._boundDragLeave = this._onDragLeave.bind(this);
this._boundDrop = this._onDrop.bind(this);
this._boundDragEnd = this._onDragEnd.bind(this);
this._boundPanelDragStart = this._onPanelDragStart.bind(this);
this._boundPanelDragEnd = this._onPanelDragEnd.bind(this);
}
init() {
this.container = document.getElementById('sidebar-mod-container');
if (!this.container) return;
this._loadLayout();
this._loadPanelLayout();
this._cacheSections();
this.applyLayout();
this.applyPanelLayout();
document.getElementById('mod-mode-reset')?.addEventListener('click', () => this.resetLayout());
}
_cacheSections() {
this.sections = [...this.container.querySelectorAll('[data-mod-id]')];
}
_loadLayout() {
try { this.savedLayout = JSON.parse(localStorage.getItem('haven-layout')); } catch { this.savedLayout = null; }
}
_loadPanelLayout() {
try {
const raw = JSON.parse(localStorage.getItem('haven-panel-layout') || 'null');
if (!raw || typeof raw !== 'object') return;
Object.keys(this.panelDefs).forEach(k => {
if (this.panelDefs[k].positions.includes(raw[k])) this.panelLayout[k] = raw[k];
});
} catch { /* invalid stored layout, ignore */ }
}
toggle() {
this.active = !this.active;
this.active ? this._enable() : this._disable();
}
// ── Enable / Disable ──
_beginLayoutEditing() {
if (document.documentElement.hasAttribute('data-haven-layout-editing')) return false;
const layout = window.HavenApi?.Layout;
this.editingLayoutOwner = layout?.owner || null;
layout?._reserve?.(this.editingLayoutOwner);
document.documentElement.setAttribute('data-haven-layout-editing', '1');
document.dispatchEvent(new CustomEvent('haven:layout-editing', {
detail: { active: true, owner: this.editingLayoutOwner }
}));
return true;
}
_endLayoutEditing() {
const preferredOwner = this.editingLayoutOwner;
const layout = window.HavenApi?.Layout;
this.editingLayoutOwner = null;
document.documentElement.removeAttribute('data-haven-layout-editing');
try {
document.dispatchEvent(new CustomEvent('haven:layout-editing', {
detail: { active: false, owner: preferredOwner }
}));
} finally {
layout?._clearReservation?.(preferredOwner);
}
if (!layout?.owner) {
document.dispatchEvent(new CustomEvent('haven:layout-owner-change', { detail: { owner: null } }));
}
}
_enable() {
this._beginLayoutEditing();
this.container.classList.add('mod-mode-active');
document.body.classList.add('mod-mode-on');
this._cacheSections();
this.sections.forEach(s => {
s.setAttribute('draggable', 'true');
s.classList.add('mod-draggable');
s.addEventListener('dragstart', this._boundDragStart);
s.addEventListener('dragover', this._boundDragOver);
s.addEventListener('dragenter', this._boundDragEnter);
s.addEventListener('dragleave', this._boundDragLeave);
s.addEventListener('drop', this._boundDrop);
s.addEventListener('dragend', this._boundDragEnd);
});
this._enablePanelMode();
this._showToast(t('modmode.enabled'));
}
_disable() {
this.container.classList.remove('mod-mode-active');
document.body.classList.remove('mod-mode-on');
this.sections.forEach(s => {
s.setAttribute('draggable', 'false');
s.classList.remove('mod-draggable', 'mod-drag-over', 'mod-drop-above', 'mod-drop-below', 'mod-dragging');
s.removeEventListener('dragstart', this._boundDragStart);
s.removeEventListener('dragover', this._boundDragOver);
s.removeEventListener('dragenter', this._boundDragEnter);
s.removeEventListener('dragleave', this._boundDragLeave);
s.removeEventListener('drop', this._boundDrop);
s.removeEventListener('dragend', this._boundDragEnd);
});
this._disablePanelMode();
try {
this._saveLayout();
this._savePanelLayout();
} finally {
this._endLayoutEditing();
}
this._showToast(t('modmode.disabled'));
}
// ── Section drag events ──
_onDragStart(e) {
this.dragSrc = e.currentTarget;
e.currentTarget.classList.add('mod-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', e.currentTarget.dataset.modId);
}
_onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.currentTarget;
if (target === this.dragSrc) return;
const rect = target.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
target.classList.toggle('mod-drop-above', e.clientY < midY);
target.classList.toggle('mod-drop-below', e.clientY >= midY);
}
_onDragEnter(e) {
e.preventDefault();
if (e.currentTarget !== this.dragSrc) e.currentTarget.classList.add('mod-drag-over');
}
_onDragLeave(e) {
e.currentTarget.classList.remove('mod-drag-over', 'mod-drop-above', 'mod-drop-below');
}
_onDrop(e) {
e.preventDefault();
const target = e.currentTarget;
target.classList.remove('mod-drag-over', 'mod-drop-above', 'mod-drop-below');
if (!this.dragSrc || target === this.dragSrc) return;
const rect = target.getBoundingClientRect();
const insertBefore = e.clientY < rect.top + rect.height / 2;
if (insertBefore) this.container.insertBefore(this.dragSrc, target);
else if (target.nextSibling) this.container.insertBefore(this.dragSrc, target.nextSibling);
else this.container.appendChild(this.dragSrc);
this._cacheSections();
}
_onDragEnd(e) {
e.currentTarget.classList.remove('mod-dragging');
this.sections.forEach(s => s.classList.remove('mod-drag-over', 'mod-drop-above', 'mod-drop-below'));
this.dragSrc = null;
}
// ── Panel snap mode ──
_enablePanelMode() {
Object.keys(this.panelDefs).forEach(key => {
const panel = document.querySelector(this.panelDefs[key].selector);
if (!panel) return;
panel.classList.add('mod-panel-target');
let handle = panel.querySelector('.mod-panel-handle');
if (!handle) {
handle = document.createElement('button');
handle.type = 'button';
handle.className = 'mod-panel-handle';
handle.textContent = '\u2725';
handle.title = t('modmode.drag_to_reposition', { panel: key.replace(/-/g, ' ') });
panel.appendChild(handle);
}
handle.setAttribute('draggable', 'true');
handle.dataset.panelKey = key;
handle.addEventListener('dragstart', this._boundPanelDragStart);
handle.addEventListener('dragend', this._boundPanelDragEnd);
this.panelHandles.set(key, handle);
});
}
_disablePanelMode() {
this._clearSnapZones();
this.draggingPanelKey = null;
this.panelHandles.forEach((handle, key) => {
handle.removeEventListener('dragstart', this._boundPanelDragStart);
handle.removeEventListener('dragend', this._boundPanelDragEnd);
handle.removeAttribute('draggable');
const panel = document.querySelector(this.panelDefs[key].selector);
if (panel) panel.classList.remove('mod-panel-target');
});
this.panelHandles.clear();
}
_onPanelDragStart(e) {
const key = e.currentTarget.dataset.panelKey;
if (!key || !this.panelDefs[key]) return;
this.draggingPanelKey = key;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `panel:${key}`);
e.currentTarget.classList.add('dragging');
this._showSnapZones(key);
}
_onPanelDragEnd(e) {
e.currentTarget.classList.remove('dragging');
this.draggingPanelKey = null;
this._clearSnapZones();
}
_showSnapZones(key) {
this._clearSnapZones();
const positions = this.panelDefs[key]?.positions || [];
const labels = {
left: `\u2190 ${t('modmode.positions.left')}`, right: `${t('modmode.positions.right')} \u2192`, top: `\u2191 ${t('modmode.positions.top')}`,
bottom: `\u2193 ${t('modmode.positions.bottom')}`, center: `\u2b24 ${t('modmode.positions.float')}`,
'right-sidebar': t('modmode.positions.right_panel'), 'left-sidebar': t('modmode.positions.left_panel')
};
positions.forEach(pos => {
const zone = document.createElement('div');
zone.className = `mod-snap-zone ${pos}`;
zone.dataset.panelKey = key;
zone.dataset.pos = pos;
zone.textContent = labels[pos] || pos;
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('active'); });
zone.addEventListener('dragleave', () => zone.classList.remove('active'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('active');
this._setPanelPosition(key, pos);
});
document.body.appendChild(zone);
this.snapZones.push(zone);
});
}
_clearSnapZones() {
this.snapZones.forEach(z => z.remove());
this.snapZones = [];
}
_setPanelPosition(key, pos) {
if (!this.panelDefs[key]?.positions.includes(pos)) return;
this.panelLayout[key] = pos;
this.applyPanelLayout();
this._savePanelLayout();
const label = pos.replace(/-/g, ' ');
this._showToast(t('modmode.moved', { panel: key.replace(/-/g, ' '), position: label }));
}
// ── Persistence ──
_saveLayout() {
const order = this.sections.map(s => s.dataset.modId);
localStorage.setItem('haven-layout', JSON.stringify(order));
this.savedLayout = order;
}
_savePanelLayout() {
localStorage.setItem('haven-panel-layout', JSON.stringify(this.panelLayout));
}
applyLayout() {
if (!this.savedLayout || !this.container) return;
const existing = new Map();
this.sections.forEach(s => existing.set(s.dataset.modId, s));
this.savedLayout.forEach(id => {
const el = existing.get(id);
if (el) this.container.appendChild(el);
});
this._cacheSections();
}
applyPanelLayout() {
const serverBar = document.getElementById('server-bar');
const sidebar = document.querySelector('.sidebar');
const rightSidebar = document.querySelector('.right-sidebar');
const app = document.getElementById('app');
const appBody = document.getElementById('app-body');
const voicePanel = document.getElementById('voice-panel');
// Server bar & sidebar positions
if (serverBar) serverBar.dataset.panelPos = this.panelLayout['server-bar'];
if (sidebar) sidebar.dataset.panelPos = this.panelLayout.sidebar;
// Status bar position
if (app) app.dataset.statusPos = this.panelLayout['status-bar'];
// Right sidebar position
if (rightSidebar) {
const rsPos = this.panelLayout['right-sidebar'];
rightSidebar.dataset.panelPos = rsPos;
// Remove float class first
rightSidebar.classList.remove('mod-float');
if (rsPos === 'center') {
rightSidebar.classList.add('mod-float');
}
}
// Voice panel position
if (voicePanel) {
const vpPos = this.panelLayout['voice-panel'];
voicePanel.dataset.modPos = vpPos;
voicePanel.classList.remove('mod-float', 'mod-voice-bottom', 'mod-voice-left');
if (vpPos === 'center') {
voicePanel.classList.add('mod-float');
} else if (vpPos === 'bottom') {
voicePanel.classList.add('mod-voice-bottom');
} else if (vpPos === 'left-sidebar') {
voicePanel.classList.add('mod-voice-left');
// Move voice panel DOM into left sidebar
const sidebarBottom = document.querySelector('.sidebar-bottom');
if (sidebarBottom && voicePanel.parentElement !== sidebarBottom) {
sidebarBottom.insertBefore(voicePanel, sidebarBottom.firstChild);
}
} else {
// Default: right-sidebar. Ensure it's in the right sidebar
if (rightSidebar && voicePanel.closest('.right-sidebar') !== rightSidebar) {
rightSidebar.appendChild(voicePanel);
}
}
}
}
resetLayout() {
const ownsEditingState = this._beginLayoutEditing();
try {
localStorage.removeItem('haven-layout');
localStorage.removeItem('haven-panel-layout');
this.savedLayout = null;
this.panelLayout = {
'server-bar': 'left', 'sidebar': 'left', 'right-sidebar': 'right',
'status-bar': 'bottom', 'voice-panel': 'right-sidebar'
};
const defaultOrder = ['join', 'create', 'channels'];
const existing = new Map();
this.sections.forEach(s => existing.set(s.dataset.modId, s));
defaultOrder.forEach(id => {
const el = existing.get(id);
if (el) this.container.appendChild(el);
});
this._cacheSections();
// Move voice panel back to right sidebar
const voicePanel = document.getElementById('voice-panel');
const rightSidebar = document.querySelector('.right-sidebar');
if (voicePanel && rightSidebar) {
rightSidebar.appendChild(voicePanel);
voicePanel.classList.remove('mod-float', 'mod-voice-bottom', 'mod-voice-left');
}
this.applyPanelLayout();
} finally {
if (ownsEditingState) this._endLayoutEditing();
}
this._showToast(t('modmode.reset'));
}
_showToast(msg) {
const t = document.createElement('div');
t.className = 'mod-toast';
t.textContent = msg;
document.body.appendChild(t);
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2200);
}
}
if (typeof module !== 'undefined') module.exports = ModMode;