@lightworkai.official/debug-capture 0.19.0 → 0.19.2
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 +44 -19
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +51 -26
- package/dist/ui/chatBubble.d.ts +61 -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/index.ts +1 -1
- 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,7 +1,68 @@
|
|
|
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
|
+
/** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
|
|
4
|
+
export declare function supportFaceUrl(host: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
|
|
7
|
+
* 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.
|
|
8
|
+
*/
|
|
9
|
+
export declare function chatLift(bubbleY: number): number;
|
|
10
|
+
/** The panel above the bubble, never past the top of the window — its height for the bubble's offset (0.19.1). Pure. */
|
|
11
|
+
export declare function panelHeight(bubbleY: number): string;
|
|
12
|
+
export interface BubbleSpot {
|
|
13
|
+
side: "right" | "left";
|
|
14
|
+
/** In from that side, px. */
|
|
15
|
+
x: number;
|
|
16
|
+
/** Up from the bottom, px — to the bubble's lower edge. */
|
|
17
|
+
y: number;
|
|
18
|
+
}
|
|
19
|
+
/** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
|
|
20
|
+
export declare function settleBubble(px: number, py: number, view: {
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
}, inset: number): BubbleSpot;
|
|
24
|
+
/** A height that keeps the whole bubble on screen. Pure. */
|
|
25
|
+
export declare function clampBubbleY(y: number, viewH: number): number;
|
|
26
|
+
/** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
|
|
27
|
+
export declare function liftFor(spot: BubbleSpot): number;
|
|
28
|
+
/**
|
|
29
|
+
* The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
|
|
30
|
+
* else below it — its own height, never past the window. Pure.
|
|
31
|
+
*/
|
|
32
|
+
export declare function anchorPanel(spot: BubbleSpot, viewH: number): {
|
|
33
|
+
bottom?: number;
|
|
34
|
+
top?: number;
|
|
35
|
+
height: number;
|
|
36
|
+
};
|
|
37
|
+
/** A dragged panel's top-left, inside the window by the margin. Pure. */
|
|
38
|
+
export declare function clampPanel(p: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}, box: {
|
|
42
|
+
w: number;
|
|
43
|
+
h: number;
|
|
44
|
+
}, view: {
|
|
45
|
+
w: number;
|
|
46
|
+
h: number;
|
|
47
|
+
}): {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
};
|
|
3
51
|
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
4
52
|
export declare function badgeText(n: number): string;
|
|
53
|
+
export declare function bubbleLabel(s: {
|
|
54
|
+
own?: string | false;
|
|
55
|
+
unread: number;
|
|
56
|
+
opened: boolean;
|
|
57
|
+
open: boolean;
|
|
58
|
+
}, t: {
|
|
59
|
+
chatLabel: string;
|
|
60
|
+
chatLabelNew: string;
|
|
61
|
+
}): {
|
|
62
|
+
text: string;
|
|
63
|
+
shown: boolean;
|
|
64
|
+
isNew: boolean;
|
|
65
|
+
} | null;
|
|
5
66
|
/** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
|
|
6
67
|
export declare function openSupportChat(): void;
|
|
7
68
|
/** 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.2",
|
|
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/index.ts
CHANGED
|
@@ -77,7 +77,7 @@ export type { OpenPortalOptions } from "./portal";
|
|
|
77
77
|
* แชทกับทีม (0.19): `chat` in the config mounts the bubble on install; a host with its own button opens and closes
|
|
78
78
|
* the same panel. The panel is the support host's page, framed — as the tickets are, maintained where they live.
|
|
79
79
|
*/
|
|
80
|
-
export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText } from "./ui/chatBubble";
|
|
80
|
+
export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText, chatLift, panelHeight } from "./ui/chatBubble";
|
|
81
81
|
export type { ChatOptions } from "./config";
|
|
82
82
|
|
|
83
83
|
export type * from "./types";
|
package/src/ui/chatBubble.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { chatPlace, getConfig } from "../config";
|
|
17
17
|
import { hostToken, onLiveFrame } from "../live";
|
|
18
18
|
import { launch } from "../reporter";
|
|
19
|
+
import { followHostFont, OWN_FONTS } from "./hostFont";
|
|
19
20
|
import { strings } from "./strings";
|
|
20
21
|
|
|
21
22
|
const HOST_TAG = "lw-support-chat";
|
|
@@ -33,29 +34,54 @@ const CSS = `
|
|
|
33
34
|
display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
|
|
34
35
|
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
36
|
transition: transform 150ms ease, background 150ms ease;
|
|
36
|
-
font-family:
|
|
37
|
+
font-family: var(--lw-host-font, ${OWN_FONTS});
|
|
37
38
|
}
|
|
38
39
|
.bubble:hover { background: #003a78; transform: translateY(-1px); }
|
|
40
|
+
.bubble { touch-action: none; }
|
|
41
|
+
.bubble.dragging { cursor: grabbing; transform: scale(1.06); transition: none; box-shadow: 0 18px 35px -8px rgba(0, 0, 0, 0.4); }
|
|
39
42
|
.bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
|
|
43
|
+
.face { display: none; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; user-select: none; -webkit-user-drag: none; }
|
|
44
|
+
.bubble.has-face, .bubble.has-face:hover { background: #fff; }
|
|
45
|
+
.has-face .face { display: block; }
|
|
46
|
+
.has-face > svg { display: none; }
|
|
40
47
|
.badge {
|
|
41
48
|
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:
|
|
49
|
+
border-radius: 10px; background: #dc2626; color: #fff; font-family: inherit; font-size: 11px; font-weight: 700; line-height: 20px;
|
|
43
50
|
text-align: center; box-shadow: 0 0 0 2px #fff;
|
|
44
51
|
}
|
|
45
52
|
.badge[hidden] { display: none; }
|
|
53
|
+
.label {
|
|
54
|
+
position: absolute; top: 50%; right: calc(100% + 12px); transform: translate(4px, -50%);
|
|
55
|
+
padding: 9px 15px; border-radius: 999px; background: #fff; color: #1f2937; white-space: nowrap;
|
|
56
|
+
font-family: inherit; font-size: 13px; font-weight: 600; line-height: 18px;
|
|
57
|
+
box-shadow: 0 8px 22px -6px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
|
58
|
+
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 150ms ease, transform 150ms ease, visibility 150ms;
|
|
59
|
+
}
|
|
60
|
+
.label::after {
|
|
61
|
+
content: ""; position: absolute; top: 50%; right: -4px; width: 10px; height: 10px; background: #fff; border-radius: 2px;
|
|
62
|
+
transform: translateY(-50%) rotate(45deg); box-shadow: 1px -1px 0 rgba(0, 0, 0, 0.04);
|
|
63
|
+
}
|
|
64
|
+
.label .text { display: block; max-width: min(240px, calc(100vw - ${SIZE + 64}px)); overflow: hidden; text-overflow: ellipsis; }
|
|
65
|
+
.at-left .label { right: auto; left: calc(100% + 12px); transform: translate(-4px, -50%); }
|
|
66
|
+
.at-left .label::after { right: auto; left: -4px; }
|
|
67
|
+
.new .label { color: #00468f; }
|
|
68
|
+
.labelled .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); }
|
|
69
|
+
@media (hover: hover) { .bubble:hover .label { opacity: 1; visibility: visible; pointer-events: auto; transform: translate(0, -50%); } }
|
|
70
|
+
.unlabelled .label, .bubble.dragging .label, .open .label { display: none; }
|
|
46
71
|
.panel {
|
|
47
72
|
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
|
|
73
|
+
border-radius: 20px; overflow: hidden; background: #fff; border: 1px solid rgba(15, 23, 42, 0.08);
|
|
74
|
+
box-shadow: 0 28px 64px -16px rgba(15, 23, 42, 0.3), 0 10px 24px -10px rgba(15, 23, 42, 0.14);
|
|
75
|
+
animation: in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
50
76
|
}
|
|
51
77
|
.panel[hidden] { display: none; }
|
|
52
78
|
.panel iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
53
79
|
@keyframes in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
|
|
54
80
|
@media (max-width: 639px) {
|
|
55
|
-
.panel { inset: 0 !important; width: 100vw; height: 100dvh; border-radius: 0; border: 0; }
|
|
81
|
+
.panel { inset: 0 !important; width: 100vw !important; height: 100dvh !important; border-radius: 0; border: 0; }
|
|
56
82
|
.open .bubble { display: none; }
|
|
57
83
|
}
|
|
58
|
-
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble { transition: none; } }
|
|
84
|
+
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble, .label { transition: none; } }
|
|
59
85
|
`;
|
|
60
86
|
|
|
61
87
|
let root: HTMLElement | null = null;
|
|
@@ -64,6 +90,7 @@ let panel: HTMLDivElement | null = null;
|
|
|
64
90
|
let frame: HTMLIFrameElement | null = null;
|
|
65
91
|
let bubble: HTMLButtonElement | null = null;
|
|
66
92
|
let badge: HTMLSpanElement | null = null;
|
|
93
|
+
let label: HTMLSpanElement | null = null;
|
|
67
94
|
let isOpen = false;
|
|
68
95
|
let unread = 0;
|
|
69
96
|
|
|
@@ -83,6 +110,66 @@ export function supportFrameUrl(host: string, realmKey: string): string {
|
|
|
83
110
|
url.searchParams.set("hand", "1");
|
|
84
111
|
return url.toString();
|
|
85
112
|
}
|
|
113
|
+
/** The desk's face on the button — the support host's public file, the same picture as its replies (0.19.2). */
|
|
114
|
+
export function supportFaceUrl(host: string): string {
|
|
115
|
+
return new URL("support-face.webp", base(host)).toString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How far the call's card and frame rise from their own 16px so they clear the bubble — its offset, its size and the
|
|
120
|
+
* 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.
|
|
121
|
+
*/
|
|
122
|
+
export function chatLift(bubbleY: number): number {
|
|
123
|
+
return bubbleY + SIZE + GAP - 16;
|
|
124
|
+
}
|
|
125
|
+
/** The panel above the bubble, never past the top of the window — its height for the bubble's offset (0.19.1). Pure. */
|
|
126
|
+
export function panelHeight(bubbleY: number): string {
|
|
127
|
+
return `min(640px, calc(100vh - ${bubbleY + SIZE + GAP + 16}px))`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/*
|
|
131
|
+
* Moved by hand (0.19.2, the owner: "sometimes the chatbubble icon might over something … can move or also the chat panel
|
|
132
|
+
* that it can drag to move it … save it on local storage"): the bubble is dragged anywhere and settles on the nearer
|
|
133
|
+
* side, at the height it was let go; the panel is dragged by its head. Both kept for this desk on this device, and
|
|
134
|
+
* brought back inside the window when it is smaller than where they were left.
|
|
135
|
+
*/
|
|
136
|
+
export interface BubbleSpot {
|
|
137
|
+
side: "right" | "left";
|
|
138
|
+
/** In from that side, px. */
|
|
139
|
+
x: number;
|
|
140
|
+
/** Up from the bottom, px — to the bubble's lower edge. */
|
|
141
|
+
y: number;
|
|
142
|
+
}
|
|
143
|
+
const MARGIN = 8;
|
|
144
|
+
/** The ring's card is ~180px tall at the corner: a bubble above that leaves it there. */
|
|
145
|
+
const RING_ZONE = 200;
|
|
146
|
+
/** Let go at (px, py), its centre: the nearer side, the height it was dropped at — inside the window. Pure. */
|
|
147
|
+
export function settleBubble(px: number, py: number, view: { w: number; h: number }, inset: number): BubbleSpot {
|
|
148
|
+
const side = px < view.w / 2 ? "left" : "right";
|
|
149
|
+
return { side, x: inset, y: clampBubbleY(view.h - py - SIZE / 2, view.h) };
|
|
150
|
+
}
|
|
151
|
+
/** A height that keeps the whole bubble on screen. Pure. */
|
|
152
|
+
export function clampBubbleY(y: number, viewH: number): number {
|
|
153
|
+
return Math.round(Math.min(Math.max(y, MARGIN), Math.max(MARGIN, viewH - SIZE - MARGIN)));
|
|
154
|
+
}
|
|
155
|
+
/** How far the call's card rises to clear the bubble: only a bubble on its side, low enough to meet it. Pure. */
|
|
156
|
+
export function liftFor(spot: BubbleSpot): number {
|
|
157
|
+
return spot.side === "right" && spot.y < RING_ZONE ? chatLift(spot.y) : 0;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The panel by the bubble, where it was not dragged: above it when the room above is enough (or more than below),
|
|
161
|
+
* else below it — its own height, never past the window. Pure.
|
|
162
|
+
*/
|
|
163
|
+
export function anchorPanel(spot: BubbleSpot, viewH: number): { bottom?: number; top?: number; height: number } {
|
|
164
|
+
const above = viewH - (spot.y + SIZE + GAP) - 16;
|
|
165
|
+
const below = spot.y - GAP - 16;
|
|
166
|
+
if (above >= 420 || above >= below) return { bottom: spot.y + SIZE + GAP, height: Math.max(240, Math.min(640, above)) };
|
|
167
|
+
return { top: viewH - spot.y + GAP, height: Math.max(240, Math.min(640, below)) };
|
|
168
|
+
}
|
|
169
|
+
/** A dragged panel's top-left, inside the window by the margin. Pure. */
|
|
170
|
+
export function clampPanel(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
|
|
171
|
+
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))) };
|
|
172
|
+
}
|
|
86
173
|
|
|
87
174
|
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
88
175
|
export function badgeText(n: number): string {
|
|
@@ -96,6 +183,32 @@ function setBadge(n: number): void {
|
|
|
96
183
|
badge.textContent = badgeText(unread);
|
|
97
184
|
badge.hidden = unread === 0;
|
|
98
185
|
bubble.setAttribute("aria-label", unread ? `${t.chatOpen} · ${t.chatUnread.replace("{n}", String(unread))}` : t.chatOpen);
|
|
186
|
+
setLabel();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* The words beside the bubble (0.19.2, the owner: "should we also have like a text on the left side icon"): a round
|
|
191
|
+
* button alone says nothing to someone who never pressed it, and words always there cover the page the bubble was just
|
|
192
|
+
* made movable for. So: until they first open the chat on this device, while something new waits, and on hover.
|
|
193
|
+
*/
|
|
194
|
+
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 {
|
|
195
|
+
if (s.own === false) return null;
|
|
196
|
+
const isNew = s.unread > 0;
|
|
197
|
+
const own = typeof s.own === "string" ? s.own.trim().slice(0, 60) : "";
|
|
198
|
+
return { text: isNew ? t.chatLabelNew : own || t.chatLabel, shown: !s.open && (isNew || !s.opened), isNew };
|
|
199
|
+
}
|
|
200
|
+
/** Opened once on this device (kept with its place): from then the words come back only for something new. */
|
|
201
|
+
let opened = false;
|
|
202
|
+
function setLabel(): void {
|
|
203
|
+
if (!label || !bubble) return;
|
|
204
|
+
const config = getConfig();
|
|
205
|
+
const own = config.chat && config.chat !== true ? config.chat.label : undefined;
|
|
206
|
+
const l = bubbleLabel({ own, unread, opened, open: isOpen }, strings(config.locale));
|
|
207
|
+
bubble.classList.toggle("unlabelled", !l);
|
|
208
|
+
bubble.classList.toggle("labelled", !!l?.shown);
|
|
209
|
+
bubble.classList.toggle("new", !!l?.isNew);
|
|
210
|
+
bubble.classList.toggle("at-left", spot.side === "left");
|
|
211
|
+
label.querySelector(".text")!.textContent = l?.text ?? "";
|
|
99
212
|
}
|
|
100
213
|
|
|
101
214
|
let asking: Promise<void> | null = null;
|
|
@@ -125,11 +238,150 @@ async function refreshUnread(): Promise<void> {
|
|
|
125
238
|
function show(on: boolean): void {
|
|
126
239
|
if (!root) return;
|
|
127
240
|
root.style.display = on ? "" : "none";
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
241
|
+
// Its words in the host page's own font — read again here, after the host has drawn itself.
|
|
242
|
+
if (on) followHostFont(root);
|
|
243
|
+
layout();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ── Where it is: the config's place, or where the person put it ───────── */
|
|
247
|
+
|
|
248
|
+
let spot: BubbleSpot = { side: "right", x: 16, y: 16 };
|
|
249
|
+
/** The panel's top-left once dragged by its head — null: by the bubble. */
|
|
250
|
+
let panelAt: { x: number; y: number; h: number } | null = null;
|
|
251
|
+
const PANEL_W = 380;
|
|
252
|
+
const placeKey = () => `lw-support-chat-place:${getConfig().realmKey}`;
|
|
253
|
+
const view = () => ({ w: window.innerWidth, h: window.innerHeight });
|
|
254
|
+
|
|
255
|
+
/** Put there by hand — kept; else the config's place, which follows the host's own offset if it changes. */
|
|
256
|
+
let movedByHand = false;
|
|
257
|
+
function loadPlace(): void {
|
|
258
|
+
const def = chatPlace(getConfig().chat);
|
|
259
|
+
if (def) spot = { side: def.side, x: def.x, y: def.y };
|
|
260
|
+
try {
|
|
261
|
+
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;
|
|
262
|
+
const b = kept?.bubble;
|
|
263
|
+
movedByHand = !!b && (b.side === "left" || b.side === "right") && typeof b.y === "number" && Number.isFinite(b.y);
|
|
264
|
+
if (movedByHand) spot = { ...spot, side: b!.side as BubbleSpot["side"], y: b!.y as number };
|
|
265
|
+
const p = kept?.panel;
|
|
266
|
+
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;
|
|
267
|
+
opened = kept?.opened === true;
|
|
268
|
+
} catch {
|
|
269
|
+
/* a page that cannot keep it: the config's place */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function savePlace(): void {
|
|
273
|
+
try {
|
|
274
|
+
localStorage.setItem(placeKey(), JSON.stringify({ ...(movedByHand ? { bubble: { side: spot.side, y: spot.y } } : {}), panel: panelAt, ...(opened ? { opened: true } : {}) }));
|
|
275
|
+
} catch {
|
|
276
|
+
/* this visit only */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Everything placed from `spot` and `panelAt`, inside the window as it is now — and the call's card clear of the bubble. */
|
|
281
|
+
function layout(): void {
|
|
282
|
+
if (!bubble || !panel) return;
|
|
283
|
+
const v = view();
|
|
284
|
+
const y = clampBubbleY(spot.y, v.h);
|
|
285
|
+
const other = spot.side === "right" ? "left" : "right";
|
|
286
|
+
Object.assign(bubble.style, { [spot.side]: `${spot.x}px`, [other]: "", bottom: `${y}px`, top: "" });
|
|
287
|
+
if (panelAt) {
|
|
288
|
+
// The height it had when it was picked up — a drag moves it, never resizes it under the hand.
|
|
289
|
+
const box = { w: Math.min(PANEL_W, v.w - 32), h: Math.min(panelAt.h, v.h - 2 * MARGIN) };
|
|
290
|
+
const p = clampPanel(panelAt, box, v);
|
|
291
|
+
Object.assign(panel.style, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "", height: `${box.h}px` });
|
|
292
|
+
} else {
|
|
293
|
+
const a = anchorPanel({ ...spot, y }, v.h);
|
|
294
|
+
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` });
|
|
295
|
+
}
|
|
296
|
+
// The call's card and frame sit above the bubble while it is there and low on their side (ring.ts, callFrame.ts).
|
|
297
|
+
const lift = root && root.style.display !== "none" ? liftFor({ ...spot, y }) : 0;
|
|
298
|
+
if (lift) document.documentElement.style.setProperty("--lw-chat-lift", `${lift}px`);
|
|
131
299
|
else document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
300
|
+
setLabel();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ── The bubble, dragged: a press that moves is a move, never a press ─── */
|
|
304
|
+
|
|
305
|
+
let pressed: { id: number; sx: number; sy: number; moved: boolean } | null = null;
|
|
306
|
+
let swallowClick = false;
|
|
307
|
+
function onBubbleDown(e: PointerEvent): void {
|
|
308
|
+
if (e.button !== 0) return;
|
|
309
|
+
pressed = { id: e.pointerId, sx: e.clientX, sy: e.clientY, moved: false };
|
|
310
|
+
try {
|
|
311
|
+
bubble?.setPointerCapture(e.pointerId);
|
|
312
|
+
} catch {
|
|
313
|
+
/* a synthetic press */
|
|
314
|
+
}
|
|
132
315
|
}
|
|
316
|
+
function onBubbleMove(e: PointerEvent): void {
|
|
317
|
+
if (!pressed || e.pointerId !== pressed.id || !bubble) return;
|
|
318
|
+
if (!pressed.moved && Math.hypot(e.clientX - pressed.sx, e.clientY - pressed.sy) < 6) return;
|
|
319
|
+
pressed.moved = true;
|
|
320
|
+
bubble.classList.add("dragging");
|
|
321
|
+
Object.assign(bubble.style, { left: `${e.clientX - SIZE / 2}px`, top: `${e.clientY - SIZE / 2}px`, right: "", bottom: "" });
|
|
322
|
+
}
|
|
323
|
+
function onBubbleUp(e: PointerEvent): void {
|
|
324
|
+
if (!pressed || e.pointerId !== pressed.id) return;
|
|
325
|
+
const was = pressed;
|
|
326
|
+
pressed = null;
|
|
327
|
+
bubble?.classList.remove("dragging");
|
|
328
|
+
if (!was.moved) return;
|
|
329
|
+
// The click that follows this release is the end of a move, not a press.
|
|
330
|
+
swallowClick = true;
|
|
331
|
+
setTimeout(() => (swallowClick = false), 0);
|
|
332
|
+
if (e.type === "pointerup") {
|
|
333
|
+
spot = settleBubble(e.clientX, e.clientY, view(), chatPlace(getConfig().chat)?.x ?? 16);
|
|
334
|
+
movedByHand = true;
|
|
335
|
+
// Moved by hand: the panel opens by it again.
|
|
336
|
+
panelAt = null;
|
|
337
|
+
savePlace();
|
|
338
|
+
}
|
|
339
|
+
layout();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* ── The panel, dragged by its head — the press is in the frame (as the call's, ui/callFrame) ─── */
|
|
343
|
+
|
|
344
|
+
let pdrag: { sx: number; sy: number; x: number; y: number; lx: number; ly: number; h: number } | null = null;
|
|
345
|
+
function onPanelDrag(m: { phase?: unknown; x?: unknown; y?: unknown }): void {
|
|
346
|
+
// A phone's panel is the whole screen: nothing to move.
|
|
347
|
+
if (!panel || typeof m.x !== "number" || typeof m.y !== "number" || view().w < 640) return;
|
|
348
|
+
if (m.phase === "start") {
|
|
349
|
+
const r = panel.getBoundingClientRect();
|
|
350
|
+
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 };
|
|
351
|
+
followHere(true);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!pdrag) return;
|
|
355
|
+
pdrag.lx = m.x;
|
|
356
|
+
pdrag.ly = m.y;
|
|
357
|
+
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 };
|
|
358
|
+
layout();
|
|
359
|
+
if (m.phase === "end") {
|
|
360
|
+
pdrag = null;
|
|
361
|
+
followHere(false);
|
|
362
|
+
savePlace();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/*
|
|
366
|
+
* This page's own pointer while the panel is dragged: a quick flick leaves the frame on its first move, and from then
|
|
367
|
+
* only this page hears it (the call's 0.17.1 lesson). Screen coordinates, as the frame sends them.
|
|
368
|
+
*/
|
|
369
|
+
let hostFollow: ((e: PointerEvent) => void) | null = null;
|
|
370
|
+
function followHere(on: boolean): void {
|
|
371
|
+
if (hostFollow) {
|
|
372
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.removeEventListener(t, hostFollow, true);
|
|
373
|
+
hostFollow = null;
|
|
374
|
+
}
|
|
375
|
+
if (!on) return;
|
|
376
|
+
hostFollow = (e: PointerEvent) => {
|
|
377
|
+
if (!pdrag) return followHere(false);
|
|
378
|
+
if (e.type === "pointermove" && e.buttons !== 0) return onPanelDrag({ phase: "move", x: e.screenX, y: e.screenY });
|
|
379
|
+
const released = e.type === "pointerup";
|
|
380
|
+
onPanelDrag({ phase: "end", x: released ? e.screenX : pdrag.lx, y: released ? e.screenY : pdrag.ly });
|
|
381
|
+
};
|
|
382
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
|
|
383
|
+
}
|
|
384
|
+
const onResize = () => layout();
|
|
133
385
|
|
|
134
386
|
function post(message: Record<string, unknown>): void {
|
|
135
387
|
frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
|
|
@@ -137,7 +389,7 @@ function post(message: Record<string, unknown>): void {
|
|
|
137
389
|
|
|
138
390
|
async function onFrame(e: MessageEvent): Promise<void> {
|
|
139
391
|
if (!frame || e.source !== frame.contentWindow) return;
|
|
140
|
-
const d = e.data as { source?: unknown; type?: unknown; total?: unknown } | null;
|
|
392
|
+
const d = e.data as { source?: unknown; type?: unknown; total?: unknown; phase?: unknown; x?: unknown; y?: unknown } | null;
|
|
141
393
|
if (!d || d.source !== FRAME) return;
|
|
142
394
|
if (d.type === "hello") {
|
|
143
395
|
const token = await hostToken();
|
|
@@ -146,6 +398,7 @@ async function onFrame(e: MessageEvent): Promise<void> {
|
|
|
146
398
|
}
|
|
147
399
|
if (d.type === "ready") return post({ type: isOpen ? "shown" : "hidden" });
|
|
148
400
|
if (d.type === "close") return closeSupportChat();
|
|
401
|
+
if (d.type === "drag") return onPanelDrag(d);
|
|
149
402
|
if (d.type === "unread" && typeof d.total === "number") return setBadge(d.total);
|
|
150
403
|
if (d.type === "report") {
|
|
151
404
|
// The panel folds, and the report opens over the page it is about — linked to the chat (ingest `fromChat`).
|
|
@@ -173,9 +426,15 @@ export function openSupportChat(): void {
|
|
|
173
426
|
panel.append(frame);
|
|
174
427
|
}
|
|
175
428
|
isOpen = true;
|
|
429
|
+
if (!opened) {
|
|
430
|
+
// They know what it is now: the words beside it come back only for something new.
|
|
431
|
+
opened = true;
|
|
432
|
+
savePlace();
|
|
433
|
+
}
|
|
176
434
|
panel.hidden = false;
|
|
177
435
|
shadow?.querySelector(".wrap")?.classList.add("open");
|
|
178
436
|
bubble?.setAttribute("aria-expanded", "true");
|
|
437
|
+
setLabel();
|
|
179
438
|
post({ type: "shown" });
|
|
180
439
|
frame.focus();
|
|
181
440
|
}
|
|
@@ -187,6 +446,7 @@ export function closeSupportChat(refocus = true): void {
|
|
|
187
446
|
panel.hidden = true;
|
|
188
447
|
shadow?.querySelector(".wrap")?.classList.remove("open");
|
|
189
448
|
bubble?.setAttribute("aria-expanded", "false");
|
|
449
|
+
setLabel();
|
|
190
450
|
post({ type: "hidden" });
|
|
191
451
|
if (refocus) bubble?.focus();
|
|
192
452
|
void refreshUnread();
|
|
@@ -211,18 +471,33 @@ export function mountSupportChat(): void {
|
|
|
211
471
|
root = document.createElement(HOST_TAG);
|
|
212
472
|
root.style.display = "none";
|
|
213
473
|
shadow = root.attachShadow({ mode: "open" });
|
|
214
|
-
|
|
215
|
-
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>`;
|
|
474
|
+
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>`;
|
|
216
475
|
panel = shadow.querySelector(".panel");
|
|
217
476
|
bubble = shadow.querySelector(".bubble");
|
|
218
477
|
badge = shadow.querySelector(".badge");
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
478
|
+
label = shadow.querySelector(".label");
|
|
479
|
+
// The desk's face (the owner, 10-31: "all the support icon that user see … use this icon"): the support host's own,
|
|
480
|
+
// the one its replies wear — the chat glyph until it is there, and if it never comes.
|
|
481
|
+
const face = shadow.querySelector<HTMLImageElement>(".face")!;
|
|
482
|
+
face.addEventListener("load", () => bubble?.classList.add("has-face"), { once: true });
|
|
483
|
+
face.src = supportFaceUrl(config.host);
|
|
484
|
+
// Where the person left it on this device, else the config's place (0.19.2).
|
|
485
|
+
loadPlace();
|
|
486
|
+
layout();
|
|
487
|
+
bubble!.addEventListener("click", () => {
|
|
488
|
+
if (swallowClick) return;
|
|
489
|
+
if (isOpen) closeSupportChat();
|
|
490
|
+
else openSupportChat();
|
|
491
|
+
});
|
|
492
|
+
bubble!.addEventListener("pointerdown", onBubbleDown);
|
|
493
|
+
bubble!.addEventListener("pointermove", onBubbleMove);
|
|
494
|
+
bubble!.addEventListener("pointerup", onBubbleUp);
|
|
495
|
+
bubble!.addEventListener("pointercancel", onBubbleUp);
|
|
222
496
|
document.body.append(root);
|
|
223
497
|
window.addEventListener("message", onMessage);
|
|
224
498
|
document.addEventListener("keydown", onKey);
|
|
225
499
|
window.addEventListener("focus", onFocus);
|
|
500
|
+
window.addEventListener("resize", onResize);
|
|
226
501
|
// The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
|
|
227
502
|
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
228
503
|
stopFrames = onLiveFrame(() => {
|
|
@@ -240,8 +515,14 @@ export function unmountSupportChat(): void {
|
|
|
240
515
|
window.removeEventListener("message", onMessage);
|
|
241
516
|
document.removeEventListener("keydown", onKey);
|
|
242
517
|
window.removeEventListener("focus", onFocus);
|
|
518
|
+
window.removeEventListener("resize", onResize);
|
|
519
|
+
followHere(false);
|
|
243
520
|
root?.remove();
|
|
244
|
-
root = shadow = panel = frame = bubble = badge = null;
|
|
521
|
+
root = shadow = panel = frame = bubble = badge = label = null;
|
|
245
522
|
isOpen = false;
|
|
523
|
+
pressed = pdrag = null;
|
|
524
|
+
panelAt = null;
|
|
525
|
+
movedByHand = opened = false;
|
|
526
|
+
unread = 0;
|
|
246
527
|
document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
247
528
|
}
|
|
@@ -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 {
|