openvibers/openvibe.shared guide.js

guide.js

main 3.7 KB · 65 lines · javascript

1'use strict';
2
3(function (root) {
4
5/** Point at a real hooked control without activating it or covering it. The host owns pointer styling. */
6function startQuestGuide({ step, nextHref, root = document, onDone = () => {}, checkDone = null, pollMs = 5000 } = {}) {
7 if (!step || !/^[a-z][a-z0-9_.-]{1,63}$/.test(step.hook || '') || !step.event_type) return () => {};
8 const control = root.querySelector(`[data-ov-guide="${CSS.escape(step.hook)}"]`);
9 if (!control) return () => {};
10 control.scrollIntoView({ block: 'center', behavior: 'smooth' });
11 const note = root.createElement('aside');
12 note.setAttribute('data-ov-quest-guide', '');
13 note.setAttribute('role', 'status');
14 note.textContent = step.help;
15 control.insertAdjacentElement('beforebegin', note);
16 let finished = false;
17 const done = (event) => {
18 if (finished || !event || event.detail?.event_type !== step.event_type) return;
19 finished = true;
20 if (poll) clearInterval(poll);
21 note.replaceChildren(root.createTextNode('Done. '));
22 if (nextHref) {
23 const link = root.createElement('a'); link.href = nextHref; link.textContent = 'Next step'; note.append(link);
24 }
25 onDone(step);
26 };
27 root.addEventListener('ov:quest:step-done', done);
28 const poll = typeof checkDone === 'function' && !step.done ? setInterval(async () => {
29 try { const latest = await checkDone(); if (latest?.step?.done) done({ detail: { event_type: step.event_type } }); }
30 catch { /* the Events receipt may still be arriving; the next read retries */ }
31 }, pollMs) : null;
32 if (poll && typeof poll.unref === 'function') poll.unref();
33 const dismiss = (event) => {
34 if (event.key === 'Escape' || (event.type === 'pointerdown' && !note.contains(event.target) && !control.contains(event.target))) stop();
35 };
36 function stop() { if (poll) clearInterval(poll); root.removeEventListener('ov:quest:step-done', done); root.removeEventListener('keydown', dismiss); root.removeEventListener('pointerdown', dismiss); note.remove(); }
37 root.addEventListener('keydown', dismiss); root.addEventListener('pointerdown', dismiss);
38 if (step.done) done({ detail: { event_type: step.event_type } });
39 return stop;
40}
41
42/** Resolve only a short opaque step id. The host supplies an authenticated reader. */
43async function startFromLocation({ load, location = root.location, document: page = root.document, onDone } = {}) {
44 if (typeof load !== 'function' || !location || !page) return () => {};
45 const id = new URL(location.href).searchParams.get('qstep');
46 if (!id || !/^[A-Za-z0-9_-]{12,48}$/.test(id)) return () => {};
47 const answer = await load(id);
48 if (!answer || !answer.step || !answer.step.hook) return () => {};
49 // Many sites render the target after their page data arrives. Watch briefly instead of losing the guide.
50 const selector = `[data-ov-guide="${CSS.escape(answer.step.hook)}"]`;
51 if (!page.querySelector(selector) && page.defaultView?.MutationObserver) await new Promise((resolve) => {
52 const observer = new page.defaultView.MutationObserver(() => {
53 if (page.querySelector(selector)) { observer.disconnect(); clearTimeout(timer); resolve(); }
54 });
55 const timer = setTimeout(() => { observer.disconnect(); resolve(); }, 15000);
56 observer.observe(page.documentElement, { childList: true, subtree: true });
57 });
58 return startQuestGuide({ step: answer.step, nextHref: answer.next_href, root: page, onDone, checkDone: () => load(id) });
59}
60
61const api = { startQuestGuide, startFromLocation };
62if (typeof module === 'object' && module.exports) module.exports = api;
63if (root && root.document) root.OpenVibeQuestGuide = api;
64})(typeof window !== 'undefined' ? window : globalThis);
65