@lightworkai.official/debug-capture 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/dist/config.d.ts +6 -0
- package/dist/debug-capture.js +59 -18
- package/dist/index.mjs +50 -25
- package/dist/ui/chatBubble.d.ts +56 -0
- package/dist/ui/hostFont.d.ts +15 -0
- package/dist/ui/strings.d.ts +5 -0
- package/package.json +1 -1
- package/src/config.ts +6 -0
- package/src/ui/chatBubble.ts +297 -16
- package/src/ui/hostFont.ts +30 -0
- package/src/ui/ring.ts +4 -1
- package/src/ui/strings.ts +11 -0
package/dist/ui/chatBubble.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
|
|
2
2
|
export declare function supportFrameUrl(host: string, realmKey: string): string;
|
|
3
|
+
/** Where the panel's เปิดในระบบแจ้งปัญหา may land: the person's chat or their เรื่อง — anything else, their chat. Pure. */
|
|
4
|
+
export declare function portalPath(path: string): string;
|
|
5
|
+
/** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
|
|
6
|
+
export declare function supportFaceUrl(host: string): string;
|
|
3
7
|
/**
|
|
4
8
|
* How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
|
|
5
9
|
* gap above it (0.19.1: 0.19.0 assumed the bubble at the corner, and a raised one sat under a ringing call). Pure.
|
|
@@ -7,8 +11,60 @@ export declare function supportFrameUrl(host: string, realmKey: string): string;
|
|
|
7
11
|
export declare function chatLift(bubbleY: number): number;
|
|
8
12
|
/** The panel above the bubble, never past the top of the window — its height for the bubble's offset (0.19.1). Pure. */
|
|
9
13
|
export declare function panelHeight(bubbleY: number): string;
|
|
14
|
+
export interface BubbleSpot {
|
|
15
|
+
side: "right" | "left";
|
|
16
|
+
/** In from that side, px. */
|
|
17
|
+
x: number;
|
|
18
|
+
/** Up from the bottom, px — to the bubble's lower edge. */
|
|
19
|
+
y: number;
|
|
20
|
+
}
|
|
21
|
+
/** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
|
|
22
|
+
export declare function settleBubble(px: number, py: number, view: {
|
|
23
|
+
w: number;
|
|
24
|
+
h: number;
|
|
25
|
+
}, inset: number): BubbleSpot;
|
|
26
|
+
/** A height that keeps the whole bubble on screen. Pure. */
|
|
27
|
+
export declare function clampBubbleY(y: number, viewH: number): number;
|
|
28
|
+
/** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
|
|
29
|
+
export declare function liftFor(spot: BubbleSpot): number;
|
|
30
|
+
/**
|
|
31
|
+
* The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
|
|
32
|
+
* else below it — its own height, never past the window. Pure.
|
|
33
|
+
*/
|
|
34
|
+
export declare function anchorPanel(spot: BubbleSpot, viewH: number): {
|
|
35
|
+
bottom?: number;
|
|
36
|
+
top?: number;
|
|
37
|
+
height: number;
|
|
38
|
+
};
|
|
39
|
+
/** A dragged panel's top-left, inside the window by the margin. Pure. */
|
|
40
|
+
export declare function clampPanel(p: {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
}, box: {
|
|
44
|
+
w: number;
|
|
45
|
+
h: number;
|
|
46
|
+
}, view: {
|
|
47
|
+
w: number;
|
|
48
|
+
h: number;
|
|
49
|
+
}): {
|
|
50
|
+
x: number;
|
|
51
|
+
y: number;
|
|
52
|
+
};
|
|
10
53
|
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
11
54
|
export declare function badgeText(n: number): string;
|
|
55
|
+
export declare function bubbleLabel(s: {
|
|
56
|
+
own?: string | false;
|
|
57
|
+
unread: number;
|
|
58
|
+
opened: boolean;
|
|
59
|
+
open: boolean;
|
|
60
|
+
}, t: {
|
|
61
|
+
chatLabel: string;
|
|
62
|
+
chatLabelNew: string;
|
|
63
|
+
}): {
|
|
64
|
+
text: string;
|
|
65
|
+
shown: boolean;
|
|
66
|
+
isNew: boolean;
|
|
67
|
+
} | null;
|
|
12
68
|
/** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
|
|
13
69
|
export declare function openSupportChat(): void;
|
|
14
70
|
/** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host page's own font, for what this library draws on the host's page — the chat bubble's words, the call's card
|
|
3
|
+
* (0.19.2, the owner: "make sure we should correct font as well, now look like font not work"). A host loads its fonts
|
|
4
|
+
* for its own document (the ERP: Anuphan, by next/font, under a generated name); a shadow root reset by `all: initial`
|
|
5
|
+
* asked for families the host never loaded, and Thai fell to the system's looped face.
|
|
6
|
+
*
|
|
7
|
+
* Read from the host's body and set on the shadow host as `--lw-host-font` — a custom property, which `all: initial`
|
|
8
|
+
* does not reset — and the CSS falls back to its own list when there is none. A page that never chose a font (the
|
|
9
|
+
* browser's serif default) is not followed.
|
|
10
|
+
*/
|
|
11
|
+
export declare const OWN_FONTS = "\"Inter Variable\", \"Noto Sans Thai Variable\", \"IBM Plex Sans Thai\", -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif";
|
|
12
|
+
/** The host's family as its body is drawn — "" when it never chose one. Pure but for the read. */
|
|
13
|
+
export declare function hostFontFamily(computed: string | null | undefined): string;
|
|
14
|
+
/** Give a shadow host the page's font (again: a host may set its font after this library mounted). */
|
|
15
|
+
export declare function followHostFont(el: HTMLElement | null): void;
|
package/dist/ui/strings.d.ts
CHANGED
|
@@ -124,5 +124,10 @@ export interface Strings {
|
|
|
124
124
|
chatOpen: string;
|
|
125
125
|
chatTitle: string;
|
|
126
126
|
chatUnread: string;
|
|
127
|
+
/** The bubble's tooltip: what a press does, and that it moves (0.19.2). */
|
|
128
|
+
chatMove: string;
|
|
129
|
+
/** The words beside the bubble (0.19.2): what it is, until first opened; that something new waits. */
|
|
130
|
+
chatLabel: string;
|
|
131
|
+
chatLabelNew: string;
|
|
127
132
|
}
|
|
128
133
|
export declare function strings(locale: "th" | "en" | undefined): Strings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
package/src/config.ts
CHANGED
|
@@ -226,6 +226,12 @@ export interface ChatOptions {
|
|
|
226
226
|
position?: "right" | "left";
|
|
227
227
|
/** In from that corner, in px — for a host that already has something there. */
|
|
228
228
|
offset?: { x?: number; y?: number };
|
|
229
|
+
/**
|
|
230
|
+
* The words beside the bubble (0.19.2) — says what the round button is to someone who never pressed it: shown until
|
|
231
|
+
* they first open the chat on this device, again while something new waits, and on hover. Your own words in place
|
|
232
|
+
* of "แชทกับทีมงาน", or `false` for the button alone.
|
|
233
|
+
*/
|
|
234
|
+
label?: string | false;
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
/** The bubble's place, from `chat`: null when it is off. */
|
package/src/ui/chatBubble.ts
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { chatPlace, getConfig } from "../config";
|
|
17
17
|
import { hostToken, onLiveFrame } from "../live";
|
|
18
|
+
import { openSupportPortal } from "../portal";
|
|
18
19
|
import { launch } from "../reporter";
|
|
20
|
+
import { followHostFont, OWN_FONTS } from "./hostFont";
|
|
19
21
|
import { strings } from "./strings";
|
|
20
22
|
|
|
21
23
|
const HOST_TAG = "lw-support-chat";
|
|
@@ -33,20 +35,45 @@ const CSS = `
|
|
|
33
35
|
display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
|
|
34
36
|
background: #00468f; color: #fff; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 4px 10px -4px rgba(0, 0, 0, 0.2);
|
|
35
37
|
transition: transform 150ms ease, background 150ms ease;
|
|
36
|
-
font-family:
|
|
38
|
+
font-family: var(--lw-host-font, ${OWN_FONTS});
|
|
37
39
|
}
|
|
38
40
|
.bubble:hover { background: #003a78; transform: translateY(-1px); }
|
|
41
|
+
.bubble { touch-action: none; }
|
|
42
|
+
.bubble.dragging { cursor: grabbing; transform: scale(1.06); transition: none; box-shadow: 0 18px 35px -8px rgba(0, 0, 0, 0.4); }
|
|
39
43
|
.bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
|
|
44
|
+
.face { display: none; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; user-select: none; -webkit-user-drag: none; }
|
|
45
|
+
.bubble.has-face, .bubble.has-face:hover { background: #fff; }
|
|
46
|
+
.has-face .face { display: block; }
|
|
47
|
+
.has-face > svg { display: none; }
|
|
40
48
|
.badge {
|
|
41
49
|
position: absolute; top: -2px; right: -2px; min-width: 20px; height: 20px; padding: 0 5px; box-sizing: border-box;
|
|
42
|
-
border-radius: 10px; background: #dc2626; color: #fff; font:
|
|
50
|
+
border-radius: 10px; background: #dc2626; color: #fff; font-family: inherit; font-size: 11px; font-weight: 700; line-height: 20px;
|
|
43
51
|
text-align: center; box-shadow: 0 0 0 2px #fff;
|
|
44
52
|
}
|
|
45
53
|
.badge[hidden] { display: none; }
|
|
54
|
+
.label {
|
|
55
|
+
position: absolute; top: 50%; right: calc(100% + 12px); transform: translate(4px, -50%);
|
|
56
|
+
padding: 9px 15px; border-radius: 999px; background: #fff; color: #1f2937; white-space: nowrap;
|
|
57
|
+
font-family: inherit; font-size: 13px; font-weight: 600; line-height: 18px;
|
|
58
|
+
box-shadow: 0 8px 22px -6px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
|
59
|
+
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
|
|
60
|
+
}
|
|
61
|
+
.label::after {
|
|
62
|
+
content: ""; position: absolute; top: 50%; right: -4px; width: 10px; height: 10px; background: #fff; border-radius: 2px;
|
|
63
|
+
transform: translateY(-50%) rotate(45deg); box-shadow: 1px -1px 0 rgba(0, 0, 0, 0.04);
|
|
64
|
+
}
|
|
65
|
+
.label .text { display: block; max-width: min(240px, calc(100vw - ${SIZE + 64}px)); overflow: hidden; text-overflow: ellipsis; }
|
|
66
|
+
.at-left .label { right: auto; left: calc(100% + 12px); transform: translate(-4px, -50%); }
|
|
67
|
+
.at-left .label::after { right: auto; left: -4px; }
|
|
68
|
+
.new .label { color: #00468f; }
|
|
69
|
+
.labelled .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); }
|
|
70
|
+
@media (hover: hover) { .bubble:hover .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); } }
|
|
71
|
+
.unlabelled .label, .bubble.dragging .label, .open .label { display: none; }
|
|
46
72
|
.panel {
|
|
47
73
|
position: fixed; z-index: 2147483001; width: min(380px, calc(100vw - 32px)); height: min(640px, calc(100vh - ${SIZE + GAP + 32}px));
|
|
48
|
-
border-radius:
|
|
49
|
-
box-shadow: 0
|
|
74
|
+
border-radius: 20px; overflow: hidden; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08);
|
|
75
|
+
box-shadow: 0 28px 64px -16px rgba(15, 23, 42, 0.3), 0 10px 24px -10px rgba(15, 23, 42, 0.14);
|
|
76
|
+
animation: in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
50
77
|
}
|
|
51
78
|
.panel[hidden] { display: none; }
|
|
52
79
|
.panel iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
@@ -55,7 +82,7 @@ const CSS = `
|
|
|
55
82
|
.panel { inset: 0 !important; width: 100vw !important; height: 100dvh !important; border-radius: 0; border: 0; }
|
|
56
83
|
.open .bubble { display: none; }
|
|
57
84
|
}
|
|
58
|
-
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble { transition: none; } }
|
|
85
|
+
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble, .label { transition: none; } }
|
|
59
86
|
`;
|
|
60
87
|
|
|
61
88
|
let root: HTMLElement | null = null;
|
|
@@ -64,6 +91,7 @@ let panel: HTMLDivElement | null = null;
|
|
|
64
91
|
let frame: HTMLIFrameElement | null = null;
|
|
65
92
|
let bubble: HTMLButtonElement | null = null;
|
|
66
93
|
let badge: HTMLSpanElement | null = null;
|
|
94
|
+
let label: HTMLSpanElement | null = null;
|
|
67
95
|
let isOpen = false;
|
|
68
96
|
let unread = 0;
|
|
69
97
|
|
|
@@ -83,6 +111,14 @@ export function supportFrameUrl(host: string, realmKey: string): string {
|
|
|
83
111
|
url.searchParams.set("hand", "1");
|
|
84
112
|
return url.toString();
|
|
85
113
|
}
|
|
114
|
+
/** Where the panel's เปิดในระบบแจ้งปัญหา may land: the person's chat or their เรื่อง — anything else, their chat. Pure. */
|
|
115
|
+
export function portalPath(path: string): string {
|
|
116
|
+
return /^\/me\/(?:chat|tickets(?:\/[\w-]{1,64})?)$/.test(path) ? path : "/me/chat";
|
|
117
|
+
}
|
|
118
|
+
/** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
|
|
119
|
+
export function supportFaceUrl(host: string): string {
|
|
120
|
+
return new URL("support-face.webp", base(host)).toString();
|
|
121
|
+
}
|
|
86
122
|
|
|
87
123
|
/**
|
|
88
124
|
* How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
|
|
@@ -96,6 +132,50 @@ export function panelHeight(bubbleY: number): string {
|
|
|
96
132
|
return `min(640px, calc(100vh - ${bubbleY + SIZE + GAP + 16}px))`;
|
|
97
133
|
}
|
|
98
134
|
|
|
135
|
+
/*
|
|
136
|
+
* Moved by hand (0.19.2, the owner: "sometimes the chatbubble icon might over something … can move or also the chat panel
|
|
137
|
+
* that it can drag to move it … save it on local storage"): the bubble is dragged anywhere and settles on the nearer
|
|
138
|
+
* side, at the height it was let go; the panel is dragged by its head. Both kept for this desk on this device, and
|
|
139
|
+
* brought back inside the window when it is smaller than where they were left.
|
|
140
|
+
*/
|
|
141
|
+
export interface BubbleSpot {
|
|
142
|
+
side: "right" | "left";
|
|
143
|
+
/** In from that side, px. */
|
|
144
|
+
x: number;
|
|
145
|
+
/** Up from the bottom, px — to the bubble's lower edge. */
|
|
146
|
+
y: number;
|
|
147
|
+
}
|
|
148
|
+
const MARGIN = 8;
|
|
149
|
+
/** The ring's card is ~180px tall at the corner: a bubble above that leaves it there. */
|
|
150
|
+
const RING_ZONE = 200;
|
|
151
|
+
/** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
|
|
152
|
+
export function settleBubble(px: number, py: number, view: { w: number; h: number }, inset: number): BubbleSpot {
|
|
153
|
+
const side = px < view.w / 2 ? "left" : "right";
|
|
154
|
+
return { side, x: inset, y: clampBubbleY(view.h - py - SIZE / 2, view.h) };
|
|
155
|
+
}
|
|
156
|
+
/** A height that keeps the whole bubble on screen. Pure. */
|
|
157
|
+
export function clampBubbleY(y: number, viewH: number): number {
|
|
158
|
+
return Math.round(Math.min(Math.max(y, MARGIN), Math.max(MARGIN, viewH - SIZE - MARGIN)));
|
|
159
|
+
}
|
|
160
|
+
/** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
|
|
161
|
+
export function liftFor(spot: BubbleSpot): number {
|
|
162
|
+
return spot.side === "right" && spot.y < RING_ZONE ? chatLift(spot.y) : 0;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
|
|
166
|
+
* else below it — its own height, never past the window. Pure.
|
|
167
|
+
*/
|
|
168
|
+
export function anchorPanel(spot: BubbleSpot, viewH: number): { bottom?: number; top?: number; height: number } {
|
|
169
|
+
const above = viewH - (spot.y + SIZE + GAP) - 16;
|
|
170
|
+
const below = spot.y - GAP - 16;
|
|
171
|
+
if (above >= 420 || above >= below) return { bottom: spot.y + SIZE + GAP, height: Math.max(240, Math.min(640, above)) };
|
|
172
|
+
return { top: viewH - spot.y + GAP, height: Math.max(240, Math.min(640, below)) };
|
|
173
|
+
}
|
|
174
|
+
/** A dragged panel's top-left, inside the window by the margin. Pure. */
|
|
175
|
+
export function clampPanel(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
|
|
176
|
+
return { x: Math.round(Math.min(Math.max(p.x, MARGIN), Math.max(MARGIN, view.w - box.w - MARGIN))), y: Math.round(Math.min(Math.max(p.y, MARGIN), Math.max(MARGIN, view.h - box.h - MARGIN))) };
|
|
177
|
+
}
|
|
178
|
+
|
|
99
179
|
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
100
180
|
export function badgeText(n: number): string {
|
|
101
181
|
return n <= 0 ? "" : n > 99 ? "99+" : String(n);
|
|
@@ -108,6 +188,32 @@ function setBadge(n: number): void {
|
|
|
108
188
|
badge.textContent = badgeText(unread);
|
|
109
189
|
badge.hidden = unread === 0;
|
|
110
190
|
bubble.setAttribute("aria-label", unread ? `${t.chatOpen} · ${t.chatUnread.replace("{n}", String(unread))}` : t.chatOpen);
|
|
191
|
+
setLabel();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/*
|
|
195
|
+
* The words beside the bubble (0.19.2, the owner: "should we also have like a text on the left side icon"): a round
|
|
196
|
+
* button alone says nothing to someone who never pressed it, and words always there cover the page the bubble was just
|
|
197
|
+
* made movable for. So: until they first open the chat on this device, while something new waits, and on hover.
|
|
198
|
+
*/
|
|
199
|
+
export function bubbleLabel(s: { own?: string | false; unread: number; opened: boolean; open: boolean }, t: { chatLabel: string; chatLabelNew: string }): { text: string; shown: boolean; isNew: boolean } | null {
|
|
200
|
+
if (s.own === false) return null;
|
|
201
|
+
const isNew = s.unread > 0;
|
|
202
|
+
const own = typeof s.own === "string" ? s.own.trim().slice(0, 60) : "";
|
|
203
|
+
return { text: isNew ? t.chatLabelNew : own || t.chatLabel, shown: !s.open && (isNew || !s.opened), isNew };
|
|
204
|
+
}
|
|
205
|
+
/** Opened once on this device (kept with its place): from then the words come back only for something new. */
|
|
206
|
+
let opened = false;
|
|
207
|
+
function setLabel(): void {
|
|
208
|
+
if (!label || !bubble) return;
|
|
209
|
+
const config = getConfig();
|
|
210
|
+
const own = config.chat && config.chat !== true ? config.chat.label : undefined;
|
|
211
|
+
const l = bubbleLabel({ own, unread, opened, open: isOpen }, strings(config.locale));
|
|
212
|
+
bubble.classList.toggle("unlabelled", !l);
|
|
213
|
+
bubble.classList.toggle("labelled", !!l?.shown);
|
|
214
|
+
bubble.classList.toggle("new", !!l?.isNew);
|
|
215
|
+
bubble.classList.toggle("at-left", spot.side === "left");
|
|
216
|
+
label.querySelector(".text")!.textContent = l?.text ?? "";
|
|
111
217
|
}
|
|
112
218
|
|
|
113
219
|
let asking: Promise<void> | null = null;
|
|
@@ -137,27 +243,174 @@ async function refreshUnread(): Promise<void> {
|
|
|
137
243
|
function show(on: boolean): void {
|
|
138
244
|
if (!root) return;
|
|
139
245
|
root.style.display = on ? "" : "none";
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
246
|
+
// Its words in the host page's own font — read again here, after the host has drawn itself.
|
|
247
|
+
if (on) followHostFont(root);
|
|
248
|
+
layout();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* ── Where it is: the config's place, or where the person put it ───────── */
|
|
252
|
+
|
|
253
|
+
let spot: BubbleSpot = { side: "right", x: 16, y: 16 };
|
|
254
|
+
/** The panel's top-left once dragged by its head — null: by the bubble. */
|
|
255
|
+
let panelAt: { x: number; y: number; h: number } | null = null;
|
|
256
|
+
const PANEL_W = 380;
|
|
257
|
+
const placeKey = () => `lw-support-chat-place:${getConfig().realmKey}`;
|
|
258
|
+
const view = () => ({ w: window.innerWidth, h: window.innerHeight });
|
|
259
|
+
|
|
260
|
+
/** Put there by hand — kept; else the config's place, which follows the host's own offset if it changes. */
|
|
261
|
+
let movedByHand = false;
|
|
262
|
+
function loadPlace(): void {
|
|
263
|
+
const def = chatPlace(getConfig().chat);
|
|
264
|
+
if (def) spot = { side: def.side, x: def.x, y: def.y };
|
|
265
|
+
try {
|
|
266
|
+
const kept = JSON.parse(localStorage.getItem(placeKey()) ?? "null") as { bubble?: { side?: unknown; y?: unknown }; panel?: { x?: unknown; y?: unknown; h?: unknown } | null; opened?: unknown } | null;
|
|
267
|
+
const b = kept?.bubble;
|
|
268
|
+
movedByHand = !!b && (b.side === "left" || b.side === "right") && typeof b.y === "number" && Number.isFinite(b.y);
|
|
269
|
+
if (movedByHand) spot = { ...spot, side: b!.side as BubbleSpot["side"], y: b!.y as number };
|
|
270
|
+
const p = kept?.panel;
|
|
271
|
+
panelAt = p && typeof p.x === "number" && typeof p.y === "number" && Number.isFinite(p.x) && Number.isFinite(p.y) ? { x: p.x, y: p.y, h: typeof p.h === "number" && p.h >= 240 ? p.h : 640 } : null;
|
|
272
|
+
opened = kept?.opened === true;
|
|
273
|
+
} catch {
|
|
274
|
+
/* a page that cannot keep it: the config's place */
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function savePlace(): void {
|
|
278
|
+
try {
|
|
279
|
+
localStorage.setItem(placeKey(), JSON.stringify({ ...(movedByHand ? { bubble: { side: spot.side, y: spot.y } } : {}), panel: panelAt, ...(opened ? { opened: true } : {}) }));
|
|
280
|
+
} catch {
|
|
281
|
+
/* this visit only */
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Everything placed from `spot` and `panelAt`, inside the window as it is now — and the call's card clear of the bubble. */
|
|
286
|
+
function layout(): void {
|
|
287
|
+
if (!bubble || !panel) return;
|
|
288
|
+
const v = view();
|
|
289
|
+
const y = clampBubbleY(spot.y, v.h);
|
|
290
|
+
const other = spot.side === "right" ? "left" : "right";
|
|
291
|
+
Object.assign(bubble.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: `${y}px`, top: "" });
|
|
292
|
+
if (panelAt) {
|
|
293
|
+
// The height it had when it was picked up — a drag moves it, never resizes it under the hand.
|
|
294
|
+
const box = { w: Math.min(PANEL_W, v.w - 32), h: Math.min(panelAt.h, v.h - 2 * MARGIN) };
|
|
295
|
+
const p = clampPanel(panelAt, box, v);
|
|
296
|
+
Object.assign(panel.style, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "", height: `${box.h}px` });
|
|
297
|
+
} else {
|
|
298
|
+
const a = anchorPanel({ ...spot, y }, v.h);
|
|
299
|
+
Object.assign(panel.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: a.bottom !== undefined ? `${a.bottom}px` : "", top: a.top !== undefined ? `${a.top}px` : "", height: `${a.height}px` });
|
|
300
|
+
}
|
|
301
|
+
// The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
|
|
302
|
+
const lift = root && root.style.display !== "none" ? liftFor({ ...spot, y }) : 0;
|
|
303
|
+
if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
|
|
143
304
|
else document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
305
|
+
setLabel();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ── The bubble, dragged: a press that moves is a move, never a press ─── */
|
|
309
|
+
|
|
310
|
+
let pressed: { id: number; sx: number; sy: number; moved: boolean } | null = null;
|
|
311
|
+
let swallowClick = false;
|
|
312
|
+
function onBubbleDown(e: PointerEvent): void {
|
|
313
|
+
if (e.button !== 0) return;
|
|
314
|
+
pressed = { id: e.pointerId, sx: e.clientX, sy: e.clientY, moved: false };
|
|
315
|
+
try {
|
|
316
|
+
bubble?.setPointerCapture(e.pointerId);
|
|
317
|
+
} catch {
|
|
318
|
+
/* a synthetic press */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function onBubbleMove(e: PointerEvent): void {
|
|
322
|
+
if (!pressed || e.pointerId !== pressed.id || !bubble) return;
|
|
323
|
+
if (!pressed.moved && Math.hypot(e.clientX - pressed.sx, e.clientY - pressed.sy) < 6) return;
|
|
324
|
+
pressed.moved = true;
|
|
325
|
+
bubble.classList.add("dragging");
|
|
326
|
+
Object.assign(bubble.style, { left: `${e.clientX - SIZE / 2}px`, top: `${e.clientY - SIZE / 2}px`, right: "", bottom: "" });
|
|
327
|
+
}
|
|
328
|
+
function onBubbleUp(e: PointerEvent): void {
|
|
329
|
+
if (!pressed || e.pointerId !== pressed.id) return;
|
|
330
|
+
const was = pressed;
|
|
331
|
+
pressed = null;
|
|
332
|
+
bubble?.classList.remove("dragging");
|
|
333
|
+
if (!was.moved) return;
|
|
334
|
+
// The click that follows this release is the end of a move, not a press.
|
|
335
|
+
swallowClick = true;
|
|
336
|
+
setTimeout(() => (swallowClick = false), 0);
|
|
337
|
+
if (e.type === "pointerup") {
|
|
338
|
+
spot = settleBubble(e.clientX, e.clientY, view(), chatPlace(getConfig().chat)?.x ?? 16);
|
|
339
|
+
movedByHand = true;
|
|
340
|
+
// Moved by hand: the panel opens by it again.
|
|
341
|
+
panelAt = null;
|
|
342
|
+
savePlace();
|
|
343
|
+
}
|
|
344
|
+
layout();
|
|
144
345
|
}
|
|
145
346
|
|
|
347
|
+
/* ── The panel, dragged by its head — the press is in the frame (as the call's, ui/callFrame) ─── */
|
|
348
|
+
|
|
349
|
+
let pdrag: { sx: number; sy: number; x: number; y: number; lx: number; ly: number; h: number } | null = null;
|
|
350
|
+
function onPanelDrag(m: { phase?: unknown; x?: unknown; y?: unknown }): void {
|
|
351
|
+
// A phone's panel is the whole screen: nothing to move.
|
|
352
|
+
if (!panel || typeof m.x !== "number" || typeof m.y !== "number" || view().w < 640) return;
|
|
353
|
+
if (m.phase === "start") {
|
|
354
|
+
const r = panel.getBoundingClientRect();
|
|
355
|
+
pdrag = { sx: m.x, sy: m.y, x: r.left, y: r.top, lx: m.x, ly: m.y, h: Math.round(r.height) || 640 };
|
|
356
|
+
followHere(true);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (!pdrag) return;
|
|
360
|
+
pdrag.lx = m.x;
|
|
361
|
+
pdrag.ly = m.y;
|
|
362
|
+
panelAt = { ...clampPanel({ x: pdrag.x + (m.x - pdrag.sx), y: pdrag.y + (m.y - pdrag.sy) }, { w: panel.offsetWidth, h: pdrag.h }, view()), h: pdrag.h };
|
|
363
|
+
layout();
|
|
364
|
+
if (m.phase === "end") {
|
|
365
|
+
pdrag = null;
|
|
366
|
+
followHere(false);
|
|
367
|
+
savePlace();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/*
|
|
371
|
+
* This page's own pointer while the panel is dragged: a quick flick leaves the frame on its first move, and from then
|
|
372
|
+
* only this page hears it (the call's 0.17.1 lesson). Screen coordinates, as the frame sends them.
|
|
373
|
+
*/
|
|
374
|
+
let hostFollow: ((e: PointerEvent) => void) | null = null;
|
|
375
|
+
function followHere(on: boolean): void {
|
|
376
|
+
if (hostFollow) {
|
|
377
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.removeEventListener(t, hostFollow, true);
|
|
378
|
+
hostFollow = null;
|
|
379
|
+
}
|
|
380
|
+
if (!on) return;
|
|
381
|
+
hostFollow = (e: PointerEvent) => {
|
|
382
|
+
if (!pdrag) return followHere(false);
|
|
383
|
+
if (e.type === "pointermove" && e.buttons !== 0) return onPanelDrag({ phase: "move", x: e.screenX, y: e.screenY });
|
|
384
|
+
const released = e.type === "pointerup";
|
|
385
|
+
onPanelDrag({ phase: "end", x: released ? e.screenX : pdrag.lx, y: released ? e.screenY : pdrag.ly });
|
|
386
|
+
};
|
|
387
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
|
|
388
|
+
}
|
|
389
|
+
const onResize = () => layout();
|
|
390
|
+
|
|
146
391
|
function post(message: Record<string, unknown>): void {
|
|
147
392
|
frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
|
|
148
393
|
}
|
|
149
394
|
|
|
150
395
|
async function onFrame(e: MessageEvent): Promise<void> {
|
|
151
396
|
if (!frame || e.source !== frame.contentWindow) return;
|
|
152
|
-
const d = e.data as { source?: unknown; type?: unknown; total?: unknown } | null;
|
|
397
|
+
const d = e.data as { source?: unknown; type?: unknown; total?: unknown; phase?: unknown; x?: unknown; y?: unknown; path?: unknown } | null;
|
|
153
398
|
if (!d || d.source !== FRAME) return;
|
|
154
399
|
if (d.type === "hello") {
|
|
155
400
|
const token = await hostToken();
|
|
156
|
-
|
|
401
|
+
// What this widget does for the panel — it shows only buttons that work here (0.19.3: the portal hand-off).
|
|
402
|
+
if (token) post({ type: "token", token, can: ["portal"] });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (d.type === "portal") {
|
|
406
|
+
// เปิดในระบบแจ้งปัญหา: the whole portal at that page, by the usual hand-off — its countdown over the open panel,
|
|
407
|
+
// a fresh sign-in, a new tab. Only the reporter's own pages; the portal checks again on arrival.
|
|
408
|
+
void openSupportPortal({ returnTo: portalPath(typeof d.path === "string" ? d.path : "") });
|
|
157
409
|
return;
|
|
158
410
|
}
|
|
159
411
|
if (d.type === "ready") return post({ type: isOpen ? "shown" : "hidden" });
|
|
160
412
|
if (d.type === "close") return closeSupportChat();
|
|
413
|
+
if (d.type === "drag") return onPanelDrag(d);
|
|
161
414
|
if (d.type === "unread" && typeof d.total === "number") return setBadge(d.total);
|
|
162
415
|
if (d.type === "report") {
|
|
163
416
|
// The panel folds, and the report opens over the page it is about — linked to the chat (ingest `fromChat`).
|
|
@@ -185,9 +438,15 @@ export function openSupportChat(): void {
|
|
|
185
438
|
panel.append(frame);
|
|
186
439
|
}
|
|
187
440
|
isOpen = true;
|
|
441
|
+
if (!opened) {
|
|
442
|
+
// They know what it is now: the words beside it come back only for something new.
|
|
443
|
+
opened = true;
|
|
444
|
+
savePlace();
|
|
445
|
+
}
|
|
188
446
|
panel.hidden = false;
|
|
189
447
|
shadow?.querySelector(".wrap")?.classList.add("open");
|
|
190
448
|
bubble?.setAttribute("aria-expanded", "true");
|
|
449
|
+
setLabel();
|
|
191
450
|
post({ type: "shown" });
|
|
192
451
|
frame.focus();
|
|
193
452
|
}
|
|
@@ -199,6 +458,7 @@ export function closeSupportChat(refocus = true): void {
|
|
|
199
458
|
panel.hidden = true;
|
|
200
459
|
shadow?.querySelector(".wrap")?.classList.remove("open");
|
|
201
460
|
bubble?.setAttribute("aria-expanded", "false");
|
|
461
|
+
setLabel();
|
|
202
462
|
post({ type: "hidden" });
|
|
203
463
|
if (refocus) bubble?.focus();
|
|
204
464
|
void refreshUnread();
|
|
@@ -223,18 +483,33 @@ export function mountSupportChat(): void {
|
|
|
223
483
|
root = document.createElement(HOST_TAG);
|
|
224
484
|
root.style.display = "none";
|
|
225
485
|
shadow = root.attachShadow({ mode: "open" });
|
|
226
|
-
|
|
227
|
-
shadow.innerHTML = `<style>${CSS}</style><div class="wrap"><div class="panel" role="dialog" aria-label="${t.chatTitle}" hidden></div><button type="button" class="bubble" aria-expanded="false" aria-haspopup="dialog" aria-label="${t.chatOpen}" title="${t.chatOpen}">${ICON}<span class="badge" hidden></span></button></div>`;
|
|
486
|
+
shadow.innerHTML = `<style>${CSS}</style><div class="wrap"><div class="panel" role="dialog" aria-label="${t.chatTitle}" hidden></div><button type="button" class="bubble" aria-expanded="false" aria-haspopup="dialog" aria-label="${t.chatOpen}" title="${t.chatMove}">${ICON}<img class="face" alt="" draggable="false" decoding="async"><span class="badge" hidden></span><span class="label" aria-hidden="true"><span class="text"></span></span></button></div>`;
|
|
228
487
|
panel = shadow.querySelector(".panel");
|
|
229
488
|
bubble = shadow.querySelector(".bubble");
|
|
230
489
|
badge = shadow.querySelector(".badge");
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
490
|
+
label = shadow.querySelector(".label");
|
|
491
|
+
// The desk's face (the owner, 10-31: "all the support icon that user see … use this icon"): the support host's own,
|
|
492
|
+
// the one its replies wear — the chat glyph until it is there, and if it never comes.
|
|
493
|
+
const face = shadow.querySelector<HTMLImageElement>(".face")!;
|
|
494
|
+
face.addEventListener("load", () => bubble?.classList.add("has-face"), { once: true });
|
|
495
|
+
face.src = supportFaceUrl(config.host);
|
|
496
|
+
// Where the person left it on this device, else the config's place (0.19.2).
|
|
497
|
+
loadPlace();
|
|
498
|
+
layout();
|
|
499
|
+
bubble!.addEventListener("click", () => {
|
|
500
|
+
if (swallowClick) return;
|
|
501
|
+
if (isOpen) closeSupportChat();
|
|
502
|
+
else openSupportChat();
|
|
503
|
+
});
|
|
504
|
+
bubble!.addEventListener("pointerdown", onBubbleDown);
|
|
505
|
+
bubble!.addEventListener("pointermove", onBubbleMove);
|
|
506
|
+
bubble!.addEventListener("pointerup", onBubbleUp);
|
|
507
|
+
bubble!.addEventListener("pointercancel", onBubbleUp);
|
|
234
508
|
document.body.append(root);
|
|
235
509
|
window.addEventListener("message", onMessage);
|
|
236
510
|
document.addEventListener("keydown", onKey);
|
|
237
511
|
window.addEventListener("focus", onFocus);
|
|
512
|
+
window.addEventListener("resize", onResize);
|
|
238
513
|
// The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
|
|
239
514
|
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
240
515
|
stopFrames = onLiveFrame(() => {
|
|
@@ -252,8 +527,14 @@ export function unmountSupportChat(): void {
|
|
|
252
527
|
window.removeEventListener("message", onMessage);
|
|
253
528
|
document.removeEventListener("keydown", onKey);
|
|
254
529
|
window.removeEventListener("focus", onFocus);
|
|
530
|
+
window.removeEventListener("resize", onResize);
|
|
531
|
+
followHere(false);
|
|
255
532
|
root?.remove();
|
|
256
|
-
root = shadow = panel = frame = bubble = badge = null;
|
|
533
|
+
root = shadow = panel = frame = bubble = badge = label = null;
|
|
257
534
|
isOpen = false;
|
|
535
|
+
pressed = pdrag = null;
|
|
536
|
+
panelAt = null;
|
|
537
|
+
movedByHand = opened = false;
|
|
538
|
+
unread = 0;
|
|
258
539
|
document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
259
540
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host page's own font, for what this library draws on the host's page — the chat bubble's words, the call's card
|
|
3
|
+
* (0.19.2, the owner: "make sure we should correct font as well, now look like font not work"). A host loads its fonts
|
|
4
|
+
* for its own document (the ERP: Anuphan, by next/font, under a generated name); a shadow root reset by `all: initial`
|
|
5
|
+
* asked for families the host never loaded, and Thai fell to the system's looped face.
|
|
6
|
+
*
|
|
7
|
+
* Read from the host's body and set on the shadow host as `--lw-host-font` — a custom property, which `all: initial`
|
|
8
|
+
* does not reset — and the CSS falls back to its own list when there is none. A page that never chose a font (the
|
|
9
|
+
* browser's serif default) is not followed.
|
|
10
|
+
*/
|
|
11
|
+
export const OWN_FONTS = `"Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif`;
|
|
12
|
+
|
|
13
|
+
/** The host's family as its body is drawn — "" when it never chose one. Pure but for the read. */
|
|
14
|
+
export function hostFontFamily(computed: string | null | undefined): string {
|
|
15
|
+
const f = (computed ?? "").trim();
|
|
16
|
+
return !f || /^(?:"?Times New Roman"?|"?Times"?|serif)$/i.test(f) ? "" : f;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Give a shadow host the page's font (again: a host may set its font after this library mounted). */
|
|
20
|
+
export function followHostFont(el: HTMLElement | null): void {
|
|
21
|
+
if (!el || typeof document === "undefined" || !document.body) return;
|
|
22
|
+
let f = "";
|
|
23
|
+
try {
|
|
24
|
+
f = hostFontFamily(getComputedStyle(document.body).fontFamily);
|
|
25
|
+
} catch {
|
|
26
|
+
/* no styles to read: the library's own fonts */
|
|
27
|
+
}
|
|
28
|
+
if (f) el.style.setProperty("--lw-host-font", f);
|
|
29
|
+
else el.style.removeProperty("--lw-host-font");
|
|
30
|
+
}
|
package/src/ui/ring.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* it is up does not photograph it.
|
|
13
13
|
*/
|
|
14
14
|
import { getConfig } from "../config";
|
|
15
|
+
import { followHostFont, OWN_FONTS } from "./hostFont";
|
|
15
16
|
import { containKeys } from "./keys";
|
|
16
17
|
import { strings } from "./strings";
|
|
17
18
|
|
|
@@ -30,7 +31,7 @@ const CSS = `
|
|
|
30
31
|
width: min(352px, calc(100vw - 32px)); box-sizing: border-box;
|
|
31
32
|
padding: 16px; border-radius: 16px; background: #fff; color: #1a1a1e;
|
|
32
33
|
border: 1px solid #e8e8ec; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
33
|
-
font-family:
|
|
34
|
+
font-family: var(--lw-host-font, ${OWN_FONTS});
|
|
34
35
|
animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
35
36
|
}
|
|
36
37
|
@media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: calc(8px + var(--lw-chat-lift, 0px)); width: auto; } }
|
|
@@ -117,6 +118,8 @@ function mount(): ShadowRoot | null {
|
|
|
117
118
|
if (host?.isConnected) return host.shadowRoot;
|
|
118
119
|
host = document.createElement(HOST_TAG);
|
|
119
120
|
host.dataset.debugReporter = "true";
|
|
121
|
+
// The card's words in the host page's own font (ui/hostFont).
|
|
122
|
+
followHostFont(host);
|
|
120
123
|
const root = host.attachShadow({ mode: "open" });
|
|
121
124
|
containKeys(root);
|
|
122
125
|
document.body.append(host);
|
package/src/ui/strings.ts
CHANGED
|
@@ -129,6 +129,11 @@ export interface Strings {
|
|
|
129
129
|
chatOpen: string;
|
|
130
130
|
chatTitle: string;
|
|
131
131
|
chatUnread: string;
|
|
132
|
+
/** The bubble's tooltip: what a press does, and that it moves (0.19.2). */
|
|
133
|
+
chatMove: string;
|
|
134
|
+
/** The words beside the bubble (0.19.2): what it is, until first opened; that something new waits. */
|
|
135
|
+
chatLabel: string;
|
|
136
|
+
chatLabelNew: string;
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
const th: Strings = {
|
|
@@ -221,6 +226,9 @@ const th: Strings = {
|
|
|
221
226
|
chatOpen: "แชทกับทีมงาน",
|
|
222
227
|
chatTitle: "แชทกับทีมงาน",
|
|
223
228
|
chatUnread: "{n} ข้อความใหม่จากทีมงาน",
|
|
229
|
+
chatMove: "แชทกับทีมงาน — ลากเพื่อย้ายไปที่อื่นได้ถ้าบังอะไรอยู่",
|
|
230
|
+
chatLabel: "แชทกับทีมงาน",
|
|
231
|
+
chatLabelNew: "มีข้อความใหม่จากทีมงาน",
|
|
224
232
|
};
|
|
225
233
|
|
|
226
234
|
const en: Strings = {
|
|
@@ -313,6 +321,9 @@ const en: Strings = {
|
|
|
313
321
|
chatOpen: "Chat with support",
|
|
314
322
|
chatTitle: "Chat with support",
|
|
315
323
|
chatUnread: "{n} new from support",
|
|
324
|
+
chatMove: "Chat with support — drag to move it if it covers something",
|
|
325
|
+
chatLabel: "Chat with us",
|
|
326
|
+
chatLabelNew: "New message from support",
|
|
316
327
|
};
|
|
317
328
|
|
|
318
329
|
export function strings(locale: "th" | "en" | undefined): Strings {
|