Files
skills/orchestrator/templates/page-bubbles.js
T

125 lines
4.5 KiB
JavaScript
Raw Normal View History

const BUBBLE_FONT = "/vendor/comic-neue.woff2";
const BUBBLE_FADE = 400;
const BUBBLE_EDGE = 8;
const bubbles = {shown: new Map(), frame: 0, font: null};
function bubbleLayer() {
return document.getElementById("bubbles");
}
function bubbleFont() {
// The CSP has no font-src, so a url() face is refused; a face built from fetched bytes loads nothing itself.
if (!bubbles.font) {
bubbles.font = fetch(BUBBLE_FONT, {cache: "force-cache"})
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(String(response.status)))))
.then((data) => new FontFace("Comic Neue", data, {style: "normal", weight: "400"}).load())
.then((loaded) => document.fonts.add(loaded))
.catch(() => null);
}
return bubbles.font;
}
function inView(box) {
return box.width > 0 && box.height > 0 && box.left + box.width > 0 && box.top + box.height > 0 && box.left < window.innerWidth && box.top < window.innerHeight;
}
function anchorOf(key) {
const projected = g3.tab === "agents" ? anchor3d(key)() : null;
if (projected && inView(projected)) {
return projected;
}
return [...document.querySelectorAll(`[data-face="${CSS.escape(key)}"]`)].map((node) => node.getBoundingClientRect()).find(inView) || null;
}
function bubbleNode(item) {
const words = h("span", {class: "bubble-words"});
return {node: h("div", {class: "speech-bubble", "data-who": item.who}, h("strong", {class: "bubble-who"}, nameOf(item.who)), words), words};
}
function placeBubble(view, box, scale) {
const layer = bubbleLayer();
const bar = layer.querySelector(".bubble-bar");
if (!box) {
if (view.node.parentElement !== bar) {
view.node.style.removeProperty("left");
view.node.style.removeProperty("top");
bar.append(view.node);
}
return;
}
if (view.node.parentElement !== layer) {
layer.append(view.node);
}
const [width, height] = [view.node.offsetWidth * scale, view.node.offsetHeight * scale];
const middle = box.left + box.width / 2;
const left = Math.min(Math.max(middle, BUBBLE_EDGE + width / 2), window.innerWidth - BUBBLE_EDGE - width / 2);
view.node.style.left = `${fixed(left)}px`;
view.node.style.top = `${fixed(Math.max(box.top - 4, BUBBLE_EDGE + height))}px`;
view.node.style.setProperty("--tail", `${fixed((middle - left) / scale)}px`);
}
function drawBubble(entry, view) {
const {item} = entry;
const said = item.words || Bubbles.words(item.line);
const count = item.started ? Bubbles.shown(said.length, item.duration, (performance.now() - item.started) / 1000) : 0;
const text = said.slice(0, count).join(" ") || "…";
if (view.words.textContent !== text) {
view.words.textContent = text;
view.words.scrollTop = view.words.scrollHeight;
}
Object.assign(view.node.dataset, {scale: String(entry.scale), state: item.started ? "speaking" : "waiting", words: String(count)});
view.node.style.setProperty("--scale", String(entry.scale));
placeBubble(view, anchorOf(entry.who), entry.scale);
}
function drawBubbles() {
if (!settings.bubbles) {
return;
}
const planned = Bubbles.plan(speech.current || speech.loading, speech.pending);
const kept = new Set(planned.map((entry) => entry.item));
for (const [item, view] of bubbles.shown) {
if (!kept.has(item)) {
bubbles.shown.delete(item);
view.node.classList.add("leaving");
window.setTimeout(() => view.node.remove(), BUBBLE_FADE);
}
}
for (const entry of planned) {
if (!bubbles.shown.has(entry.item)) {
bubbles.shown.set(entry.item, bubbleNode(entry.item));
}
drawBubble(entry, bubbles.shown.get(entry.item));
}
if (bubbles.shown.size && !bubbles.frame) {
bubbles.frame = window.requestAnimationFrame(() => {
bubbles.frame = 0;
drawBubbles();
});
}
}
function clearBubbles() {
window.cancelAnimationFrame(bubbles.frame);
bubbles.frame = 0;
bubbles.shown.clear();
for (const node of bubbleLayer().querySelectorAll(".speech-bubble")) {
node.remove();
}
}
function syncBubbles() {
document.getElementById("bubbles-toggle").setAttribute("aria-pressed", String(settings.bubbles));
bubbleLayer().hidden = !settings.bubbles;
if (settings.bubbles) {
bubbleFont();
drawBubbles();
} else {
clearBubbles();
dropSilent();
}
}
function toggleBubbles() {
saveSettings({...settings, bubbles: !settings.bubbles});
}