/* * quick-dock.js — optional always-visible, DRAGGABLE mini tool dock for one-tap tool switching. * * Isolated + additive: it does NOT rework the existing toolbar. Every button proxies to the real * control (clicks the actual tool button, or calls window.presentationTools), so all existing logic * is reused. Toggled from Settings > Presentation (localStorage 'toolDockMode'); default OFF, so the * current toolbar is unchanged unless the user opts in. Position is remembered across sessions. */ (function () { 'use strict'; var COLORS = [ { c: '#fefefe', t: 'White' }, { c: '#ff3333', t: 'Red' }, { c: '#0090ff', t: 'Blue' }, { c: '#00ff00', t: 'Green' }, { c: '#ffff00', t: 'Yellow' } ]; var POS_KEY = 'quickDockPos'; var STATE_KEY = 'toolDockState'; // 'docked' (top rail) | 'floating' var VIS_KEY = 'toolVisibility'; var DOCK_ZONE = 90; // px from top; releasing the dock here re-docks it to the top rail var dock = null, colorsRow = null, syncTimer = 0; // Tools the user can show/hide (Settings). 'menu' is always shown so Settings stays reachable. var TOGGLEABLE = ['pen', 'highlight', 'erase', 'laser', 'spotlight', 'undo', 'redo', 'clear']; var DOCK_BTNS = [ { k: 'pen', i: 'fa-pen', t: 'Pen' }, { k: 'highlight', i: 'fa-highlighter', t: 'Highlighter' }, { k: 'erase', i: 'fa-eraser', t: 'Eraser' }, { k: 'laser', i: 'fa-circle-dot', t: 'Laser' }, { k: 'spotlight', i: 'fa-bullseye', t: 'Spotlight' }, { k: 'undo', i: 'fa-undo', t: 'Undo' }, { k: 'redo', i: 'fa-redo', t: 'Redo' }, { k: 'clear', i: 'fa-trash', t: 'Clear' } ]; // Top-toolbar elements for each toggleable tool (visibility applies here too). var TOP_SEL = { pen: '#penToolBtn', highlight: '#highlightToolBtn', erase: '.tool-icon[data-tool="erase"]', laser: '#laserToolBtn', spotlight: '#spotlightToolBtn', undo: '#undoBtn', redo: '#redoBtn', clear: '#resetBtn' }; function byId(id) { return document.getElementById(id); } function getState() { try { return localStorage.getItem(STATE_KEY) === 'floating' ? 'floating' : 'docked'; } catch (_) { return 'docked'; } } function toolVis() { var def = { pen: true, highlight: true, erase: true, laser: true, spotlight: true, undo: true, redo: true, clear: true }; try { var s = JSON.parse(localStorage.getItem(VIS_KEY) || 'null'); if (s && typeof s === 'object') TOGGLEABLE.forEach(function (k) { if (k in s) def[k] = !!s[k]; }); } catch (_) {} return def; } // Hide/show tools in the EXISTING top toolbar per the visibility config (applies with dock off too). function applyToolVisibilityToTop(cfg) { try { Object.keys(TOP_SEL).forEach(function (k) { var el = document.querySelector(TOP_SEL[k]); if (!el) return; var target = el.closest('.tool-wrapper') || el; target.style.display = (cfg[k] === false) ? 'none' : ''; }); } catch (_) {} } function realClick(sel) { try { var el = typeof sel === 'string' ? document.querySelector(sel) : sel; if (el) el.click(); } catch (_) {} } function activeTool() { try { if (window.presentationTools) { var m = window.presentationTools.getMode(); if (m) return m; } } catch (_) {} return window.currentTool || 'pen'; } function build() { if (dock) return; var cfg = toolVis(); dock = document.createElement('div'); dock.id = 'quickDock'; dock.className = 'quick-dock'; var html = '
'; DOCK_BTNS.forEach(function (b) { if (cfg[b.k] !== false) html += ''; }); // Menu is ALWAYS present so Settings / Content Manager stay reachable while the dock is on. html += ''; html += '
'; dock.innerHTML = html; document.body.appendChild(dock); colorsRow = byId('qdColors'); restorePos(); wireButtons(); wireDrag(); } function restorePos() { try { var p = JSON.parse(localStorage.getItem(POS_KEY) || 'null'); if (p && typeof p.left === 'number' && typeof p.top === 'number') { dock.style.left = Math.max(0, Math.min(window.innerWidth - 60, p.left)) + 'px'; dock.style.top = Math.max(0, Math.min(window.innerHeight - 80, p.top)) + 'px'; } else { dock.style.left = '12px'; dock.style.top = '34vh'; } dock.style.right = 'auto'; } catch (_) { dock.style.left = '12px'; dock.style.top = '34vh'; dock.style.right = 'auto'; } } function savePos() { try { var r = dock.getBoundingClientRect(); localStorage.setItem(POS_KEY, JSON.stringify({ left: Math.round(r.left), top: Math.round(r.top) })); } catch (_) {} } function selectTool(kind) { if (kind === 'pen') realClick('#penToolBtn'); else if (kind === 'highlight') realClick('#highlightToolBtn'); else if (kind === 'erase') realClick('.tool-icon[data-tool="erase"]'); else if (kind === 'laser') { if (window.presentationTools) window.presentationTools.laser(); } else if (kind === 'spotlight') { if (window.presentationTools) window.presentationTools.spotlight(); } else if (kind === 'undo') realClick('#undoBtn'); else if (kind === 'redo') realClick('#redoBtn'); else if (kind === 'clear') realClick('#resetBtn'); else if (kind === 'menu') realClick('#touchMenuBtn'); sync(); } function showColors(kind) { if (!colorsRow) return; /* * The swatch row is wider than the button column, so the floating dock has to grow. Width cannot be * animated from 'auto', so the dock carries an explicit width and this class switches it to the wide * one — CSS then eases between them instead of snapping. */ if (!kind) { colorsRow.classList.remove('show'); if (dock) dock.classList.remove('qd-wide'); // Empty it only after the collapse has finished, so the swatches do not vanish mid-animation. setTimeout(function () { if (!colorsRow.classList.contains('show')) colorsRow.innerHTML = ''; }, 200); return; } colorsRow.innerHTML = ''; COLORS.forEach(function (o) { var b = document.createElement('button'); b.className = 'qd-color'; b.style.background = o.c; b.title = o.t; b.setAttribute('data-color', o.c); b.addEventListener('click', function (e) { e.stopPropagation(); pickColor(kind, o.c); }); colorsRow.appendChild(b); }); colorsRow.classList.add('show'); if (dock) dock.classList.add('qd-wide'); } function pickColor(kind, c) { if (kind === 'pen') realClick('#penColorDropdown .pen-color-option[data-color="' + c + '"]'); else if (kind === 'highlight') realClick('#highlighterColorDropdown .highlighter-color-option[data-color="' + c + '"]'); else if (kind === 'laser') { if (window.presentationTools) window.presentationTools.setLaserColor(c); } showColors(false); sync(); } function wireButtons() { dock.querySelectorAll('.qd-btn').forEach(function (btn) { var kind = btn.getAttribute('data-qd'); if (kind === 'clear') { wireClearHold(btn); return; } // clear = tap-vs-hold, not a plain click btn.addEventListener('click', function (e) { e.preventDefault(); if (kind === 'undo' || kind === 'redo' || kind === 'menu') { selectTool(kind); return; } var colorable = (kind === 'pen' || kind === 'highlight' || kind === 'laser'); var wasActive = (activeTool() === kind); if (colorable && wasActive) { // Second tap on the already-active colourable tool toggles its swatches (mirrors top toolbar) if (colorsRow.classList.contains('show')) showColors(false); else showColors(kind); return; } selectTool(kind); showColors(false); }); }); } // Clear button: tap = clear current tab, hold 1.5s = clear all — with a red progress bar + // blue-glow-while-holding + red flash, matching the top toolbar's trash button exactly. function wireClearHold(btn) { var HOLD = 1500, PROGRESS_DELAY = 300; var holdTimer = null, progressRAF = null, startT = 0, isHolding = false, processing = false; var bar = document.createElement('div'); bar.className = 'qd-clear-progress'; btn.appendChild(bar); function reset() { btn.classList.remove('qd-holding', 'qd-flash'); bar.style.width = '0%'; } function begin(e) { e.preventDefault(); if (holdTimer || processing) return; isHolding = false; startT = Date.now(); btn.classList.add('qd-holding'); try { btn.setPointerCapture(e.pointerId); } catch (_) {} holdTimer = setTimeout(function () { isHolding = true; }, HOLD); setTimeout(function () { if (!holdTimer) return; var step = function () { if (!holdTimer) { progressRAF = null; return; } var p = Math.min((Date.now() - startT) / HOLD * 100, 100); bar.style.width = p + '%'; if (p >= 100) { progressRAF = null; bar.style.width = '0%'; btn.classList.add('qd-flash'); } else progressRAF = requestAnimationFrame(step); }; progressRAF = requestAnimationFrame(step); }, PROGRESS_DELAY); } function end() { if (processing) return; var wasHolding = isHolding, pct = (Date.now() - startT) / HOLD * 100; clearTimeout(holdTimer); holdTimer = null; if (progressRAF) { cancelAnimationFrame(progressRAF); progressRAF = null; } processing = true; try { var tabId = window.bibleApp && window.bibleApp.activeTabId; if (wasHolding) { if (window.clearAllAnnotationsAcrossApp) window.clearAllAnnotationsAcrossApp(); } else if (pct < 20) { if (window.clearAllAnnotations) window.clearAllAnnotations(tabId); } } catch (_) {} setTimeout(function () { reset(); processing = false; }, wasHolding ? 350 : 0); } function cancel() { clearTimeout(holdTimer); holdTimer = null; isHolding = false; if (progressRAF) { cancelAnimationFrame(progressRAF); progressRAF = null; } reset(); } btn.addEventListener('pointerdown', begin); btn.addEventListener('pointerup', end); btn.addEventListener('pointercancel', cancel); } // Drag the dock by its grip (a large full-width bar across the top). Uses DOCUMENT-level move/up so // the drag grabs on the FIRST touch and keeps following even if the finger slides off the grip — // no "wake it up first" and no repeated tries. function wireDrag() { var grip = dock.querySelector('.qd-grip'); if (!grip) return; var dragging = false, sx = 0, sy = 0, ox = 0, oy = 0, lastY = 0; function move(e) { if (!dragging) return; lastY = e.clientY; var nx = Math.max(0, Math.min(window.innerWidth - dock.offsetWidth, ox + (e.clientX - sx))); var ny = Math.max(0, Math.min(window.innerHeight - dock.offsetHeight, oy + (e.clientY - sy))); dock.style.left = nx + 'px'; dock.style.top = ny + 'px'; dock.style.right = 'auto'; dock.classList.toggle('qd-will-dock', e.clientY < DOCK_ZONE); // hint: release here to dock } function up() { document.removeEventListener('pointermove', move, true); document.removeEventListener('pointerup', up, true); document.removeEventListener('pointercancel', up, true); if (!dragging) return; dragging = false; dock.classList.remove('dragging', 'qd-will-dock'); setBright(true); if (lastY < DOCK_ZONE) setDockState('docked'); // "thrown" to the top rail → dock else savePos(); } grip.addEventListener('pointerdown', function (e) { e.preventDefault(); dragging = true; var r = dock.getBoundingClientRect(); ox = r.left; oy = r.top; sx = e.clientX; sy = e.clientY; lastY = e.clientY; dock.classList.add('dragging'); setBright(true); document.addEventListener('pointermove', move, true); document.addEventListener('pointerup', up, true); document.addEventListener('pointercancel', up, true); }); } // Mirror the live colour of the matching top-toolbar icon so the dock looks identical. var COLOR_MIRROR = { pen: '#penToolBtn', highlight: '#highlightToolBtn', erase: '.tool-icon[data-tool="erase"]', laser: '#laserToolBtn', spotlight: '#spotlightToolBtn' }; function activeTabMode() { try { var a = window.bibleApp; var t = a && a.tabs && a.tabs.find(function (x) { return x.id === a.activeTabId; }); return t ? t.mode : null; } catch (_) { return null; } } function sync() { if (!dock) return; // While floating, hide on tabs where drawing tools don't apply (media + ask + prayer); reappear (same spot) elsewhere. var mode = activeTabMode(); var HIDE_ON = ['announcements', 'graphics', 'maps', 'ask', 'prayer']; var show = getState() === 'floating' && HIDE_ON.indexOf(mode) === -1; dock.style.display = show ? 'flex' : 'none'; if (!show) return; var a = activeTool(); dock.querySelectorAll('.qd-btn').forEach(function (b) { var k = b.getAttribute('data-qd'); if (!(k === 'undo' || k === 'redo' || k === 'clear' || k === 'menu')) b.classList.toggle('active', k === a); if (COLOR_MIRROR[k]) { var top = document.querySelector(COLOR_MIRROR[k]); if (top) { var c = getComputedStyle(top).color; if (c && c !== 'rgba(0, 0, 0, 0)') b.style.color = c; // The dock's active ring is drawn in currentColor, so the Notes tab's black ink would ring a // dark button in black. Same white-paper treatment as the top rail. if (typeof window.isDarkInk === 'function') b.classList.toggle('dark-ink', window.isDarkInk(c)); } } }); } function applyState() { if (getState() === 'floating') { build(); dock.style.display = 'flex'; document.body.classList.add('qd-on'); // hides the top-rail handle + rail if (!syncTimer) syncTimer = setInterval(sync, 300); sync(); } else { if (dock) dock.style.display = 'none'; document.body.classList.remove('qd-on'); // restores the normal top rail + its hide/reveal handle if (syncTimer) { clearInterval(syncTimer); syncTimer = 0; } } } function setDockState(s, at) { try { localStorage.setItem(STATE_KEY, s === 'floating' ? 'floating' : 'docked'); } catch (_) {} applyState(); if (s === 'floating' && dock && at) { var w = dock.offsetWidth || 60, h = dock.offsetHeight || 300; dock.style.left = Math.max(0, Math.min(window.innerWidth - w, at.x - w / 2)) + 'px'; dock.style.top = Math.max(0, Math.min(window.innerHeight - h, at.y)) + 'px'; dock.style.right = 'auto'; savePos(); } } function setBright(on) { if (dock) dock.classList.toggle('qd-bright', !!on); } // Grab a handle and drag it straight out into a floating dock — ONE continuous motion (undock, // then the dock follows the finger until release). Used by BOTH the folded top handle and the grip // in the opened rail. A plain tap on the folded handle still opens the rail. function wireFloatGrab(el) { if (!el || el._qdFloat) return; el._qdFloat = true; var armed = false, dragging = false, sx = 0, sy = 0; function place(e) { var w = dock.offsetWidth || 60, h = dock.offsetHeight || 300; dock.style.left = Math.max(0, Math.min(window.innerWidth - w, e.clientX - w / 2)) + 'px'; dock.style.top = Math.max(0, Math.min(window.innerHeight - h, e.clientY - 18)) + 'px'; dock.style.right = 'auto'; dock.classList.toggle('qd-will-dock', e.clientY < DOCK_ZONE); } function move(e) { if (!armed) return; if (!dragging) { if (Math.abs(e.clientX - sx) < 12 && Math.abs(e.clientY - sy) < 12) return; dragging = true; setDockState('floating'); if (dock) { dock.classList.add('dragging'); setBright(true); } } if (dock) place(e); } function up(e) { document.removeEventListener('pointermove', move, true); document.removeEventListener('pointerup', up, true); document.removeEventListener('pointercancel', up, true); armed = false; if (dragging) { dragging = false; if (dock) dock.classList.remove('dragging', 'qd-will-dock'); if (e && e.clientY < DOCK_ZONE) setDockState('docked'); // dragged back to the top → re-dock else savePos(); } } el.addEventListener('pointerdown', function (e) { armed = true; dragging = false; sx = e.clientX; sy = e.clientY; document.addEventListener('pointermove', move, true); document.addEventListener('pointerup', up, true); document.addEventListener('pointercancel', up, true); }, true); } function wireUndock() { wireFloatGrab(byId('toolIndicator')); // folded handle wireFloatGrab(byId('railGrip')); // grip in the opened rail (first, left of pen) } // Auto-dim: the floating dock is bright only while you're touching it; any other input dims it. function wireAutoDim() { if (window.__qdAutoDim) return; window.__qdAutoDim = true; document.addEventListener('pointerdown', function (e) { if (getState() !== 'floating' || !dock) return; setBright(dock.contains(e.target)); }, true); window.addEventListener('wheel', function () { if (getState() === 'floating') setBright(false); }, { passive: true, capture: true }); } window.quickDock = { getState: getState, floatDock: function () { setDockState('floating'); }, dockToRail: function () { setDockState('docked'); }, isEnabled: function () { return getState() === 'floating'; }, /* * Re-read toolVisibility and apply it now. Settings normally only takes effect on the next * load, which is fine when the operator walks back into the app — but the admin console pushes * a setting to a TV that is mid-service, and reloading the stage to show it is not acceptable. */ refreshToolVisibility: function () { try { applyToolVisibilityToTop(toolVis()); applyState(); return true; } catch (_) { return false; } } }; function init() { applyToolVisibilityToTop(toolVis()); // applies in both docked + floating states wireUndock(); wireAutoDim(); applyState(); } if (document.readyState !== 'loading') init(); else document.addEventListener('DOMContentLoaded', init); })();