Files
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

407 lines
18 KiB
JavaScript

/*
* 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 = '<div class="qd-grip" title="Drag to move"><i class="fas fa-grip-vertical"></i></div>';
DOCK_BTNS.forEach(function (b) {
if (cfg[b.k] !== false) html += '<button class="qd-btn" data-qd="' + b.k + '" title="' + b.t + '"><i class="fas ' + b.i + '"></i></button>';
});
// Menu is ALWAYS present so Settings / Content Manager stay reachable while the dock is on.
html += '<button class="qd-btn qd-menu" data-qd="menu" title="Menu (Settings)"><i class="fas fa-bars"></i></button>';
html += '<div class="qd-colors" id="qdColors"></div>';
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);
})();