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>
407 lines
18 KiB
JavaScript
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);
|
|
})();
|