Files
bible-app/tool-wheel.js
T

562 lines
28 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* tool-wheel.js — two-finger tap → a radial tool selector under the fingers. Isolated + additive.
*
* On stage the pastor changes tools by reaching for a row of small icons at the top-right of a 75" screen,
* above his head, mid-sentence, and then wondering which one he is on. This puts the choice under his hand:
* tap the glass with two fingers anywhere on the passage and Pen / Highlighter / Eraser / Laser / Spotlight
* fan out around the tap, the current one ringed in its own colour; tap one and the wheel is gone. It is
* invisible until asked for, so the stage and the program feed stay clean.
*
* Every pick goes through the REAL controls — the toolbar button's own click, or window.presentationTools —
* exactly as the phone remote and the floating dock do. Nothing about changing tools is re-implemented here:
* the toolbar ring, the tool indicator, the cursor, the media canvases, Companion / OSC feedback, the phone
* remote's state and the laser / spotlight overlay all react as if the toolbar itself had been tapped.
*
* Why it cannot fight anything else in the app:
* - It watches Pointer Events on the window in the CAPTURE phase and never stops, cancels or retargets one,
* so every existing handler sees exactly what it always saw.
* - A "two-finger tap" is two touch pointers landing within 160 ms of each other, neither moving more than
* 14 px, both lifting within 400 ms of the first landing, with no third finger. The two-finger SCROLL
* moves; a pinch moves; the three- and five-finger gestures need more fingers; a resting palm stays
* down; a word tap is one finger. None of them can look like this, and this looks like none of them.
* - The wheel is a small fixed element, not a full-screen layer. The page beneath stays live, and a press
* anywhere outside the wheel closes it AND carries on to whatever it landed on — so a scroll, a stroke,
* a tab tap, any action at all, puts it away without costing a touch.
* - The two fingers are neither a word tap nor a stroke: app.js is told so through window.__gestureGuards
* (word-popup suppression, and cancelling the one-point stroke the first finger may have begun), the
* same way the two-finger scroll protects itself.
* - It only opens where a tool can be used: a passage, whiteboard, notes or media page that is actually
* showing. Not on the book / chapter / verse pickers, not on Graphics / Announcements / Maps (two fingers
* there mean pinch-zoom), not on Ask or Prayer, and never while the splash or the menu is up.
* - Never on the booth follower screen, which displays the stage and takes no input.
*
* Two shapes, picked in Settings › Presentation. Same gesture, same rules, same colours, same feel:
*
* RING (default) — five separate discs fanned out around the tap on a 108 px radius. Big, unmissable,
* and it takes about a third of the width of the screen while it is up.
* SLIM — one small dial: a segmented ring around a close hub, about two thirds the width and
* nothing but the dial itself. Less of the passage covered, less on camera if the
* program feed catches it mid-choice, and the same distance for the thumb to travel.
*
* Settings › Presentation: on/off (default on), which shape, and an optional "highlighter / eraser return
* to the pen after N seconds without a touch" (default off) — so that "what am I on?" can always be
* answered "the pen".
*/
(function () {
'use strict';
if (window.__followerMode) return;
var ENABLED_KEY = 'toolWheelEnabled'; // '0' = off; default on
var STYLE_KEY = 'toolWheelStyle'; // 'slim' = the segmented dial; anything else = the ring (default)
var SNAP_KEY = 'toolSnapBackSec'; // seconds; 0 / blank = off (default)
var VIS_KEY = 'toolVisibility'; // Settings › Presentation › "Tools to show" (shared with the toolbar + dock)
// Gesture, in CSS px. The stage TV is 1080x1920 CSS px at DPR 2, so 1 CSS px ≈ 0.9 mm on the glass.
var SECOND_FINGER_MS = 160; // both fingers land within this window
var MAX_TAP_MS = 400; // first landing → last lift
var MAX_MOVE_PX = 14; // per finger
var MAX_SPREAD_PX = 320; // fingers of one hand; a resting hand plus a tap elsewhere is not a tap
// RING geometry, CSS px. On the stage TV a choice is ~7 cm across and sits ~9 cm from the tap.
var RING_R = 108, ITEM_D = 84, MARGIN = 12;
// SLIM geometry, CSS px. One dial: a hole for the close hub, then a band of segments around it. Half
// the radius of the ring's outermost edge, so it covers about a third of the area.
var SL_IN = 36, SL_OUT = 96, SL_PAD = 4; // SL_PAD leaves room for the segment stroke inside the viewBox
var AUTO_CLOSE_MS = 6000; // left alone, it folds away by itself
var CLOSE_MS = 180, CHOSEN_HOLD_MS = 90;
var TOOLS = [
{ 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' }
];
// The real toolbar controls: colours are mirrored from them, drawing tools are picked by clicking them.
var TOP_SEL = {
pen: '#penToolBtn', highlight: '#highlightToolBtn', erase: '.tool-icon[data-tool="erase"]',
laser: '#laserToolBtn', spotlight: '#spotlightToolBtn'
};
var FALLBACK_COLOR = { pen: '#ffff00', highlight: '#ffff00', erase: '#ffb6c1', laser: '#ffff00', spotlight: '#e6e6e6' };
var NO_TOOL_MODES = ['announcements', 'graphics', 'maps', 'prayer', 'ask'];
function log() { try { console.log.apply(console, ['[TOOL WHEEL]'].concat([].slice.call(arguments))); } catch (_) {} }
function guards() { return window.__gestureGuards || null; }
function enabled() { try { return localStorage.getItem(ENABLED_KEY) !== '0'; } catch (_) { return true; } }
function style() { try { return localStorage.getItem(STYLE_KEY) === 'slim' ? 'slim' : 'ring'; } catch (_) { return 'ring'; } }
function snapSec() { try { var v = parseFloat(localStorage.getItem(SNAP_KEY)); return (isFinite(v) && v > 0) ? v : 0; } catch (_) { return 0; } }
function activeTab() {
try { var a = window.bibleApp; return (a && a.tabs && a.tabs.find(function (t) { return t.id === a.activeTabId; })) || null; }
catch (_) { return null; }
}
function onScreen(el) { return !!(el && el.offsetParent !== null); }
// Same rules as presentation-tools.js's drawingAllowedHere(): a page you present over, actually showing.
function allowedHere() {
var t = activeTab();
if (!t) return false;
if (NO_TOOL_MODES.indexOf(t.mode) !== -1) return false;
if (t.mode === 'bible' || t.mode === 'ppsync') {
if (!t.book || t.isSelectingVerses) return false;
var ids = ['selectorContainer', 'bookCards', 'chapterCards', 'verseCards', 'selectionContent'];
for (var i = 0; i < ids.length; i++) if (onScreen(document.getElementById(ids[i] + t.id))) return false;
}
return true;
}
function overlayUp() {
try {
var s = document.querySelector('.splash-overlay');
if (s && !s.classList.contains('hidden')) return true;
var m = document.getElementById('touchMenuOverlay');
if (m && !m.classList.contains('hidden')) return true;
} catch (_) {}
return false;
}
function visibleTools() {
var cfg = {};
try { var s = JSON.parse(localStorage.getItem(VIS_KEY) || 'null'); if (s && typeof s === 'object') cfg = s; } catch (_) {}
return TOOLS.filter(function (t) { return cfg[t.k] !== false && !!document.querySelector(TOP_SEL[t.k]); });
}
function activeTool() {
try { var pt = window.presentationTools; if (pt) { var m = pt.getMode(); if (m) return m; } } catch (_) {}
return window.currentTool || 'pen';
}
function colorFor(kind) {
try {
var el = document.querySelector(TOP_SEL[kind]);
var c = el && getComputedStyle(el).color;
if (c && c !== 'rgba(0, 0, 0, 0)') return c;
} catch (_) {}
return FALLBACK_COLOR[kind] || '#e6e6e6';
}
// ------------------------------------------------------------------ applying a pick
function applyTool(kind) {
if (kind === 'laser' || kind === 'spotlight') {
var pt = window.presentationTools;
if (!pt) return;
// setMode toggles, so only call when it is not already this mode.
if (pt.getMode() !== kind) { if (kind === 'laser') pt.laser(); else pt.spotlight(); }
return;
}
// Drawing tools: the toolbar button's own click, so every side effect happens (toolbar ring, tool
// indicator, cursor, media canvases, Companion feedback, presentation-tools dropping its overlay).
var btn = document.querySelector(TOP_SEL[kind]);
if (btn && typeof btn.click === 'function') btn.click();
else if (typeof window.forceSetDrawingTool === 'function') window.forceSetDrawingTool(kind);
}
// ------------------------------------------------------------------ the wheel
var W = { el: null, items: [], closing: false, timer: 0, tick: 0, pressId: null, tabId: null, tool: null };
/*
* Both shapes are built the same way: place the container on the tap point, work out how much room
* there is, then hand off to the shape's own builder. Whatever comes back, the rest of the file only
* ever deals in "items" — an element carrying data-tw, optionally with an .__ico partner to colour
* alongside it. So the gesture, the hit-testing, the painting and the close animation are shared, and
* a shape is only ever a different set of boxes.
*/
function build(tools, cx, cy) {
var slim = style() === 'slim';
var el = document.createElement('div');
el.id = 'toolWheel';
el.className = 'tool-wheel' + (slim ? ' tool-wheel-slim' : '');
el.setAttribute('role', 'menu');
el.setAttribute('aria-label', 'Tool selector');
// Keep every choice on screen: near an edge the centre is shifted inward rather than a choice clipped.
// --tw-scale only shrinks the wheel in a window too small to hold it (a dev browser, never the TV).
var ext = slim ? (SL_OUT + SL_PAD + MARGIN) : (RING_R + ITEM_D / 2 + MARGIN);
var vw = window.innerWidth, vh = window.innerHeight;
var topSafe = 0;
try { topSafe = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-top')) || 0; } catch (_) {}
var scale = Math.max(0.4, Math.min(1, (vw - 2 * MARGIN) / (2 * ext), (vh - topSafe - 2 * MARGIN) / (2 * ext)));
var e2 = ext * scale;
var x = Math.min(Math.max(cx, e2), Math.max(e2, vw - e2));
var y = Math.min(Math.max(cy, e2 + topSafe), Math.max(e2 + topSafe, vh - e2));
el.style.left = Math.round(x) + 'px';
el.style.top = Math.round(y) + 'px';
el.style.setProperty('--tw-scale', String(scale));
var hub = document.createElement('div');
hub.className = 'tw-hub';
hub.setAttribute('aria-label', 'Close');
hub.innerHTML = '<i class="fas fa-times"></i>';
return slim ? buildSlim(el, hub, tools, scale) : buildRing(el, hub, tools, scale);
}
// ---- RING: a disc per tool, fanned out around the tap ----------------------------------------
function buildRing(el, hub, tools, scale) {
el.appendChild(hub);
var n = tools.length, items = [];
tools.forEach(function (t, idx) {
var ang = -Math.PI / 2 + idx * (2 * Math.PI / n); // first choice straight up, then clockwise
var it = document.createElement('div');
it.className = 'tw-item';
it.setAttribute('data-tw', t.k);
it.setAttribute('role', 'menuitem');
it.setAttribute('aria-label', t.t);
it.style.setProperty('--tw-x', (Math.cos(ang) * RING_R * scale).toFixed(1) + 'px');
it.style.setProperty('--tw-y', (Math.sin(ang) * RING_R * scale).toFixed(1) + 'px');
it.style.setProperty('--tw-delay', (idx * 22) + 'ms');
it.innerHTML = '<i class="fas ' + t.i + '"></i><span>' + t.t + '</span>';
el.appendChild(it);
items.push(it);
});
return { el: el, hub: hub, items: items };
}
/*
* ---- SLIM: one dial, cut into a segment per tool ----------------------------------------------
*
* A band of wedges around a hole, with the close hub sitting in the hole. The wedges touch, so their
* own outlines draw the dial: the outer circle, the inner circle, and a line between each pair — and
* there is no dead space between the choices to miss with a thumb.
*
* The wedges are SVG, because a donut sector is a shape the browser can hit-test exactly; the icons
* are ordinary elements laid over the top with pointer-events off, so a touch always lands on the
* wedge underneath and never on a glyph. That is what lets hit-testing stay one shared elementFromPoint
* for both shapes.
*/
function buildSlim(el, hub, tools, scale) {
var size = 2 * (SL_OUT + SL_PAD);
var c = size / 2;
var dial = document.createElement('div');
dial.className = 'tw-dial';
dial.style.width = dial.style.height = size + 'px';
dial.style.margin = (-size / 2) + 'px 0 0 ' + (-size / 2) + 'px';
var NS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'tw-svg');
svg.setAttribute('viewBox', '0 0 ' + size + ' ' + size);
svg.setAttribute('aria-hidden', 'true');
var icons = document.createElement('div');
icons.className = 'tw-icons';
var n = tools.length, items = [];
var span = 2 * Math.PI / n;
var mid = (SL_IN + SL_OUT) / 2;
var pt = function (r, a) { return (c + r * Math.cos(a)).toFixed(2) + ' ' + (c + r * Math.sin(a)).toFixed(2); };
tools.forEach(function (t, idx) {
var am = -Math.PI / 2 + idx * span; // this tool's middle, first one straight up
var a0 = am - span / 2, a1 = am + span / 2;
var large = span > Math.PI ? 1 : 0;
var d = 'M ' + pt(SL_OUT, a0) +
' A ' + SL_OUT + ' ' + SL_OUT + ' 0 ' + large + ' 1 ' + pt(SL_OUT, a1) +
' L ' + pt(SL_IN, a1) +
' A ' + SL_IN + ' ' + SL_IN + ' 0 ' + large + ' 0 ' + pt(SL_IN, a0) + ' Z';
var g = document.createElementNS(NS, 'g');
g.setAttribute('class', 'tw-seg');
g.setAttribute('data-tw', t.k);
g.setAttribute('role', 'menuitem');
g.setAttribute('aria-label', t.t);
g.style.setProperty('--tw-delay', (idx * 22) + 'ms');
// Folded: pulled a little back toward the hub, so it unfolds outward like the ring's discs do.
g.style.setProperty('--tw-sx', (Math.cos(am) * -14).toFixed(1) + 'px');
g.style.setProperty('--tw-sy', (Math.sin(am) * -14).toFixed(1) + 'px');
var p = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
g.appendChild(p);
svg.appendChild(g);
var ico = document.createElement('div');
ico.className = 'tw-ico';
ico.style.setProperty('--tw-delay', (idx * 22) + 'ms');
ico.style.setProperty('--tw-x', (Math.cos(am) * mid * scale).toFixed(1) + 'px');
ico.style.setProperty('--tw-y', (Math.sin(am) * mid * scale).toFixed(1) + 'px');
ico.style.setProperty('--tw-x0', (Math.cos(am) * mid * scale * 0.55).toFixed(1) + 'px');
ico.style.setProperty('--tw-y0', (Math.sin(am) * mid * scale * 0.55).toFixed(1) + 'px');
ico.innerHTML = '<i class="fas ' + t.i + '"></i><span>' + t.t + '</span>';
icons.appendChild(ico);
// The two halves of one choice: painted together, pressed together (see paint / mark).
g.__ico = ico;
items.push(g);
});
dial.appendChild(svg);
el.appendChild(dial);
el.appendChild(icons);
el.appendChild(hub);
return { el: el, hub: hub, items: items };
}
// A choice is one or two elements (the wedge and its icon); state goes on both.
function mark(it, cls, on) {
try {
it.classList.toggle(cls, !!on);
if (it.__ico) it.__ico.classList.toggle(cls, !!on);
} catch (_) {}
}
// Colours and the active ring, mirrored live from the toolbar (same treatment as the floating dock).
function paint() {
if (!W.el) return;
var a = activeTool();
W.tool = a;
W.items.forEach(function (it) {
var k = it.getAttribute('data-tw');
var c = colorFor(k);
// currentColor: the ring's disc borders and the slim dial's segment fill both resolve from this.
it.style.color = c;
if (it.__ico) it.__ico.style.color = c;
var on = (k === a);
mark(it, 'tw-active', on);
var dark = false;
try { dark = typeof window.isDarkInk === 'function' && window.isDarkInk(c); } catch (_) {}
mark(it, 'dark-ink', on && !!dark);
});
}
function open(cx, cy) {
if (W.el) close(true);
var tools = visibleTools();
if (tools.length < 2) return false;
var b = build(tools, cx, cy);
W.el = b.el; W.items = b.items; W.closing = false; W.pressId = null;
var t = activeTab(); W.tabId = t ? t.id : null;
paint();
wire(b.el);
document.body.appendChild(b.el);
// Paint the folded pose first, then transition to open — it fans out, it never appears in place.
void b.el.offsetWidth;
var unfold = function () { if (W.el === b.el) b.el.classList.add('tw-open'); };
requestAnimationFrame(unfold);
setTimeout(unfold, 40); // a frame that never comes (busy renderer) must not leave it folded
armAutoClose();
W.tick = setInterval(watch, 250);
log('open at', Math.round(cx) + ',' + Math.round(cy), '— style:', style(), '— active:', W.tool);
return true;
}
function armAutoClose() { clearTimeout(W.timer); W.timer = setTimeout(function () { close(false); }, AUTO_CLOSE_MS); }
// While open: the splash or menu coming up, the tab changing, or the tool changing behind our back.
function watch() {
if (!W.el || W.closing) return;
if (overlayUp()) { close(true); return; }
var t = activeTab();
if ((t ? t.id : null) !== W.tabId) { close(false); return; }
if (activeTool() !== W.tool) paint();
}
function close(instant, chosen) {
var el = W.el;
if (!el) return;
clearTimeout(W.timer); W.timer = 0;
clearInterval(W.tick); W.tick = 0;
if (instant) {
W.el = null; W.items = []; W.closing = false; W.pressId = null;
try { el.remove(); } catch (_) {}
return;
}
if (W.closing) return;
W.closing = true;
var fold = function () {
el.classList.add('tw-closing');
// Stays in the DOM (and keeps taking pointer events) until the fold has finished, so the click
// Chrome synthesises after a touch lands on the wheel — not on a word or a tab underneath it.
setTimeout(function () {
if (W.el === el) { W.el = null; W.items = []; W.closing = false; W.pressId = null; }
try { el.remove(); } catch (_) {}
}, CLOSE_MS + 60);
};
if (chosen) setTimeout(fold, CHOSEN_HOLD_MS); else fold();
}
// Shared by both shapes: whatever is under the finger, climb to the nearest thing carrying data-tw.
// (The slim dial's icons are pointer-transparent, so this always lands on the wedge itself.)
function itemAt(x, y) {
try {
var hit = document.elementFromPoint(x, y);
var it = hit && hit.closest && hit.closest('[data-tw]');
return (it && W.el && W.el.contains(it)) ? it : null;
} catch (_) { return null; }
}
function setPressed(it) { W.items.forEach(function (x) { mark(x, 'tw-pressed', x === it); }); }
function choose(it) {
var kind = it.getAttribute('data-tw');
var cur = activeTool();
W.items.forEach(function (x) { mark(x, 'tw-chosen', x === it); });
mark(it, 'tw-active', true);
if (kind !== cur) {
try { applyTool(kind); } catch (e) { log('could not apply', kind, e); }
log('tool →', kind, '(was ' + cur + ')');
}
armSnapBack();
close(false, true);
}
/*
* A tap on a choice, or a slide from the tap point onto one and lift — both pick it. Everything is handled
* on pointer events with the pointer captured, so a finger drifting off a choice still ends cleanly.
*/
function wire(el) {
el.addEventListener('pointerdown', function (e) {
// Ours: the page beneath (canvas, words, tabs) must never take it as its own.
e.stopPropagation();
if (e.cancelable) e.preventDefault();
if (W.closing) return;
W.pressId = e.pointerId;
try { (e.target && e.target.setPointerCapture ? e.target : el).setPointerCapture(e.pointerId); } catch (_) {}
setPressed(itemAt(e.clientX, e.clientY));
armAutoClose();
});
el.addEventListener('pointermove', function (e) {
if (W.closing || e.pointerId !== W.pressId) return;
setPressed(itemAt(e.clientX, e.clientY));
});
el.addEventListener('pointerup', function (e) {
if (e.pointerId !== W.pressId) return;
W.pressId = null;
if (W.closing) return;
var it = itemAt(e.clientX, e.clientY);
setPressed(null);
if (it) choose(it);
else close(false); // lifted on the hub or off the wheel: just put it away
});
el.addEventListener('pointercancel', function (e) {
if (e.pointerId !== W.pressId) return;
W.pressId = null;
setPressed(null);
});
// The compatibility mouse events / click Chrome synthesises after a touch: kept on the wheel, inert.
['click', 'dblclick', 'mousedown', 'mouseup', 'contextmenu'].forEach(function (type) {
el.addEventListener(type, function (e) { e.stopPropagation(); e.preventDefault(); });
});
}
// ------------------------------------------------------------------ the gesture
var P = new Map(); // touch pointers currently down: id -> { x0, y0, x, y, t0 }
var G = null; // the two-finger candidate: { t0, ids, last, lifted, dead }
function moved(p) { return Math.hypot(p.x - p.x0, p.y - p.y0) > MAX_MOVE_PX; }
function kill() { if (G) G.dead = true; }
function inWheel(target) { try { return !!(W.el && target && W.el.contains(target)); } catch (_) { return false; } }
function onDown(e) {
// Any press outside the wheel puts it away — and goes on to do whatever it was going to do.
if (W.el && !inWheel(e.target)) close(false);
if (e.pointerType !== 'touch') return;
var now = performance.now();
// A pointer that never lifted (a missed pointerup) must not jam the gesture for the rest of the service.
P.forEach(function (p, id) { if (now - p.t0 > 3000) P.delete(id); });
// `blocked` is decided by the FIRST finger: a tap on the splash dismisses it on pointerdown, so by the
// time the second finger lands the splash is already gone — the wheel must still not open on it.
P.set(e.pointerId, { x0: e.clientX, y0: e.clientY, x: e.clientX, y: e.clientY, t0: now, blocked: overlayUp() });
if (P.size === 1) { G = null; return; }
if (P.size > 2 || G) { kill(); return; }
var firstId = null, first = null;
P.forEach(function (p, id) { if (id !== e.pointerId) { firstId = id; first = p; } });
if (!first) return;
var dead = first.blocked || (now - first.t0 > SECOND_FINGER_MS) || moved(first) ||
Math.hypot(first.x - e.clientX, first.y - e.clientY) > MAX_SPREAD_PX ||
!enabled() || overlayUp() || !allowedHere() || inWheel(e.target);
if (dead) { G = { dead: true }; return; }
G = { t0: first.t0, ids: [firstId, e.pointerId], last: {}, lifted: 0, dead: false };
// Two fingers landing together are not a word tap, and not a stroke.
var g = guards();
if (g) { try { g.suppressWordInfo(700); } catch (_) {} try { g.cancelStroke(); } catch (_) {} }
}
function onMove(e) {
if (e.pointerType !== 'touch') return;
var p = P.get(e.pointerId);
if (!p) return;
p.x = e.clientX; p.y = e.clientY;
if (G && !G.dead && G.ids.indexOf(e.pointerId) !== -1 && moved(p)) kill();
}
function onUp(e) {
if (e.pointerType !== 'touch') return;
var p = P.get(e.pointerId);
if (!p) return;
P.delete(e.pointerId);
var now = performance.now();
if (G && !G.dead && G.ids.indexOf(e.pointerId) !== -1) {
p.x = e.clientX; p.y = e.clientY;
if (moved(p) || (now - G.t0) > MAX_TAP_MS) kill();
else {
G.last[e.pointerId] = { x: e.clientX, y: e.clientY };
G.lifted++;
if (G.lifted === 2) {
var a = G.last[G.ids[0]], b = G.last[G.ids[1]];
G = null;
var g = guards();
if (g) { try { g.suppressWordInfo(600); } catch (_) {} }
open((a.x + b.x) / 2, (a.y + b.y) / 2);
}
}
}
if (P.size === 0) G = null;
}
function onCancel(e) {
if (e.pointerType !== 'touch') return;
P.delete(e.pointerId);
kill();
if (P.size === 0) G = null;
}
function reset() { P.clear(); G = null; if (W.el) close(true); }
window.addEventListener('pointerdown', onDown, { capture: true, passive: true });
window.addEventListener('pointermove', onMove, { capture: true, passive: true });
window.addEventListener('pointerup', onUp, { capture: true, passive: true });
window.addEventListener('pointercancel', onCancel, { capture: true, passive: true });
// A touch that starts on the wheel: cancel the compat mouse/click chain at the source (see close()).
window.addEventListener('touchstart', function (e) { if (W.el && inWheel(e.target) && e.cancelable) e.preventDefault(); }, { capture: true, passive: false });
// Any other kind of input puts it away too.
window.addEventListener('wheel', function () { if (W.el) close(false); }, { capture: true, passive: true });
window.addEventListener('keydown', function () { if (W.el) close(false); }, true);
window.addEventListener('blur', reset);
document.addEventListener('visibilitychange', function () { if (document.hidden) reset(); }, true);
// ------------------------------------------------------------------ optional snap-back to the pen
var snapTimer = 0;
function clearSnap() { if (snapTimer) { clearTimeout(snapTimer); snapTimer = 0; } }
function armSnapBack() {
clearSnap();
var s = snapSec();
if (!s) return;
var cur = window.currentTool;
if (cur !== 'highlight' && cur !== 'erase') return;
snapTimer = setTimeout(fireSnap, s * 1000);
}
function dropdownOpen() {
try { return !!document.querySelector('.pen-color-dropdown.show, .highlighter-color-dropdown.show, .size-slider-popup.show, .laser-color-dropdown.show'); }
catch (_) { return false; }
}
function fireSnap() {
snapTimer = 0;
var s = snapSec();
if (!s) return;
var cur = window.currentTool;
if (cur !== 'highlight' && cur !== 'erase') return;
if (document.body.classList.contains('pt-tool-active')) return; // laser / spotlight own the screen; leave it
var g = guards();
var busy = P.size > 0 || (g && (g.drawing || g.multiTouch)) || dropdownOpen() || !!W.el;
if (busy) { armSnapBack(); return; }
log('back to the pen after', s, 's without a touch');
try { applyTool('pen'); } catch (_) {}
}
// Only counts time with nothing pressed: a press clears the countdown, a lift restarts it.
window.addEventListener('pointerdown', clearSnap, { capture: true, passive: true });
window.addEventListener('pointerup', function () { armSnapBack(); }, { capture: true, passive: true });
window.addEventListener('pointercancel', function () { armSnapBack(); }, { capture: true, passive: true });
// ------------------------------------------------------------------ console / test hooks
window.toolWheel = {
open: function (x, y) { return open(typeof x === 'number' ? x : window.innerWidth / 2, typeof y === 'number' ? y : window.innerHeight / 2); },
close: function () { close(true); },
get isOpen() { return !!W.el && !W.closing; },
get enabled() { return enabled(); },
get style() { return style(); },
get allowedHere() { return allowedHere(); },
get pointersDown() { return P.size; }
};
log('ready');
})();