Files
bible-app/tool-wheel.js
T
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

562 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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');
})();