232 lines
9.4 KiB
JavaScript
232 lines
9.4 KiB
JavaScript
/* The tutorial overlay, added to every page before it loads (Playwright addInitScript).
|
|
* Plain JS: it runs in the page, not in the build.
|
|
*
|
|
* - A steady pointer that follows the mouse Playwright moves (real DOM events, so it follows
|
|
* clicks, hovers and canvas drags) and a ring where the button goes down. It never fades.
|
|
* - A caption bar at the bottom for the narration line.
|
|
* - A callout: a box around a part of the screen with a short label.
|
|
*
|
|
* Everything is pointer-events: none and sits above the app, so it never takes a click. */
|
|
(() => {
|
|
if (window.__tut) return;
|
|
const Z = "2147483647";
|
|
const ARROW =
|
|
'<svg width="20" height="25" viewBox="0 0 26 32" xmlns="http://www.w3.org/2000/svg">' +
|
|
'<path d="M2 2 L2 26 L8.5 20 L13 30 L17.5 28 L13 18.5 L22 18.5 Z" fill="#fff" stroke="#111" stroke-width="1.8" stroke-linejoin="round"/></svg>';
|
|
|
|
let root = null;
|
|
let pointer = null;
|
|
let caption = null;
|
|
const callouts = new Map();
|
|
/**
|
|
* Puts a callout's box around `rect`, kept a few px inside the window so its stroke is never cut
|
|
* off at an edge, and its label (above, or below near the top) shifted in when it would run off.
|
|
*/
|
|
const EDGE = 6;
|
|
function placeCallout(box, rect) {
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
// Padding is the same on both sides of an axis: near an edge (a top-bar button) it shrinks on
|
|
// both, so the box stays centred on its part instead of losing only the side at the edge.
|
|
const room = (lo, hi, max) => Math.max(2, Math.min(8, lo - EDGE, max - EDGE - hi));
|
|
// And the same on every side: a top-bar button only has a few pixels above it, and a box with
|
|
// 8 px at the sides but 2 px above and below read as off-centre (wide and squat round a square
|
|
// button), so the tighter axis sets both.
|
|
const pad = Math.min(room(rect.x, rect.x + rect.width, vw), room(rect.y, rect.y + rect.height, vh));
|
|
const padX = pad;
|
|
const padY = pad;
|
|
// Clamped to the window, a side can still lose padding (a top-bar button sits closer to the
|
|
// top than EDGE): take the same off the opposite side, so the box stays centred on the part.
|
|
const cut = (lo, hi, p, max) => Math.min(p, lo - EDGE, max - EDGE - hi);
|
|
// Still the same on every side, for the reason above.
|
|
const px = Math.min(cut(rect.x, rect.x + rect.width, padX, vw), cut(rect.y, rect.y + rect.height, padY, vh));
|
|
const py = px;
|
|
const left = rect.x - px;
|
|
const top = rect.y - py;
|
|
const right = rect.x + rect.width + px;
|
|
const bottom = rect.y + rect.height + py;
|
|
box.style.left = `${left}px`;
|
|
box.style.top = `${top}px`;
|
|
box.style.width = `${Math.max(0, right - left)}px`;
|
|
box.style.height = `${Math.max(0, bottom - top)}px`;
|
|
const tag = box.firstElementChild;
|
|
if (!tag) return;
|
|
const tw = tag.offsetWidth;
|
|
const th = tag.offsetHeight;
|
|
// Above the box when there is room, else below it, else inside its top edge.
|
|
if (top - th - 8 >= EDGE) {
|
|
tag.style.top = "";
|
|
tag.style.bottom = "calc(100% + 8px)";
|
|
} else if (bottom + th + 8 <= vh - EDGE) {
|
|
tag.style.bottom = "";
|
|
tag.style.top = "calc(100% + 8px)";
|
|
} else {
|
|
tag.style.bottom = "";
|
|
tag.style.top = "8px";
|
|
}
|
|
// From the box's left edge, moved left as far as it must to stay inside the window.
|
|
const x = Math.min(-3, vw - EDGE - tw - left);
|
|
tag.style.left = `${Math.max(EDGE - left, x)}px`;
|
|
}
|
|
let x = -100;
|
|
let y = -100;
|
|
|
|
/**
|
|
* The caption sits at the bottom, unless a callout reaches down into its band (a canvas node or a
|
|
* dialog's footer that can't be scrolled up) and none sits at the top: then it moves up there.
|
|
*/
|
|
const BAND = 200;
|
|
function placeCaption() {
|
|
if (!caption) return;
|
|
const vh = window.innerHeight;
|
|
let low = false;
|
|
let high = false;
|
|
for (const box of callouts.values()) {
|
|
const top = parseFloat(box.style.top) || 0;
|
|
const bottom = top + (parseFloat(box.style.height) || 0);
|
|
if (bottom > vh - BAND) low = true;
|
|
if (top < BAND) high = true;
|
|
}
|
|
// An open list or menu (a mode dropdown, a picker) counts too: the viewer is reading it.
|
|
for (const el of document.querySelectorAll('[role="listbox"], [role="menu"], .p-select-overlay, .p-autocomplete-overlay')) {
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 4 || r.height < 4 || getComputedStyle(el).visibility === "hidden") continue;
|
|
if (r.bottom > vh - BAND) low = true;
|
|
if (r.top < BAND) high = true;
|
|
}
|
|
if (low && !high) {
|
|
caption.style.bottom = "";
|
|
caption.style.top = "72px";
|
|
} else {
|
|
caption.style.top = "";
|
|
caption.style.bottom = "44px";
|
|
}
|
|
}
|
|
|
|
function layer() {
|
|
if (root && root.isConnected) return root;
|
|
if (!document.body) return null;
|
|
root = document.createElement("div");
|
|
root.setAttribute("data-tut", "layer");
|
|
root.style.cssText = `position:fixed;inset:0;pointer-events:none;z-index:${Z};`;
|
|
pointer = document.createElement("div");
|
|
pointer.style.cssText = "position:absolute;left:0;top:0;width:20px;height:25px;transform:translate(-100px,-100px);filter:drop-shadow(0 2px 3px rgba(0,0,0,.45));will-change:transform;";
|
|
pointer.innerHTML = ARROW;
|
|
caption = document.createElement("div");
|
|
caption.style.cssText =
|
|
"position:absolute;left:50%;bottom:44px;transform:translateX(-50%);max-width:min(1400px,calc(100% - 120px));" +
|
|
"padding:14px 26px;border-radius:12px;background:rgba(12,12,16,.86);color:#fff;" +
|
|
"font:600 30px/1.35 'Inter','Segoe UI',system-ui,sans-serif;text-align:center;letter-spacing:.005em;" +
|
|
"box-shadow:0 6px 24px rgba(0,0,0,.4);opacity:0;transition:opacity .25s ease;";
|
|
root.append(caption, pointer);
|
|
document.body.appendChild(root);
|
|
place();
|
|
return root;
|
|
}
|
|
|
|
function place() {
|
|
if (pointer) pointer.style.transform = `translate(${x - 2}px,${y - 2}px)`;
|
|
}
|
|
|
|
function ring(px, py) {
|
|
const r = layer();
|
|
if (!r) return;
|
|
const el = document.createElement("div");
|
|
el.style.cssText =
|
|
`position:absolute;left:${px - 14}px;top:${py - 14}px;width:28px;height:28px;border-radius:50%;` +
|
|
"border:3px solid rgba(124,108,255,.95);background:rgba(124,108,255,.22);transform:scale(.4);opacity:1;" +
|
|
"transition:transform .35s ease-out,opacity .45s ease-out;";
|
|
r.insertBefore(el, pointer);
|
|
requestAnimationFrame(() => {
|
|
el.style.transform = "scale(1.25)";
|
|
el.style.opacity = "0";
|
|
});
|
|
setTimeout(() => el.remove(), 600);
|
|
}
|
|
|
|
const move = (e) => {
|
|
x = e.clientX;
|
|
y = e.clientY;
|
|
layer();
|
|
place();
|
|
};
|
|
// Capture phase on window: the canvas and dialogs stop some events from bubbling.
|
|
window.addEventListener("pointermove", move, true);
|
|
window.addEventListener("mousemove", move, true);
|
|
window.addEventListener("pointerdown", (e) => {
|
|
move(e);
|
|
ring(e.clientX, e.clientY);
|
|
}, true);
|
|
// A page that rebuilds its body (a route change) drops the layer: put it back.
|
|
setInterval(() => {
|
|
if (root && !root.isConnected && document.body) document.body.appendChild(root);
|
|
}, 400);
|
|
|
|
window.__tut = {
|
|
/** A magenta square in the top-right corner for a moment: render.mjs finds it to line the voice up with the video. */
|
|
mark(ms) {
|
|
const r = layer();
|
|
if (!r) return;
|
|
const m = document.createElement("div");
|
|
m.style.cssText = "position:absolute;right:0;top:0;width:16px;height:16px;background:#ff00ff;";
|
|
r.appendChild(m);
|
|
setTimeout(() => requestAnimationFrame(() => m.remove()), ms);
|
|
},
|
|
caption(text) {
|
|
layer();
|
|
if (!caption) return;
|
|
if (!text) {
|
|
caption.style.opacity = "0";
|
|
return;
|
|
}
|
|
caption.textContent = text;
|
|
caption.style.opacity = "1";
|
|
},
|
|
callout(id, rect, label) {
|
|
const r = layer();
|
|
if (!r) return;
|
|
this.uncallout(id);
|
|
const box = document.createElement("div");
|
|
box.style.cssText =
|
|
"position:absolute;box-sizing:border-box;" +
|
|
"border:3px solid #7c6cff;border-radius:12px;box-shadow:0 0 0 9999px rgba(0,0,0,.28),0 0 18px rgba(124,108,255,.7);" +
|
|
"opacity:0;transition:opacity .3s ease;";
|
|
if (label) {
|
|
const tag = document.createElement("div");
|
|
tag.textContent = label;
|
|
tag.style.cssText =
|
|
"position:absolute;left:-3px;white-space:nowrap;" +
|
|
"padding:6px 14px;border-radius:8px;background:#7c6cff;color:#fff;font:600 22px/1.3 'Inter','Segoe UI',system-ui,sans-serif;";
|
|
box.appendChild(tag);
|
|
}
|
|
r.insertBefore(box, caption);
|
|
placeCallout(box, rect);
|
|
requestAnimationFrame(() => (box.style.opacity = "1"));
|
|
callouts.set(id, box);
|
|
placeCaption();
|
|
},
|
|
/** Moves a callout to a new rect (its part moved or changed size). */
|
|
recallout(id, rect) {
|
|
const box = callouts.get(id);
|
|
if (!box) return;
|
|
placeCallout(box, rect);
|
|
placeCaption();
|
|
},
|
|
uncallout(id) {
|
|
const old = callouts.get(id);
|
|
if (!old) return;
|
|
callouts.delete(id);
|
|
placeCaption();
|
|
old.style.opacity = "0";
|
|
setTimeout(() => old.remove(), 320);
|
|
},
|
|
clear() {
|
|
for (const id of [...callouts.keys()]) this.uncallout(id);
|
|
this.caption("");
|
|
},
|
|
};
|
|
document.addEventListener("DOMContentLoaded", () => layer());
|
|
// Lists open and close without a callout changing, so the caption checks its place on a timer.
|
|
setInterval(() => { if (caption && caption.textContent) placeCaption(); }, 250);
|
|
})();
|