mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(webchat): применять оформление кнопки из конфигурации
This commit is contained in:
1 parent
223a962cb9
commit
b160335bf6
17 files changed
+829
-400
No files matched your search
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: T-021
|
||||
title: Лоадер применяет оформление кнопки
|
||||
milestone: M04
|
||||
status: done
|
||||
depends_on:
|
||||
- T-020
|
||||
order: 3
|
||||
spec: "0021"
|
||||
created: 2026-09-29
|
||||
branch: skaro/T-021-loader-primenyaet-oformlenie-k
|
||||
---
|
||||
|
||||
## Цель
|
||||
|
||||
Кнопка на сайте получает цвет, иконку, положение, размер и форму из конфигурации (R-5).
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- [x] Лоадер запрашивает публичную конфигурацию и применяет accent (включая тень и фокус), иконку, left/right, 48/56/64, circle/rounded/square
|
||||
- [x] Панель открывается над кнопкой с той же стороны; анимация «джин» и полноэкранный режим на телефоне работают с обеих сторон
|
||||
- [x] Ошибка конфигурации не ломает страницу: кнопка остаётся в стандартном виде
|
||||
- [x] Проверено на тестовой странице для обеих сторон и трёх размеров
|
||||
|
||||
## Заметки
|
||||
|
||||
webchat/loader.py — цвет сейчас зашит #1677ff, в genie-анимации тоже (paintGenie).
|
||||
|
||||
## Итог
|
||||
|
||||
Ветка T-021 перенесена через git rebase main на 223a962c, конфликт views.py разрешён. Общие публичные обработчики используются из access.py свежей main, дубликат public_views.py удалён; передача preChatFields сохранена, обработчики конфигурации с CORS и лоадера сохранены. Новый коммит a0eb9f31: связанные проверки прошли, исходные настройки dev-подключения восстановлены.
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Публичная конфигурация для iframe и лоадера на сайте организации."""
|
||||
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.webchat import services
|
||||
from chatballs.webchat.access import _Public, _resolved_web_widget
|
||||
from chatballs.webchat.api_inputs import host_origin
|
||||
from chatballs.webchat.throttling import WebchatConfigThrottle
|
||||
|
||||
|
||||
class WebchatConfigView(_Public):
|
||||
throttle_classes = [WebchatConfigThrottle]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
widget_key = request.GET.get("widgetKey", "")
|
||||
channel_code = request.GET.get("channel", "")
|
||||
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
|
||||
if context is None or widget is None:
|
||||
response = Response({"available": False})
|
||||
else:
|
||||
response = Response(
|
||||
services.public_config(
|
||||
context=context, widget=widget, origin=host_origin(request),
|
||||
)
|
||||
)
|
||||
# Лоадер работает на другом домене. CORS разрешается только для
|
||||
# конфигурации доступного этому сайту виджета, без cookies и сессии.
|
||||
origin = request.headers.get("Origin")
|
||||
if origin and response.data.get("available"):
|
||||
response["Access-Control-Allow-Origin"] = origin
|
||||
patch_vary_headers(response, ["Origin"])
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
@@ -1,362 +1,14 @@
|
||||
# Публичный JS-лоадер виджета (SPEC-CHATBALLS-0003 §3). Подключается одним тегом:
|
||||
# <script src="https://<ваш-домен>/chat-widget.js"
|
||||
# data-widget-key="wgt_public_key" async></script>
|
||||
# Лоадер рисует launcher и открывает панель в изолированном iframe (/chat/).
|
||||
#
|
||||
# TODO (security): для production настроить CSP `frame-ancestors` для /chat/
|
||||
# (раздаётся vite/nginx, не Django — настраивается в infra/deploy), разрешив
|
||||
# домены сайтов, где стоит виджет. Домены — у владельца.
|
||||
"""Публичный автономный JS-лоадер: оформление, панель и анимация джина.
|
||||
|
||||
LOADER_JS = r"""
|
||||
(function () {
|
||||
var script = document.currentScript;
|
||||
if (!script) return;
|
||||
var widgetKey = script.getAttribute("data-widget-key") || "";
|
||||
var legacyChannel = script.getAttribute("data-channel") || "";
|
||||
if (!widgetKey && !legacyChannel) return;
|
||||
var origin = new URL(script.src, location.href).origin;
|
||||
var instanceId = "chat_" + Math.random().toString(36).slice(2);
|
||||
var entryQuery = widgetKey
|
||||
? "widgetKey=" + encodeURIComponent(widgetKey)
|
||||
: "channel=" + encodeURIComponent(legacyChannel);
|
||||
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
|
||||
|
||||
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
|
||||
var contentTimer = 0;
|
||||
var api = window.ChatballsChat = window.ChatballsChat || {};
|
||||
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
|
||||
|
||||
var style = document.createElement("style");
|
||||
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(22,119,255,0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(22,119,255,0.45);outline-offset:2px}";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
|
||||
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
|
||||
// остаётся на месте и показывает шеврон — им же панель и закрывают.
|
||||
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
|
||||
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "chatballs-chat-launcher";
|
||||
btn.type = "button";
|
||||
btn.setAttribute("aria-label", "Открыть чат");
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;background:#1677ff;box-shadow:0 8px 24px rgba(22,119,255,0.35);cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
|
||||
btn.innerHTML = BOT_ICON;
|
||||
|
||||
var dot = document.createElement("span");
|
||||
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
|
||||
btn.appendChild(dot);
|
||||
|
||||
var notification = new Audio(origin + "/chat/audio/notification.mp3");
|
||||
var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
|
||||
notification.preload = "auto";
|
||||
ringtone.preload = "auto";
|
||||
ringtone.loop = true;
|
||||
ringtone.volume = 1;
|
||||
|
||||
function play(audio) {
|
||||
var result = audio.play();
|
||||
if (result && result.catch) result.catch(function () {});
|
||||
}
|
||||
|
||||
function updateDot() {
|
||||
dot.style.display = !open && (unread || callActive) ? "block" : "none";
|
||||
}
|
||||
|
||||
function bumpLauncher() {
|
||||
btn.classList.remove("chatballs-chat-message-bump");
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add("chatballs-chat-message-bump");
|
||||
window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
|
||||
}
|
||||
|
||||
function setCallActive(active) {
|
||||
callActive = active;
|
||||
btn.classList.toggle("chatballs-chat-call-shake", active);
|
||||
if (active) play(ringtone);
|
||||
else {
|
||||
ringtone.pause();
|
||||
ringtone.currentTime = 0;
|
||||
}
|
||||
updateDot();
|
||||
}
|
||||
|
||||
function ensureFrame() {
|
||||
if (frame) return;
|
||||
shell = document.createElement("div");
|
||||
shell.style.cssText = "position:fixed;right:24px;bottom:92px;width:" + PANEL_WIDTH.normal + ";height:" + PANEL_HEIGHT.normal + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
|
||||
frame = document.createElement("iframe");
|
||||
frame.src = panelUrl;
|
||||
frame.title = "Чат";
|
||||
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
|
||||
shell.appendChild(frame);
|
||||
layoutShell();
|
||||
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
|
||||
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
|
||||
// повторяем его на load.
|
||||
frame.addEventListener("load", function () {
|
||||
notifyLayout();
|
||||
if (open) notifyOpened();
|
||||
});
|
||||
document.body.appendChild(shell);
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-layout-request") notifyLayout();
|
||||
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
|
||||
if (d.type === "chatballs-chat-unread") {
|
||||
unread = Boolean(d.unread);
|
||||
updateDot();
|
||||
}
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "message") {
|
||||
unread = !open;
|
||||
updateDot();
|
||||
if (!open && !callActive) bumpLauncher();
|
||||
notification.currentTime = 0;
|
||||
play(notification);
|
||||
}
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
|
||||
});
|
||||
}
|
||||
|
||||
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
|
||||
// самим iframe (transition в его стилях).
|
||||
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
|
||||
var PANEL_HEIGHT = { normal: "min(720px,calc(100vh - 116px))", expanded: "min(820px,calc(100vh - 116px))" };
|
||||
|
||||
// На телефоне плавающее окно не помещается: панель занимает весь экран,
|
||||
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
|
||||
var mobile = window.matchMedia("(max-width: 480px)");
|
||||
|
||||
function panelRadius() { return mobile.matches ? 0 : 24; }
|
||||
|
||||
function layoutShell() {
|
||||
if (!shell) return;
|
||||
var full = mobile.matches;
|
||||
shell.style.top = full ? "0" : "";
|
||||
shell.style.left = full ? "0" : "";
|
||||
shell.style.right = full ? "0" : "24px";
|
||||
shell.style.bottom = full ? "0" : "92px";
|
||||
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
|
||||
shell.style.height = full ? "100%" : expanded ? PANEL_HEIGHT.expanded : PANEL_HEIGHT.normal;
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
showLauncher();
|
||||
notifyLayout();
|
||||
}
|
||||
|
||||
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
|
||||
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
|
||||
function notifyLayout() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
|
||||
function showLauncher() {
|
||||
var hidden = open && mobile.matches;
|
||||
btn.style.opacity = hidden ? "0" : "1";
|
||||
btn.style.pointerEvents = hidden ? "none" : "";
|
||||
}
|
||||
|
||||
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
|
||||
else if (mobile.addListener) mobile.addListener(layoutShell);
|
||||
|
||||
function setExpanded(next) {
|
||||
expanded = next;
|
||||
layoutShell();
|
||||
}
|
||||
|
||||
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
|
||||
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
|
||||
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
|
||||
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
|
||||
// нельзя, внутри переворачивалась бы вёрстка.
|
||||
var GENIE_OPEN_MS = 300;
|
||||
var GENIE_CLOSE_MS = 240;
|
||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||
var genieSvg = null, geniePath = null, genieRaf = 0;
|
||||
|
||||
function ensureGenie() {
|
||||
if (genieSvg) return;
|
||||
genieSvg = document.createElementNS(SVG_NS, "svg");
|
||||
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
|
||||
geniePath = document.createElementNS(SVG_NS, "path");
|
||||
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
|
||||
genieSvg.appendChild(geniePath);
|
||||
document.body.appendChild(genieSvg);
|
||||
}
|
||||
|
||||
function smooth(x) {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
function mix(a, b, k) { return a + (b - a) * k; }
|
||||
|
||||
function shapeAt(p, panel, icon) {
|
||||
// Низ втягивается раньше верха — из-за этого и появляется горловина.
|
||||
var pb = smooth(Math.min(1, p * 1.7));
|
||||
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
|
||||
|
||||
var topY = mix(panel.top, icon.top, pt);
|
||||
var topHalf = mix(panel.half, icon.half, pt);
|
||||
var topCx = mix(panel.cx, icon.cx, pt);
|
||||
var topR = Math.min(mix(panel.radius, icon.half, pt), topHalf);
|
||||
|
||||
var bottomY = mix(panel.bottom, icon.bottom, pb);
|
||||
var bottomHalf = mix(panel.half, icon.half, pb);
|
||||
var bottomCx = mix(panel.cx, icon.cx, pb);
|
||||
var bottomR = Math.min(mix(panel.radius, icon.half, pb), bottomHalf);
|
||||
|
||||
return {
|
||||
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
|
||||
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
|
||||
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
|
||||
k: Math.max(0, (bottomY - topY) * 0.45),
|
||||
fill: pt
|
||||
};
|
||||
}
|
||||
|
||||
function pathOf(g, dx, dy) {
|
||||
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
|
||||
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
|
||||
return "M" + (tl + g.topR) + "," + ty +
|
||||
"H" + (tr - g.topR) +
|
||||
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
|
||||
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
|
||||
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
|
||||
"H" + (bl + g.bottomR) +
|
||||
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
|
||||
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
|
||||
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
|
||||
}
|
||||
|
||||
function paintGenie(p, panel, icon) {
|
||||
var g = shapeAt(p, panel, icon);
|
||||
geniePath.setAttribute("d", pathOf(g, 0, 0));
|
||||
geniePath.setAttribute(
|
||||
"fill",
|
||||
"rgb(" + Math.round(mix(255, 22, g.fill)) + "," +
|
||||
Math.round(mix(255, 119, g.fill)) + "," +
|
||||
Math.round(mix(255, 255, g.fill)) + ")"
|
||||
);
|
||||
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
|
||||
// координаты контура честно переводятся в его систему простым сдвигом.
|
||||
var box = shell.getBoundingClientRect();
|
||||
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
|
||||
|
||||
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
|
||||
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
|
||||
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
|
||||
var width = Math.max(1, box.width);
|
||||
var height = Math.max(1, box.height);
|
||||
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
|
||||
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
|
||||
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
|
||||
Math.max(1, g.bottomY - g.topY);
|
||||
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
|
||||
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
|
||||
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
|
||||
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.half, g.fill)) + "px";
|
||||
}
|
||||
|
||||
function restPanel() {
|
||||
shell.style.clipPath = "none";
|
||||
frame.style.transform = "none";
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
}
|
||||
|
||||
function playGenie(from, to, duration, panel, icon, done) {
|
||||
ensureGenie();
|
||||
genieSvg.style.display = "block";
|
||||
var started = 0;
|
||||
window.cancelAnimationFrame(genieRaf);
|
||||
function step(now) {
|
||||
if (!started) started = now;
|
||||
var linear = Math.min(1, (now - started) / duration);
|
||||
// Мягкий вход и выход: резкий старт читается как рывок.
|
||||
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
|
||||
paintGenie(mix(from, to, eased), panel, icon);
|
||||
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
|
||||
else done();
|
||||
}
|
||||
genieRaf = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function panelRect() {
|
||||
var box = shell.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2,
|
||||
radius: panelRadius()
|
||||
};
|
||||
}
|
||||
|
||||
function iconRect() {
|
||||
var box = btn.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2
|
||||
};
|
||||
}
|
||||
|
||||
function notifyOpened() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
function setOpen(next) {
|
||||
ensureFrame();
|
||||
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
|
||||
// запуска анимации, чтобы панель не мелькнула целиком.
|
||||
ensureGenie();
|
||||
open = next;
|
||||
window.clearTimeout(contentTimer);
|
||||
btn.style.opacity = "0";
|
||||
btn.style.pointerEvents = "none";
|
||||
if (open) {
|
||||
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
|
||||
// границам панели, а они известны только разложенной.
|
||||
shell.style.display = "block";
|
||||
paintGenie(1, panelRect(), iconRect());
|
||||
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
|
||||
if (!open) return;
|
||||
genieSvg.style.display = "none";
|
||||
restPanel();
|
||||
showLauncher();
|
||||
});
|
||||
} else {
|
||||
var box = panelRect();
|
||||
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
|
||||
if (open) return;
|
||||
genieSvg.style.display = "none";
|
||||
shell.style.display = "none";
|
||||
restPanel();
|
||||
showLauncher();
|
||||
});
|
||||
}
|
||||
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
|
||||
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
|
||||
btn.appendChild(dot);
|
||||
if (open) {
|
||||
unread = false;
|
||||
updateDot();
|
||||
if (callActive) play(ringtone);
|
||||
notifyOpened();
|
||||
} else updateDot();
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () { setOpen(!open); });
|
||||
function mount() {
|
||||
document.body.appendChild(btn);
|
||||
ensureFrame();
|
||||
}
|
||||
if (document.body) mount();
|
||||
else window.addEventListener("DOMContentLoaded", mount);
|
||||
})();
|
||||
Модули собираются в одну IIFE; дополнительных запросов за JS на сайте нет.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_ASSETS = Path(__file__).with_name("loader_assets")
|
||||
LOADER_JS = (_ASSETS / "runtime.js").read_text(encoding="utf-8")
|
||||
for _module in ("appearance", "launcher", "genie"):
|
||||
LOADER_JS = LOADER_JS.replace(
|
||||
f"/*__{_module.upper()}__*/",
|
||||
(_ASSETS / f"{_module}.js").read_text(encoding="utf-8"),
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
// Публичная конфигурация не должна мешать загрузке страницы хоста.
|
||||
var appearance = { accent: "#1677ff", rgb: [22, 119, 255], position: "right", size: 56, shape: "circle" };
|
||||
var launcherIcon = null;
|
||||
|
||||
function choice(value, allowed, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
if (allowed.indexOf(value) < 0) throw new Error("Invalid launcher appearance");
|
||||
return value;
|
||||
}
|
||||
|
||||
function readAppearance(config) {
|
||||
if (!config || config.available !== true) return null;
|
||||
var raw = config.appearance === undefined ? {} : config.appearance;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid appearance");
|
||||
var accent = raw.accent || config.accent || "#1677ff";
|
||||
if (typeof accent !== "string" || !/^#[0-9a-f]{6}$/i.test(accent)) throw new Error("Invalid accent");
|
||||
var icon = raw.launcherIcon || "";
|
||||
if (typeof icon !== "string") throw new Error("Invalid launcher icon");
|
||||
if (icon) {
|
||||
if (icon.length > 2048 || /[\x00-\x20\x7f\\]/.test(icon) ||
|
||||
!(/^(https?:\/\/|\/(?!\/))/i.test(icon))) throw new Error("Invalid launcher icon URL");
|
||||
icon = new URL(icon, origin).href;
|
||||
}
|
||||
return {
|
||||
accent: accent,
|
||||
rgb: [1, 3, 5].map(function (offset) { return parseInt(accent.slice(offset, offset + 2), 16); }),
|
||||
position: choice(raw.launcherPosition, ["left", "right"], "right"),
|
||||
size: choice(raw.launcherSize, [48, 56, 64], 56),
|
||||
shape: choice(raw.launcherShape, ["circle", "rounded", "square"], "circle"),
|
||||
icon: icon
|
||||
};
|
||||
}
|
||||
|
||||
function launcherRadius() {
|
||||
return appearance.shape === "square" ? 10 : appearance.size * (appearance.shape === "rounded" ? 0.3 : 0.5);
|
||||
}
|
||||
|
||||
function renderLauncher() {
|
||||
btn.innerHTML = open ? CHEVRON_ICON : BOT_ICON;
|
||||
if (!open && launcherIcon) {
|
||||
btn.textContent = "";
|
||||
btn.appendChild(launcherIcon);
|
||||
}
|
||||
btn.appendChild(dot);
|
||||
}
|
||||
|
||||
function applyAppearance(next) {
|
||||
if (animating) {
|
||||
pendingAppearance = next;
|
||||
return;
|
||||
}
|
||||
appearance = next;
|
||||
btn.style.setProperty("--chatballs-accent", next.accent);
|
||||
btn.style.setProperty("--chatballs-accent-rgb", next.rgb.join(","));
|
||||
btn.style.left = next.position === "left" ? "24px" : "";
|
||||
btn.style.right = next.position === "right" ? "24px" : "";
|
||||
btn.style.width = btn.style.height = next.size + "px";
|
||||
btn.style.borderRadius = next.shape === "square" ? "10px" : next.shape === "rounded" ? "30%" : "50%";
|
||||
layoutShell();
|
||||
if (!next.icon) return;
|
||||
// Загружаем как изображение, не вставляем SVG/URL в HTML страницы хоста.
|
||||
var image = document.createElement("img");
|
||||
image.alt = "";
|
||||
image.style.cssText = "width:30px;height:30px;object-fit:contain;flex:none;";
|
||||
image.onload = function () {
|
||||
launcherIcon = image;
|
||||
renderLauncher();
|
||||
};
|
||||
image.onerror = function () {
|
||||
launcherIcon = null;
|
||||
renderLauncher();
|
||||
};
|
||||
image.src = next.icon;
|
||||
}
|
||||
|
||||
function loadAppearance() {
|
||||
if (!window.fetch) return;
|
||||
var configUrl = origin + "/api/v1/webchat/config/?" + entryQuery + "&hostOrigin=" + encodeURIComponent(location.origin);
|
||||
window.fetch(configUrl, { cache: "no-cache", credentials: "omit" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error("Widget configuration unavailable");
|
||||
return response.json();
|
||||
})
|
||||
.then(function (config) {
|
||||
var next = readAppearance(config);
|
||||
if (next) applyAppearance(next);
|
||||
})
|
||||
.catch(function () { /* Стандартная кнопка уже смонтирована. */ });
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Эффект джина: окно втягивается в кнопку, вытягивая горловину, — как в
|
||||
// доке macOS. Аффинными трансформациями такое не получить: там изгиб, а не
|
||||
// масштаб. Поэтому силуэт рисуется контуром SVG и перестраивается на каждом
|
||||
// кадре. Сам iframe в анимации не участвует — менять его размеры покадрово
|
||||
// нельзя, внутри переворачивалась бы вёрстка.
|
||||
var GENIE_OPEN_MS = 300;
|
||||
var GENIE_CLOSE_MS = 240;
|
||||
var SVG_NS = "http://www.w3.org/2000/svg";
|
||||
var genieSvg = null, geniePath = null, genieRaf = 0;
|
||||
|
||||
function ensureGenie() {
|
||||
if (genieSvg) return;
|
||||
genieSvg = document.createElementNS(SVG_NS, "svg");
|
||||
genieSvg.style.cssText = "position:fixed;left:0;top:0;width:100%;height:100%;z-index:2147482999;pointer-events:none;display:none;overflow:visible;";
|
||||
geniePath = document.createElementNS(SVG_NS, "path");
|
||||
geniePath.style.filter = "drop-shadow(0 12px 40px rgba(0,0,0,0.18))";
|
||||
genieSvg.appendChild(geniePath);
|
||||
document.body.appendChild(genieSvg);
|
||||
}
|
||||
|
||||
function smooth(x) {
|
||||
if (x <= 0) return 0;
|
||||
if (x >= 1) return 1;
|
||||
return x * x * (3 - 2 * x);
|
||||
}
|
||||
|
||||
function mix(a, b, k) { return a + (b - a) * k; }
|
||||
|
||||
function shapeAt(p, panel, icon) {
|
||||
// Низ втягивается раньше верха — из-за этого и появляется горловина.
|
||||
var pb = smooth(Math.min(1, p * 1.7));
|
||||
var pt = smooth(Math.max(0, (p - 0.3) / 0.7));
|
||||
|
||||
var topY = mix(panel.top, icon.top, pt);
|
||||
var topHalf = mix(panel.half, icon.half, pt);
|
||||
var topCx = mix(panel.cx, icon.cx, pt);
|
||||
var topR = Math.min(mix(panel.radius, icon.radius, pt), topHalf);
|
||||
|
||||
var bottomY = mix(panel.bottom, icon.bottom, pb);
|
||||
var bottomHalf = mix(panel.half, icon.half, pb);
|
||||
var bottomCx = mix(panel.cx, icon.cx, pb);
|
||||
var bottomR = Math.min(mix(panel.radius, icon.radius, pb), bottomHalf);
|
||||
|
||||
return {
|
||||
topY: topY, topL: topCx - topHalf, topRt: topCx + topHalf, topR: topR,
|
||||
bottomY: bottomY, bottomL: bottomCx - bottomHalf, bottomRt: bottomCx + bottomHalf, bottomR: bottomR,
|
||||
// Плечо кривой: чем длиннее горловина, тем мягче изгиб.
|
||||
k: Math.max(0, (bottomY - topY) * 0.45),
|
||||
fill: pt
|
||||
};
|
||||
}
|
||||
|
||||
function pathOf(g, dx, dy) {
|
||||
var tl = g.topL - dx, tr = g.topRt - dx, ty = g.topY - dy;
|
||||
var bl = g.bottomL - dx, br = g.bottomRt - dx, by = g.bottomY - dy;
|
||||
return "M" + (tl + g.topR) + "," + ty +
|
||||
"H" + (tr - g.topR) +
|
||||
"Q" + tr + "," + ty + " " + tr + "," + (ty + g.topR) +
|
||||
"C" + tr + "," + (ty + g.k) + " " + br + "," + (by - g.k) + " " + br + "," + (by - g.bottomR) +
|
||||
"Q" + br + "," + by + " " + (br - g.bottomR) + "," + by +
|
||||
"H" + (bl + g.bottomR) +
|
||||
"Q" + bl + "," + by + " " + bl + "," + (by - g.bottomR) +
|
||||
"C" + bl + "," + (by - g.k) + " " + tl + "," + (ty + g.k) + " " + tl + "," + (ty + g.topR) +
|
||||
"Q" + tl + "," + ty + " " + (tl + g.topR) + "," + ty + "Z";
|
||||
}
|
||||
|
||||
function paintGenie(p, panel, icon) {
|
||||
var g = shapeAt(p, panel, icon);
|
||||
geniePath.setAttribute("d", pathOf(g, 0, 0));
|
||||
geniePath.setAttribute(
|
||||
"fill",
|
||||
"rgb(" + Math.round(mix(255, appearance.rgb[0], g.fill)) + "," +
|
||||
Math.round(mix(255, appearance.rgb[1], g.fill)) + "," +
|
||||
Math.round(mix(255, appearance.rgb[2], g.fill)) + ")"
|
||||
);
|
||||
// Контур режет контейнер. Его собственная геометрия не меняется, поэтому
|
||||
// координаты контура честно переводятся в его систему простым сдвигом.
|
||||
var box = shell.getBoundingClientRect();
|
||||
shell.style.clipPath = "path('" + pathOf(g, box.left, box.top) + "')";
|
||||
|
||||
// Окно внутри повторяет форму: ширина идёт за горловиной, высота — за
|
||||
// длиной силуэта, наклон — за уходом центра к кнопке. Содержимое из-за
|
||||
// этого сжимается и заваливается вместе с окном, а не стоит на месте.
|
||||
var width = Math.max(1, box.width);
|
||||
var height = Math.max(1, box.height);
|
||||
var sx = Math.max(0.001, (g.topRt - g.topL) / width);
|
||||
var sy = Math.max(0.001, (g.bottomY - g.topY) / height);
|
||||
var lean = ((g.bottomL + g.bottomRt) / 2 - (g.topL + g.topRt) / 2) /
|
||||
Math.max(1, g.bottomY - g.topY);
|
||||
var skew = Math.atan((sy / sx) * lean) * 180 / Math.PI;
|
||||
frame.style.transform = "translate(" + (g.topL - box.left) + "px," + (g.topY - box.top) + "px)" +
|
||||
" scale(" + sx + "," + sy + ") skewX(" + skew + "deg)";
|
||||
frame.style.borderRadius = Math.round(mix(panelRadius(), icon.radius, g.fill)) + "px";
|
||||
}
|
||||
|
||||
function restPanel() {
|
||||
shell.style.clipPath = "none";
|
||||
frame.style.transform = "none";
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
}
|
||||
|
||||
function playGenie(from, to, duration, panel, icon, done) {
|
||||
ensureGenie();
|
||||
genieSvg.style.display = "block";
|
||||
var started = 0;
|
||||
window.cancelAnimationFrame(genieRaf);
|
||||
function step(now) {
|
||||
if (!started) started = now;
|
||||
var linear = Math.min(1, (now - started) / duration);
|
||||
// Мягкий вход и выход: резкий старт читается как рывок.
|
||||
var eased = linear < 0.5 ? 2 * linear * linear : 1 - Math.pow(-2 * linear + 2, 2) / 2;
|
||||
paintGenie(mix(from, to, eased), panel, icon);
|
||||
if (linear < 1) genieRaf = window.requestAnimationFrame(step);
|
||||
else done();
|
||||
}
|
||||
genieRaf = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function panelRect() {
|
||||
var box = shell.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2,
|
||||
radius: panelRadius()
|
||||
};
|
||||
}
|
||||
|
||||
function iconRect() {
|
||||
var box = btn.getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
cx: box.left + box.width / 2,
|
||||
half: box.width / 2,
|
||||
radius: launcherRadius()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
var style = document.createElement("style");
|
||||
style.textContent = "@keyframes chatballs-chat-pop{from{opacity:0;transform:scale(.55) rotate(-25deg)}to{opacity:1;transform:none}}.chatballs-chat-launcher svg{animation:chatballs-chat-pop .24s cubic-bezier(.16,1,.3,1)}@keyframes chatballs-chat-blink{0%,92%,100%{transform:scaleY(1)}95%{transform:scaleY(.12)}}.chatballs-chat-eyes{transform-origin:24px 20px;animation:chatballs-chat-blink 4.2s infinite}@keyframes chatballs-chat-message-bump{0%,100%{transform:translateY(0)}35%{transform:translateY(-6px)}70%{transform:translateY(-2px)}}@keyframes chatballs-chat-call-shake{0%,18%,100%{transform:translateX(0)}3%{transform:translateX(-5px)}6%{transform:translateX(5px)}9%{transform:translateX(-4px)}12%{transform:translateX(4px)}15%{transform:translateX(-2px)}}.chatballs-chat-message-bump{animation:chatballs-chat-message-bump .42s ease-out}.chatballs-chat-call-shake{animation:chatballs-chat-call-shake 3.2s ease-in-out infinite}.chatballs-chat-launcher{box-shadow:0 8px 24px rgba(var(--chatballs-accent-rgb),0.35)}.chatballs-chat-launcher:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(var(--chatballs-accent-rgb),0.45)}.chatballs-chat-launcher:focus-visible{outline:3px solid rgba(var(--chatballs-accent-rgb),0.45);outline-offset:2px}";
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// Launcher — круглая кнопка со знаком агента. Знак вшит как inline SVG:
|
||||
// на сайте, где стоит виджет, наших файлов нет. Пока панель открыта, кнопка
|
||||
// остаётся на месте и показывает шеврон — им же панель и закрывают.
|
||||
var BOT_ICON = '<svg viewBox="0 0 48 48" width="30" height="30" fill="#fff" style="flex:none"><mask id="chatballs-launcher-face" maskUnits="userSpaceOnUse" x="0" y="0" width="48" height="48"><rect width="48" height="48" fill="#fff"/><g class="chatballs-chat-eyes"><rect x="15" y="16.4" width="4" height="7.2" rx="2" fill="#000"/><rect x="29" y="16.4" width="4" height="7.2" rx="2" fill="#000"/></g><path d="M17.6 26.4c1.7 2.4 3.9 3.6 6.4 3.6s4.7-1.2 6.4-3.6" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round"/></mask><circle cx="24" cy="3.6" r="2.4"/><rect x="22.8" y="4.8" width="2.4" height="5"/><rect x="2.2" y="18" width="2.6" height="8.4" rx="1.3"/><rect x="43.2" y="18" width="2.6" height="8.4" rx="1.3"/><path mask="url(#chatballs-launcher-face)" d="M14 9.2h20a6.8 6.8 0 0 1 6.8 6.8v12.4a6.8 6.8 0 0 1-6.8 6.8H21.6l-6.4 5.2a.9.9 0 0 1-1.5-.7v-4.5h-.3A6.8 6.8 0 0 1 7.2 28.4V16A6.8 6.8 0 0 1 14 9.2Z"/></svg>';
|
||||
var CHEVRON_ICON = '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="flex:none"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "chatballs-chat-launcher";
|
||||
btn.type = "button";
|
||||
btn.setAttribute("aria-label", "Открыть чат");
|
||||
btn.style.cssText = "position:fixed;right:24px;bottom:24px;width:56px;height:56px;padding:0;border:none;border-radius:50%;--chatballs-accent-rgb:22,119,255;background:var(--chatballs-accent);--chatballs-accent:#1677ff;cursor:pointer;z-index:2147483002;display:flex;align-items:center;justify-content:center;color:#fff;transition:transform .18s ease,box-shadow .18s ease,opacity .14s ease;";
|
||||
btn.innerHTML = BOT_ICON;
|
||||
|
||||
var dot = document.createElement("span");
|
||||
dot.style.cssText = "position:absolute;top:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:#faad14;border:2px solid #fff;display:none;";
|
||||
btn.appendChild(dot);
|
||||
|
||||
var notification = new Audio(origin + "/chat/audio/notification.mp3");
|
||||
var ringtone = new Audio(origin + "/chat/audio/ringtone.mp3");
|
||||
notification.preload = "auto";
|
||||
ringtone.preload = "auto";
|
||||
ringtone.loop = true;
|
||||
ringtone.volume = 1;
|
||||
|
||||
function play(audio) {
|
||||
var result = audio.play();
|
||||
if (result && result.catch) result.catch(function () {});
|
||||
}
|
||||
|
||||
function updateDot() {
|
||||
dot.style.display = !open && (unread || callActive) ? "block" : "none";
|
||||
}
|
||||
|
||||
function bumpLauncher() {
|
||||
btn.classList.remove("chatballs-chat-message-bump");
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add("chatballs-chat-message-bump");
|
||||
window.setTimeout(function () { btn.classList.remove("chatballs-chat-message-bump"); }, 450);
|
||||
}
|
||||
|
||||
function setCallActive(active) {
|
||||
callActive = active;
|
||||
btn.classList.toggle("chatballs-chat-call-shake", active);
|
||||
if (active) play(ringtone);
|
||||
else {
|
||||
ringtone.pause();
|
||||
ringtone.currentTime = 0;
|
||||
}
|
||||
updateDot();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
(function () {
|
||||
var script = document.currentScript;
|
||||
if (!script) return;
|
||||
var widgetKey = script.getAttribute("data-widget-key") || "";
|
||||
var legacyChannel = script.getAttribute("data-channel") || "";
|
||||
if (!widgetKey && !legacyChannel) return;
|
||||
var origin = new URL(script.src, location.href).origin;
|
||||
var instanceId = "chat_" + Math.random().toString(36).slice(2);
|
||||
var entryQuery = widgetKey
|
||||
? "widgetKey=" + encodeURIComponent(widgetKey)
|
||||
: "channel=" + encodeURIComponent(legacyChannel);
|
||||
var panelUrl = origin + "/chat/?" + entryQuery + "&instanceId=" + encodeURIComponent(instanceId);
|
||||
|
||||
var open = false, frame = null, shell = null, unread = false, callActive = false, expanded = false;
|
||||
var animating = false, pendingAppearance = null;
|
||||
var api = window.ChatballsChat = window.ChatballsChat || {};
|
||||
window.ChatballsChat = api; // legacy alias для уже встроенных хостов
|
||||
|
||||
/*__APPEARANCE__*/
|
||||
|
||||
/*__LAUNCHER__*/
|
||||
|
||||
function ensureFrame() {
|
||||
if (frame) return;
|
||||
shell = document.createElement("div");
|
||||
shell.style.cssText = "position:fixed;width:" + PANEL_WIDTH.normal + ";height:" + panelHeight() + ";z-index:2147483001;display:none;transition:width .32s cubic-bezier(.4,0,.2,1),height .32s cubic-bezier(.4,0,.2,1);";
|
||||
frame = document.createElement("iframe");
|
||||
frame.src = panelUrl;
|
||||
frame.title = "Чат";
|
||||
frame.style.cssText = "width:100%;height:100%;border:none;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,0.18);background:transparent;display:block;transform-origin:0 0;";
|
||||
shell.appendChild(frame);
|
||||
layoutShell();
|
||||
// Панель может открыться раньше, чем загрузится iframe — тогда сообщение
|
||||
// «открыто» (по нему виджет доскроллит ленту вниз) будет потеряно, поэтому
|
||||
// повторяем его на load.
|
||||
frame.addEventListener("load", function () {
|
||||
notifyLayout();
|
||||
if (open) notifyOpened();
|
||||
});
|
||||
document.body.appendChild(shell);
|
||||
window.addEventListener("message", function (e) {
|
||||
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
||||
var d = e.data || {};
|
||||
if (d.instanceId && d.instanceId !== instanceId) return;
|
||||
if (d.type === "chatballs-chat-close") setOpen(false);
|
||||
if (d.type === "chatballs-chat-layout-request") notifyLayout();
|
||||
if (d.type === "chatballs-chat-expand") setExpanded(Boolean(d.expanded));
|
||||
if (d.type === "chatballs-chat-unread") {
|
||||
unread = Boolean(d.unread);
|
||||
updateDot();
|
||||
}
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "message") {
|
||||
unread = !open;
|
||||
updateDot();
|
||||
if (!open && !callActive) bumpLauncher();
|
||||
notification.currentTime = 0;
|
||||
play(notification);
|
||||
}
|
||||
if (d.type === "chatballs-chat-activity" && d.kind === "call") setCallActive(Boolean(d.active));
|
||||
});
|
||||
}
|
||||
|
||||
// Развёрнутая панель почти вдвое шире и немного выше; переход анимируется
|
||||
// самим iframe (transition в его стилях).
|
||||
var PANEL_WIDTH = { normal: "min(440px,calc(100vw - 32px))", expanded: "min(820px,calc(100vw - 32px))" };
|
||||
function panelHeight() {
|
||||
return "min(" + (expanded ? 820 : 720) + "px,calc(100vh - " + (appearance.size + 60) + "px))";
|
||||
}
|
||||
|
||||
// На телефоне плавающее окно не помещается: панель занимает весь экран,
|
||||
// разворачивать её уже некуда, а закрывают её кнопкой в шапке.
|
||||
var mobile = window.matchMedia("(max-width: 480px)");
|
||||
|
||||
function panelRadius() { return mobile.matches ? 0 : 24; }
|
||||
|
||||
function layoutShell() {
|
||||
if (!shell) return;
|
||||
var full = mobile.matches;
|
||||
shell.style.top = full ? "0" : "";
|
||||
shell.style.left = full ? "0" : appearance.position === "left" ? "24px" : "";
|
||||
shell.style.right = full ? "0" : appearance.position === "right" ? "24px" : "";
|
||||
shell.style.bottom = full ? "0" : (appearance.size + 36) + "px";
|
||||
shell.style.width = full ? "100%" : expanded ? PANEL_WIDTH.expanded : PANEL_WIDTH.normal;
|
||||
shell.style.height = full ? "100%" : panelHeight();
|
||||
frame.style.borderRadius = panelRadius() + "px";
|
||||
showLauncher();
|
||||
notifyLayout();
|
||||
}
|
||||
|
||||
// Сама панель ширину экрана не видит: в iframe её вьюпорт — это окно
|
||||
// виджета. Поэтому про полноэкранный режим ей говорит лоадер.
|
||||
function notifyLayout() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-layout", fullscreen: mobile.matches }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
// Пока панель на весь экран, кнопка висела бы поверх поля ввода.
|
||||
function showLauncher() {
|
||||
var hidden = open && mobile.matches;
|
||||
btn.style.opacity = hidden ? "0" : "1";
|
||||
btn.style.pointerEvents = hidden ? "none" : "";
|
||||
}
|
||||
|
||||
if (mobile.addEventListener) mobile.addEventListener("change", layoutShell);
|
||||
else if (mobile.addListener) mobile.addListener(layoutShell);
|
||||
|
||||
function setExpanded(next) {
|
||||
expanded = next;
|
||||
layoutShell();
|
||||
}
|
||||
|
||||
/*__GENIE__*/
|
||||
|
||||
function notifyOpened() {
|
||||
try { frame.contentWindow.postMessage({ type: "chatballs-chat-opened" }, origin); } catch (_) {}
|
||||
}
|
||||
|
||||
function finishAnimation() {
|
||||
animating = false;
|
||||
genieSvg.style.display = "none";
|
||||
restPanel();
|
||||
showLauncher();
|
||||
if (pendingAppearance) {
|
||||
var next = pendingAppearance;
|
||||
pendingAppearance = null;
|
||||
applyAppearance(next);
|
||||
}
|
||||
}
|
||||
|
||||
function setOpen(next) {
|
||||
ensureFrame();
|
||||
// Слой силуэта нужен уже на первом кадре: первый же кадр рисуется до
|
||||
// запуска анимации, чтобы панель не мелькнула целиком.
|
||||
ensureGenie();
|
||||
open = next;
|
||||
animating = true;
|
||||
btn.style.opacity = "0";
|
||||
btn.style.pointerEvents = "none";
|
||||
if (open) {
|
||||
// Контейнер раскладывается до расчёта: силуэт строится по настоящим
|
||||
// границам панели, а они известны только разложенной.
|
||||
shell.style.display = "block";
|
||||
paintGenie(1, panelRect(), iconRect());
|
||||
playGenie(1, 0, GENIE_OPEN_MS, panelRect(), iconRect(), function () {
|
||||
if (!open) return;
|
||||
finishAnimation();
|
||||
});
|
||||
} else {
|
||||
var box = panelRect();
|
||||
playGenie(0, 1, GENIE_CLOSE_MS, box, iconRect(), function () {
|
||||
if (open) return;
|
||||
shell.style.display = "none";
|
||||
finishAnimation();
|
||||
});
|
||||
}
|
||||
renderLauncher();
|
||||
btn.setAttribute("aria-label", open ? "Свернуть чат" : "Открыть чат");
|
||||
if (open) {
|
||||
unread = false;
|
||||
updateDot();
|
||||
if (callActive) play(ringtone);
|
||||
notifyOpened();
|
||||
} else updateDot();
|
||||
}
|
||||
|
||||
btn.addEventListener("click", function () { setOpen(!open); });
|
||||
function mount() {
|
||||
document.body.appendChild(btn);
|
||||
ensureFrame();
|
||||
loadAppearance();
|
||||
}
|
||||
if (document.body) mount();
|
||||
else window.addEventListener("DOMContentLoaded", mount);
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Раздача автономного лоадера с кэшем не дольше пяти минут."""
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.views import View
|
||||
|
||||
from chatballs.webchat.loader import LOADER_JS
|
||||
|
||||
LOADER_MAX_AGE_SECONDS = 300
|
||||
|
||||
|
||||
class WidgetLoaderView(View):
|
||||
def get(self, request) -> HttpResponse:
|
||||
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
|
||||
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
|
||||
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
|
||||
return response
|
||||
@@ -195,3 +195,34 @@ class WebAppearanceApiTests(TestCase):
|
||||
def test_loader_is_cached_no_longer_than_five_minutes(self) -> None:
|
||||
response = self.client.get("/chat-widget.js")
|
||||
self.assertEqual(response["Cache-Control"], "public, max-age=300")
|
||||
|
||||
def test_allowed_site_can_read_public_config_without_credentials(self) -> None:
|
||||
response = self.client.get(
|
||||
"/api/v1/webchat/config/",
|
||||
{"widgetKey": self.integration.web_chat_widget.public_key},
|
||||
HTTP_ORIGIN="https://example.com",
|
||||
)
|
||||
self.assertTrue(response.json()["available"])
|
||||
self.assertEqual(response["Access-Control-Allow-Origin"], "https://example.com")
|
||||
self.assertFalse(response.has_header("Access-Control-Allow-Credentials"))
|
||||
self.assertIn("Origin", response["Vary"])
|
||||
|
||||
def test_other_site_cannot_override_origin_or_read_config(self) -> None:
|
||||
response = self.client.get(
|
||||
"/api/v1/webchat/config/",
|
||||
{
|
||||
"widgetKey": self.integration.web_chat_widget.public_key,
|
||||
"hostOrigin": "https://example.com",
|
||||
},
|
||||
HTTP_ORIGIN="https://other.example",
|
||||
)
|
||||
self.assertFalse(response.json()["available"])
|
||||
self.assertFalse(response.has_header("Access-Control-Allow-Origin"))
|
||||
|
||||
def test_loader_host_origin_and_legacy_channel_are_supported(self) -> None:
|
||||
response = self.client.get(
|
||||
"/api/v1/webchat/config/",
|
||||
{"channel": self.channel.code, "hostOrigin": "https://example.com"},
|
||||
)
|
||||
self.assertTrue(response.json()["available"])
|
||||
self.assertEqual(response.json()["widgetKey"], self.integration.web_chat_widget.public_key)
|
||||
@@ -2,10 +2,11 @@ from django.urls import path
|
||||
|
||||
from chatballs.webchat import views
|
||||
from chatballs.webchat.asset_views import WidgetAssetView
|
||||
from chatballs.webchat.config_views import WebchatConfigView
|
||||
|
||||
urlpatterns = [
|
||||
path("assets/<uuid:public_id>/", WidgetAssetView.as_view(), name="webchat-asset"),
|
||||
path("config/", views.WebchatConfigView.as_view(), name="webchat-config"),
|
||||
path("config/", WebchatConfigView.as_view(), name="webchat-config"),
|
||||
path("session/", views.WebchatSessionView.as_view(), name="webchat-session"),
|
||||
path("fields/", views.WebchatFieldsView.as_view(), name="webchat-fields"),
|
||||
path("messages/", views.WebchatMessagesView.as_view(), name="webchat-messages"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.http import FileResponse, HttpResponse
|
||||
from django.views import View
|
||||
from django.http import FileResponse
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@@ -20,37 +19,10 @@ from chatballs.webchat.access import (
|
||||
_resolved_web_widget,
|
||||
)
|
||||
from chatballs.webchat.api_inputs import host_origin
|
||||
from chatballs.webchat.loader import LOADER_JS
|
||||
from chatballs.webchat.throttling import (
|
||||
WebchatConfigThrottle,
|
||||
WebchatSessionIssueThrottle,
|
||||
)
|
||||
|
||||
LOADER_MAX_AGE_SECONDS = 300
|
||||
|
||||
|
||||
class WebchatConfigView(_Public):
|
||||
throttle_classes = [WebchatConfigThrottle]
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
widget_key = request.GET.get("widgetKey", "")
|
||||
channel_code = request.GET.get("channel", "")
|
||||
with _resolved_web_widget(widget_key, channel_code) as (context, widget):
|
||||
if context is None or widget is None:
|
||||
response = Response({"available": False})
|
||||
else:
|
||||
response = Response(
|
||||
services.public_config(
|
||||
context=context,
|
||||
widget=widget,
|
||||
origin=host_origin(request),
|
||||
)
|
||||
)
|
||||
# Оформление меняют в админке, и сайт должен увидеть его на следующей
|
||||
# загрузке страницы (SPEC-0021 R-11). Ответ к тому же зависит от Origin.
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
class WebchatSessionView(_Public):
|
||||
throttle_classes = [WebchatSessionIssueThrottle]
|
||||
@@ -246,11 +218,3 @@ class WebchatCallDeclineView(_PublicSession):
|
||||
except CallConflict as error:
|
||||
return Response({"detail": str(error)}, status=409)
|
||||
return Response({"ok": True})
|
||||
|
||||
|
||||
class WidgetLoaderView(View):
|
||||
def get(self, request) -> HttpResponse:
|
||||
response = HttpResponse(LOADER_JS, content_type="application/javascript; charset=utf-8")
|
||||
# Не дольше 5 минут: лоадер применяет оформление кнопки (SPEC-0021 R-11).
|
||||
response["Cache-Control"] = f"public, max-age={LOADER_MAX_AGE_SECONDS}"
|
||||
return response
|
||||
@@ -2,7 +2,7 @@ from django.urls import include, path
|
||||
from rest_framework.schemas import get_schema_view
|
||||
|
||||
from chatballs.identity.demo_views import DemoMediaView
|
||||
from chatballs.webchat.views import WidgetLoaderView
|
||||
from chatballs.webchat.loader_views import WidgetLoaderView
|
||||
|
||||
urlpatterns = [
|
||||
path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"),
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
Встраивание: <code><script src="/chat-widget.js" data-channel="demo" async></script></code>
|
||||
</div>
|
||||
|
||||
<script src="/chat-widget.js" data-channel="demo" async></script>
|
||||
<script>
|
||||
// Можно проверить существующее подключение, не меняя встроенный код.
|
||||
var params = new URLSearchParams(location.search);
|
||||
var widgetKey = params.get("widgetKey");
|
||||
var loader = document.createElement("script");
|
||||
loader.src = new URL("/chat-widget.js", params.get("widgetOrigin") || location.origin).href;
|
||||
loader.async = true;
|
||||
loader.setAttribute(widgetKey ? "data-widget-key" : "data-channel", widgetKey || "demo");
|
||||
document.body.appendChild(loader);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
# Проверка лоадера
|
||||
|
||||
Модульные тесты разбора оформления и геометрии анимации:
|
||||
|
||||
```sh
|
||||
node --test tests/webchat/loader.test.cjs
|
||||
```
|
||||
|
||||
Связанные серверные тесты запускаются в `backend-admin` штатного dev-контура,
|
||||
с `--reuse-db`:
|
||||
|
||||
```sh
|
||||
pytest -q chatballs/webchat/test_appearance.py::WebAppearanceApiTests \
|
||||
chatballs/webchat/tests.py::PublicWebChatWidgetTests --reuse-db
|
||||
```
|
||||
|
||||
Для браузерной проверки открыть `/chat/demo.html?widgetKey=<существующий ключ>`.
|
||||
Параметр `widgetOrigin` позволяет проверить вставку с другого origin:
|
||||
`/chat/demo.html?widgetKey=<ключ>&widgetOrigin=<адрес установки>`.
|
||||
Оба адреса страницы должны быть разрешены в настройках существующего подключения.
|
||||
|
||||
`check-launcher.js` выполняется через task-local Playwright MCP
|
||||
(`browser_run_code_unsafe`, `filename`). Сценарий читает реальную конфигурацию,
|
||||
проверяет размер, форму, сторону, цвет, тень, фокус, иконку, открытие/закрытие
|
||||
«джина», разворачивание окна и полноэкранный режим 390×844.
|
||||
Запустить для размеров 48/56/64 с обеих сторон, меняя настройки существующего
|
||||
dev-подключения и восстанавливая их после проверки. Сценарий не создаёт данные
|
||||
и не начинает диалог.
|
||||
|
||||
`check-fallback.js` проверяет стандартную кнопку и работу страницы при сетевом
|
||||
отказе запроса конфигурации. Он прерывает запрос, не подставляя ответ API.
|
||||
Отдельно проверить отказ загрузки иконки и получение конфигурации во время
|
||||
анимации: при ошибке иконки возвращается стандартный знак, поздние настройки
|
||||
применяются после завершения анимации.
|
||||
@@ -0,0 +1,29 @@
|
||||
// Проверяем сетевой отказ, не подменяя API вымышленным ответом.
|
||||
async (page) => {
|
||||
const errors = [];
|
||||
const onError = error => errors.push(error.message);
|
||||
const endpoint = "**/api/v1/webchat/config/**";
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
page.on("pageerror", onError);
|
||||
await page.route(endpoint, route => route.abort("failed"));
|
||||
try {
|
||||
const failed = page.waitForEvent("requestfailed", { predicate: request => request.url().includes("/webchat/config/") });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await failed;
|
||||
const button = page.locator(".chatballs-chat-launcher");
|
||||
const defaults = await button.evaluate(b => ({ color: getComputedStyle(b).backgroundColor,
|
||||
width: b.offsetWidth, radius: b.style.borderRadius, side: b.style.right, standardIcon: !!b.querySelector("svg") }));
|
||||
if (defaults.color !== "rgb(22, 119, 255)" || defaults.width !== 56 || defaults.radius !== "50%" ||
|
||||
defaults.side !== "24px" || !defaults.standardIcon) throw new Error("Default launcher was lost");
|
||||
await button.click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.clipPath === "none");
|
||||
await button.click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none");
|
||||
if (errors.length) throw new Error(errors.join("; "));
|
||||
if (!await page.getByRole("heading").isVisible()) throw new Error("Host page was affected");
|
||||
return { defaults, pageErrors: errors, openCloseWorks: true, passed: true };
|
||||
} finally {
|
||||
await page.unroute(endpoint);
|
||||
page.off("pageerror", onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Сценарий для task-local Playwright MCP на штатной demo.html?widgetKey=… .
|
||||
// Конфигурация берётся с реального API; ответы и учётные записи не подменяются.
|
||||
async (page) => {
|
||||
const check = (condition, message) => { if (!condition) throw new Error(message); };
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const config = await page.evaluate(async () => {
|
||||
const key = new URLSearchParams(location.search).get("widgetKey");
|
||||
const response = await fetch("/api/v1/webchat/config/?widgetKey=" + encodeURIComponent(key) +
|
||||
"&hostOrigin=" + encodeURIComponent(location.origin));
|
||||
return response.json();
|
||||
});
|
||||
check(config.available, "The real widget must be available");
|
||||
const { launcherPosition: side, launcherSize: size, launcherShape: shape, accent, launcherIcon } = config.appearance;
|
||||
const rgb = [1, 3, 5].map(offset => parseInt(accent.slice(offset, offset + 2), 16)).join(", ");
|
||||
const fill = `rgb(${rgb.replaceAll(" ", "")})`;
|
||||
await page.waitForFunction(({ side, size }) => {
|
||||
const b = document.querySelector(".chatballs-chat-launcher");
|
||||
return b && b.style[side] === "24px" && b.offsetWidth === size;
|
||||
}, { side, size });
|
||||
const button = page.locator(".chatballs-chat-launcher");
|
||||
await page.mouse.move(640, 850);
|
||||
await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher"))
|
||||
.boxShadow.includes(`rgba(${rgb}, 0.35)`), rgb);
|
||||
const read = () => button.evaluate(b => ({ box: b.getBoundingClientRect().toJSON(),
|
||||
color: getComputedStyle(b).backgroundColor, radius: getComputedStyle(b).borderRadius,
|
||||
shadow: getComputedStyle(b).boxShadow, outline: getComputedStyle(b).outlineColor,
|
||||
icon: b.querySelector("img")?.src, iconWidth: b.querySelector("img")?.naturalWidth }));
|
||||
if (launcherIcon) await page.waitForFunction(() => document.querySelector(".chatballs-chat-launcher img")?.naturalWidth > 0);
|
||||
const launcher = await read();
|
||||
check(launcher.color === `rgb(${rgb})`, "Accent was not applied");
|
||||
check(launcher.box.width === size && launcher.box.height === size, "Incorrect launcher size");
|
||||
check(launcher.radius === ({ circle: "50%", rounded: "30%", square: "10px" })[shape], "Incorrect launcher shape");
|
||||
check(side === "left" ? launcher.box.left === 24 : launcher.box.right === 1256, "Incorrect launcher side");
|
||||
await page.keyboard.press("Tab");
|
||||
await button.focus();
|
||||
const focus = await read();
|
||||
check(focus.outline === `rgba(${rgb}, 0.45)`, "Focus must use accent");
|
||||
await button.hover();
|
||||
await page.waitForFunction(rgb => getComputedStyle(document.querySelector(".chatballs-chat-launcher"))
|
||||
.boxShadow.includes(`rgba(${rgb}, 0.45)`), rgb);
|
||||
const hover = await read();
|
||||
await page.mouse.move(640, 850);
|
||||
const animate = () => page.evaluate(() => new Promise(resolve => {
|
||||
document.querySelector(".chatballs-chat-launcher").click();
|
||||
const svg = document.querySelector("body > svg");
|
||||
const path = svg.querySelector("path");
|
||||
const samples = [];
|
||||
const sample = () => {
|
||||
samples.push({ fill: path.getAttribute("fill"), path: path.getAttribute("d") });
|
||||
if (svg.style.display === "none") resolve(samples);
|
||||
else requestAnimationFrame(sample);
|
||||
};
|
||||
sample();
|
||||
}));
|
||||
const opening = await animate();
|
||||
check(opening.length > 2 && opening[0].fill === fill, "Genie must start in the launcher accent");
|
||||
check(opening.every(s => !/NaN|Infinity/.test(s.path)), "Genie geometry must stay finite");
|
||||
const panel = () => page.locator("iframe").evaluate(f => f.parentElement.getBoundingClientRect().toJSON());
|
||||
const normal = await panel();
|
||||
check(normal.bottom === 900 - size - 36, "Panel must stay 12px above launcher");
|
||||
check(side === "left" ? normal.left === 24 : normal.right === 1256, "Panel must follow launcher side");
|
||||
const frame = page.frameLocator("iframe");
|
||||
await frame.getByRole("button", { name: "Развернуть окно", exact: true }).click();
|
||||
await page.waitForFunction(() => Math.abs(document.querySelector("iframe").parentElement.getBoundingClientRect().width - 820) < .1);
|
||||
const expanded = await panel();
|
||||
check(side === "left" ? expanded.left === 24 : expanded.right === 1256, "Expanded panel must keep its side");
|
||||
const closing = await animate();
|
||||
check(closing.at(-1).fill === fill, "Genie must close into the launcher accent");
|
||||
if (launcherIcon) check((await read()).iconWidth > 0, "Custom icon must return after closing");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await animate();
|
||||
const mobile = await panel();
|
||||
check(mobile.x === 0 && mobile.y === 0 && mobile.width === 390 && mobile.height === 844, "Mobile panel must be fullscreen");
|
||||
check(await button.evaluate(b => b.style.opacity === "0" && b.style.pointerEvents === "none"), "Launcher must be hidden on mobile");
|
||||
check(await frame.getByRole("button", { name: "Развернуть окно", exact: true }).count() === 0, "Fullscreen panel cannot expand");
|
||||
await frame.getByRole("button", { name: "Свернуть", exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("iframe").parentElement.style.display === "none");
|
||||
check(await button.evaluate(b => b.style.opacity === "1" && b.style.pointerEvents !== "none"), "Launcher must return after mobile close");
|
||||
return { side, size, shape, accent, launcher, focus: focus.outline, hover: hover.shadow,
|
||||
normal, expanded, mobile, genieFrames: [opening.length, closing.length], passed: true };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const assets = join(__dirname, "../../apps/backend/chatballs/webchat/loader_assets");
|
||||
function context() {
|
||||
const ctx = vm.createContext({ URL, origin: "https://widget.example" });
|
||||
vm.runInContext(readFileSync(join(assets, "appearance.js"), "utf8"), ctx);
|
||||
vm.runInContext(readFileSync(join(assets, "genie.js"), "utf8"), ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test("launcher configuration covers both sides, all sizes and all shapes", () => {
|
||||
const ctx = context();
|
||||
for (const position of ["left", "right"]) {
|
||||
for (const size of [48, 56, 64]) {
|
||||
for (const shape of ["circle", "rounded", "square"]) {
|
||||
ctx.appearance = ctx.readAppearance({ available: true, appearance: {
|
||||
accent: "#0d8a7e", launcherPosition: position, launcherSize: size, launcherShape: shape,
|
||||
launcherIcon: "/api/v1/webchat/assets/icon/",
|
||||
} });
|
||||
assert.equal(ctx.appearance.position, position);
|
||||
assert.equal(ctx.appearance.size, size);
|
||||
assert.equal(ctx.appearance.shape, shape);
|
||||
assert.deepEqual(Array.from(ctx.appearance.rgb), [13, 138, 126]);
|
||||
assert.equal(ctx.appearance.icon, "https://widget.example/api/v1/webchat/assets/icon/");
|
||||
assert.equal(ctx.launcherRadius(), shape === "square" ? 10 : size * (shape === "rounded" ? .3 : .5));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy accent works and absent configuration leaves the default launcher", () => {
|
||||
const ctx = context();
|
||||
const legacy = ctx.readAppearance({ available: true, accent: "#BE123C" });
|
||||
assert.deepEqual(Array.from(legacy.rgb), [190, 18, 60]);
|
||||
assert.equal(legacy.position, "right");
|
||||
assert.equal(legacy.size, 56);
|
||||
assert.equal(legacy.shape, "circle");
|
||||
for (const response of [null, {}, { available: false }, { available: false, appearance: {} }]) {
|
||||
assert.equal(ctx.readAppearance(response), null);
|
||||
}
|
||||
assert.equal(ctx.appearance.accent, "#1677ff");
|
||||
});
|
||||
|
||||
test("invalid configuration is rejected before any appearance is applied", () => {
|
||||
const ctx = context();
|
||||
for (const appearance of [null, [], "text", { accent: "red" }, { launcherSize: 50 },
|
||||
{ launcherSize: true }, { launcherPosition: "top" }, { launcherShape: "oval" },
|
||||
{ launcherIcon: 42 }, { launcherIcon: "javascript:alert(1)" },
|
||||
{ launcherIcon: "//evil.example/icon.svg" }, { launcherIcon: "data:image/svg+xml,<svg/>" },
|
||||
{ launcherIcon: '/icon.svg" onerror="alert(1)' }]) {
|
||||
assert.throws(() => ctx.readAppearance({ available: true, appearance }));
|
||||
assert.equal(ctx.appearance.accent, "#1677ff");
|
||||
}
|
||||
});
|
||||
|
||||
test("genie geometry mirrors across the viewport and ends in the selected launcher shape", () => {
|
||||
const ctx = context();
|
||||
const viewport = 1280;
|
||||
const panel = { top: 100, bottom: 708, cx: 1036, half: 220, radius: 24 };
|
||||
for (const size of [48, 56, 64]) {
|
||||
for (const radius of [size / 2, size * .3, 10]) {
|
||||
const icon = { top: 720, bottom: 720 + size, cx: 1224, half: size / 2, radius };
|
||||
for (const p of [0, .25, .5, .75, 1]) {
|
||||
const right = ctx.shapeAt(p, panel, icon);
|
||||
const left = ctx.shapeAt(p, { ...panel, cx: viewport - panel.cx }, { ...icon, cx: viewport - icon.cx });
|
||||
assert.ok(Math.abs(left.topL - (viewport - right.topRt)) < 1e-9);
|
||||
assert.ok(Math.abs(left.bottomRt - (viewport - right.bottomL)) < 1e-9);
|
||||
assert.equal(left.topY, right.topY);
|
||||
assert.equal(left.bottomY, right.bottomY);
|
||||
assert.ok(!/NaN|Infinity/.test(ctx.pathOf(left, 0, 0)));
|
||||
if (p === 1) {
|
||||
assert.equal(left.topR, radius);
|
||||
assert.equal(left.bottomR, radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("all loader modules assemble into one valid standalone script", () => {
|
||||
let source = readFileSync(join(assets, "runtime.js"), "utf8");
|
||||
for (const module of ["appearance", "launcher", "genie"]) {
|
||||
source = source.replace(`/*__${module.toUpperCase()}__*/`, () => readFileSync(join(assets, `${module}.js`), "utf8"));
|
||||
}
|
||||
assert.doesNotThrow(() => new vm.Script(source));
|
||||
});
|
||||
Reference in new issue
Block a user