/* * 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 = ''; 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 = '' + t.t + ''; 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 = '' + t.t + ''; 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'); })();