@hidemikimura/chit-ui 0.2.0 → 0.3.0
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/CHANGELOG.md +33 -0
- package/README.md +108 -2
- package/dist/chit-ui.iife.min.js +193 -98
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +184 -89
- package/dist/chit-ui.min.js.map +1 -1
- package/dist/types/chit-ui.d.ts +64 -4
- package/dist/types/controllers/composer-controller.d.ts +4 -0
- package/dist/types/controllers/drag-controller.d.ts +118 -0
- package/dist/types/events.d.ts +1 -0
- package/dist/types/global.d.ts +7 -1
- package/dist/types/i18n/labels.d.ts +8 -0
- package/dist/types/render/message-list.d.ts +0 -1
- package/dist/types/theme/default-theme.d.ts +8 -0
- package/dist/types/types.d.ts +40 -0
- package/package.json +1 -1
- package/src/chit-ui.js +255 -4
- package/src/controllers/composer-controller.js +2 -0
- package/src/controllers/drag-controller.js +319 -0
- package/src/events.js +1 -0
- package/src/global.d.ts +7 -1
- package/src/i18n/labels.js +6 -0
- package/src/render/launcher.js +9 -1
- package/src/render/message-list.js +40 -10
- package/src/render/panel.js +17 -1
- package/src/styles/launcher.css.js +13 -0
- package/src/styles/message.css.js +60 -15
- package/src/styles/panel.css.js +23 -2
- package/src/theme/default-theme.js +16 -0
- package/src/types.js +18 -0
package/dist/types/chit-ui.d.ts
CHANGED
|
@@ -34,6 +34,12 @@ export class ChitUI extends LitElement {
|
|
|
34
34
|
};
|
|
35
35
|
typing: {
|
|
36
36
|
type: ObjectConstructor;
|
|
37
|
+
noAccessor: boolean;
|
|
38
|
+
};
|
|
39
|
+
loading: {
|
|
40
|
+
type: BooleanConstructor;
|
|
41
|
+
reflect: boolean;
|
|
42
|
+
noAccessor: boolean;
|
|
37
43
|
};
|
|
38
44
|
busy: {
|
|
39
45
|
type: BooleanConstructor;
|
|
@@ -83,10 +89,6 @@ export class ChitUI extends LitElement {
|
|
|
83
89
|
theme: Theme;
|
|
84
90
|
/** @type {Message[]} Rendered as given. The library never mutates this. */
|
|
85
91
|
messages: Message[];
|
|
86
|
-
/** @type {boolean | { html: string }} Show the "typing" bubble. */
|
|
87
|
-
typing: boolean | {
|
|
88
|
-
html: string;
|
|
89
|
-
};
|
|
90
92
|
/** @type {boolean} Lock the composer while a reply is in flight. */
|
|
91
93
|
busy: boolean;
|
|
92
94
|
/** @type {boolean} */
|
|
@@ -119,6 +121,63 @@ export class ChitUI extends LitElement {
|
|
|
119
121
|
sanitize: ((html: string) => string) | undefined;
|
|
120
122
|
/** @type {((time: Date) => string) | undefined} */
|
|
121
123
|
formatTime: ((time: Date) => string) | undefined;
|
|
124
|
+
/** The launcher's drag, for the render function to hook up. */
|
|
125
|
+
get launcherDrag(): DragController;
|
|
126
|
+
/** The panel's drag, likewise. */
|
|
127
|
+
get panelDrag(): DragController;
|
|
128
|
+
set dragOffset(value: {
|
|
129
|
+
x: number;
|
|
130
|
+
y: number;
|
|
131
|
+
} | null);
|
|
132
|
+
/**
|
|
133
|
+
* How far the reader has dragged the widget from where the theme put it, in
|
|
134
|
+
* screen pixels, or null when it is still there.
|
|
135
|
+
*
|
|
136
|
+
* There is one of these for the whole widget rather than one per state: the
|
|
137
|
+
* launcher and the panel are the same widget wearing two shapes, so moving
|
|
138
|
+
* either moves both, and the panel opens where the launcher was left.
|
|
139
|
+
*
|
|
140
|
+
* @type {{ x: number, y: number } | null}
|
|
141
|
+
*/
|
|
142
|
+
get dragOffset(): {
|
|
143
|
+
x: number;
|
|
144
|
+
y: number;
|
|
145
|
+
} | null;
|
|
146
|
+
/** Forget the dragged position and go back to what the theme says. */
|
|
147
|
+
resetPosition(): void;
|
|
148
|
+
/**
|
|
149
|
+
* The composer calls this once a submit has gone out and no listener
|
|
150
|
+
* cancelled it. Listening for `chat-submit` here instead would be wrong:
|
|
151
|
+
* this element's own listener runs before the consumer's, so it cannot see
|
|
152
|
+
* a `preventDefault()` that is still to come.
|
|
153
|
+
*
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
handleSubmitted(): void;
|
|
157
|
+
set loading(value: boolean);
|
|
158
|
+
/**
|
|
159
|
+
* Show the "waiting for an answer" indicator. Turning this on turns
|
|
160
|
+
* `typing` off, for the reason given above.
|
|
161
|
+
*
|
|
162
|
+
* @type {boolean}
|
|
163
|
+
*/
|
|
164
|
+
get loading(): boolean;
|
|
165
|
+
set typing(value: boolean | {
|
|
166
|
+
html: string;
|
|
167
|
+
});
|
|
168
|
+
/**
|
|
169
|
+
* Show the "typing" bubble: `true`, or `{ html }` for your own markup.
|
|
170
|
+
*
|
|
171
|
+
* Turning this on turns `loading` off. The two describe the same pause in
|
|
172
|
+
* the conversation — one says a person is writing, the other that a server
|
|
173
|
+
* has not answered yet — and showing both would leave the reader to work
|
|
174
|
+
* out the difference.
|
|
175
|
+
*
|
|
176
|
+
* @type {boolean | { html: string }}
|
|
177
|
+
*/
|
|
178
|
+
get typing(): boolean | {
|
|
179
|
+
html: string;
|
|
180
|
+
};
|
|
122
181
|
set state(value: ChatState);
|
|
123
182
|
/**
|
|
124
183
|
* Current state. Assigning it starts the transition, exactly as calling the
|
|
@@ -270,6 +329,7 @@ import { LitElement } from 'lit';
|
|
|
270
329
|
import type { Theme } from './types.js';
|
|
271
330
|
import type { Message } from './types.js';
|
|
272
331
|
import type { Labels } from './i18n/labels.js';
|
|
332
|
+
import { DragController } from './controllers/drag-controller.js';
|
|
273
333
|
import type { ChatState } from './types.js';
|
|
274
334
|
import type { Device } from './types.js';
|
|
275
335
|
import type { ResolvedTheme } from './types.js';
|
|
@@ -49,6 +49,10 @@ export type ComposerHost = {
|
|
|
49
49
|
sendOnEnter: boolean;
|
|
50
50
|
maxLength: number | undefined;
|
|
51
51
|
currentLabels: import("../i18n/labels.js").Labels;
|
|
52
|
+
/**
|
|
53
|
+
* Called after a submit that no listener cancelled.
|
|
54
|
+
*/
|
|
55
|
+
handleSubmitted: () => void;
|
|
52
56
|
};
|
|
53
57
|
import type { ReactiveController } from 'lit';
|
|
54
58
|
import type { LitElement } from 'lit';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moves one thing — the launcher or the panel — around the viewport.
|
|
3
|
+
*
|
|
4
|
+
* What a gesture produces is not a position but a displacement: how far the
|
|
5
|
+
* reader has dragged the widget from wherever the theme put it, in screen
|
|
6
|
+
* pixels. The host keeps one such displacement for the whole widget and hands
|
|
7
|
+
* it to both controllers, which is what makes the two states travel together:
|
|
8
|
+
* drag the launcher into a corner and the panel opens in that same corner,
|
|
9
|
+
* because both are the theme's position plus the same shift.
|
|
10
|
+
*
|
|
11
|
+
* Each controller then turns the displacement into the terms its own corner
|
|
12
|
+
* uses — at a right-hand corner, moving right means a smaller offset — and
|
|
13
|
+
* writes it into the two custom properties the stylesheet already reads. The
|
|
14
|
+
* offset lives on the host as an inline property, which outranks the theme's
|
|
15
|
+
* sheet and the page's CSS: a reader who dragged the widget somewhere means
|
|
16
|
+
* it, and until the drag is cleared theirs is the most specific word on where
|
|
17
|
+
* it goes.
|
|
18
|
+
*
|
|
19
|
+
* @implements {ReactiveController}
|
|
20
|
+
*/
|
|
21
|
+
export class DragController implements ReactiveController {
|
|
22
|
+
/**
|
|
23
|
+
* @param {LitElement} host
|
|
24
|
+
* @param {DragOptions} options
|
|
25
|
+
*/
|
|
26
|
+
constructor(host: LitElement, options: DragOptions);
|
|
27
|
+
hostConnected(): void;
|
|
28
|
+
hostDisconnected(): void;
|
|
29
|
+
/**
|
|
30
|
+
* True when the gesture that just ended was a drag. Reading it clears it, so
|
|
31
|
+
* the click a pointer-up fires can be skipped exactly once.
|
|
32
|
+
*
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
consumeDrag(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Put this thing where the displacement says, within the viewport.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ x: number, y: number } | null} displacement
|
|
40
|
+
* @returns {{ x: number, y: number } | undefined} Where it ended up, when it is on screen.
|
|
41
|
+
*/
|
|
42
|
+
place(displacement: {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
} | null): {
|
|
46
|
+
x: number;
|
|
47
|
+
y: number;
|
|
48
|
+
} | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Begin a drag.
|
|
51
|
+
*
|
|
52
|
+
* @param {PointerEvent} event
|
|
53
|
+
* @returns {void}
|
|
54
|
+
*/
|
|
55
|
+
start(event: PointerEvent): void;
|
|
56
|
+
/**
|
|
57
|
+
* Move with the arrow keys, so the same thing can be done without a pointer.
|
|
58
|
+
*
|
|
59
|
+
* @param {KeyboardEvent} event
|
|
60
|
+
* @returns {boolean} True when the key was used.
|
|
61
|
+
*/
|
|
62
|
+
nudge(event: KeyboardEvent): boolean;
|
|
63
|
+
#private;
|
|
64
|
+
}
|
|
65
|
+
export type DragOptions = {
|
|
66
|
+
name: "launcher" | "panel";
|
|
67
|
+
enabled: () => boolean;
|
|
68
|
+
/**
|
|
69
|
+
* What moves.
|
|
70
|
+
*/
|
|
71
|
+
element: () => HTMLElement | null;
|
|
72
|
+
/**
|
|
73
|
+
* How big it means to be.
|
|
74
|
+
*/
|
|
75
|
+
size: () => {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Which corner the offset counts from.
|
|
81
|
+
*/
|
|
82
|
+
corner: () => Position;
|
|
83
|
+
/**
|
|
84
|
+
* The theme's offset, before any drag.
|
|
85
|
+
*/
|
|
86
|
+
base: () => {
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* The widget's displacement now.
|
|
92
|
+
*/
|
|
93
|
+
current: () => {
|
|
94
|
+
x: number;
|
|
95
|
+
y: number;
|
|
96
|
+
} | null;
|
|
97
|
+
/**
|
|
98
|
+
* A new displacement.
|
|
99
|
+
*/
|
|
100
|
+
onMove: (displacement: {
|
|
101
|
+
x: number;
|
|
102
|
+
y: number;
|
|
103
|
+
}) => void;
|
|
104
|
+
/**
|
|
105
|
+
* The gesture ended here.
|
|
106
|
+
*/
|
|
107
|
+
onSettle: (name: "launcher" | "panel") => void;
|
|
108
|
+
/**
|
|
109
|
+
* The custom properties to write.
|
|
110
|
+
*/
|
|
111
|
+
vars: {
|
|
112
|
+
x: string;
|
|
113
|
+
y: string;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
import type { ReactiveController } from 'lit';
|
|
117
|
+
import type { LitElement } from 'lit';
|
|
118
|
+
import type { Position } from '../types.js';
|
package/dist/types/events.d.ts
CHANGED
package/dist/types/global.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ChitUI } from './chit-ui.js';
|
|
2
|
-
import type { Message, ChatState, Trigger, Device } from './types.js';
|
|
2
|
+
import type { Message, ChatState, Trigger, Device, Position } from './types.js';
|
|
3
3
|
|
|
4
4
|
export interface ChitUIEventMap {
|
|
5
5
|
'chat-submit': CustomEvent<{ text: string }>;
|
|
@@ -17,6 +17,12 @@ export interface ChitUIEventMap {
|
|
|
17
17
|
'chat-breakpoint-change': CustomEvent<{ device: Device }>;
|
|
18
18
|
'chat-home': CustomEvent<{ trigger: Trigger }>;
|
|
19
19
|
'chat-attach': CustomEvent<{ files: File[] }>;
|
|
20
|
+
'chat-move': CustomEvent<{
|
|
21
|
+
target: 'launcher' | 'panel';
|
|
22
|
+
position: Position;
|
|
23
|
+
offset: { x: number; y: number };
|
|
24
|
+
displacement: { x: number; y: number };
|
|
25
|
+
}>;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
declare global {
|
|
@@ -53,6 +53,14 @@ export type Labels = {
|
|
|
53
53
|
* Announced while the other side is typing.
|
|
54
54
|
*/
|
|
55
55
|
typing: string;
|
|
56
|
+
/**
|
|
57
|
+
* Announced while waiting for an answer.
|
|
58
|
+
*/
|
|
59
|
+
loading: string;
|
|
60
|
+
/**
|
|
61
|
+
* The panel's drag handle.
|
|
62
|
+
*/
|
|
63
|
+
move: string;
|
|
56
64
|
/**
|
|
57
65
|
* "Jump to newest" button.
|
|
58
66
|
*/
|
|
@@ -19,6 +19,7 @@ export namespace defaultTheme {
|
|
|
19
19
|
}
|
|
20
20
|
let radius: number;
|
|
21
21
|
let launcher: "hidden";
|
|
22
|
+
let draggable: boolean;
|
|
22
23
|
namespace header {
|
|
23
24
|
let visible: boolean;
|
|
24
25
|
let title: null;
|
|
@@ -64,6 +65,13 @@ export namespace defaultTheme {
|
|
|
64
65
|
export { radius_1 as radius };
|
|
65
66
|
export let tail: "none";
|
|
66
67
|
}
|
|
68
|
+
namespace loading {
|
|
69
|
+
export let auto: boolean;
|
|
70
|
+
export let style: "spinner";
|
|
71
|
+
let text_1: null;
|
|
72
|
+
export { text_1 as text };
|
|
73
|
+
export let timeout: number;
|
|
74
|
+
}
|
|
67
75
|
namespace speaker {
|
|
68
76
|
namespace assistant {
|
|
69
77
|
let name: null;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -102,6 +102,10 @@ export type ClosedTheme = {
|
|
|
102
102
|
*/
|
|
103
103
|
image?: string | null | undefined;
|
|
104
104
|
label?: string | null | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Let the reader move it around the viewport.
|
|
107
|
+
*/
|
|
108
|
+
draggable?: boolean | undefined;
|
|
105
109
|
/**
|
|
106
110
|
* A component that draws the whole launcher.
|
|
107
111
|
*/
|
|
@@ -119,6 +123,31 @@ export type ClosedTheme = {
|
|
|
119
123
|
idle?: "none" | "pulse" | "bounce";
|
|
120
124
|
}) | undefined;
|
|
121
125
|
};
|
|
126
|
+
/**
|
|
127
|
+
* The wait between sending and the answer arriving.
|
|
128
|
+
*/
|
|
129
|
+
export type LoadingStyle = "dots" | "spinner" | "text";
|
|
130
|
+
/**
|
|
131
|
+
* The wait between sending and the answer arriving.
|
|
132
|
+
*/
|
|
133
|
+
export type LoadingTheme = {
|
|
134
|
+
/**
|
|
135
|
+
* Show it from `chat-submit` until the next message from the other side.
|
|
136
|
+
*/
|
|
137
|
+
auto?: boolean | undefined;
|
|
138
|
+
/**
|
|
139
|
+
* What it looks like.
|
|
140
|
+
*/
|
|
141
|
+
style?: LoadingStyle | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* Wording beside the animation, or on its own for `'text'`.
|
|
144
|
+
*/
|
|
145
|
+
text?: string | null | undefined;
|
|
146
|
+
/**
|
|
147
|
+
* ms after which it gives up on its own; 0 leaves it to the consumer.
|
|
148
|
+
*/
|
|
149
|
+
timeout?: number | undefined;
|
|
150
|
+
};
|
|
122
151
|
/**
|
|
123
152
|
* Who is talking, as the theme describes them. A message that carries its own
|
|
124
153
|
* `name` or `avatar` wins; `null` on the message hides what the theme set.
|
|
@@ -166,6 +195,10 @@ export type OpenTheme = {
|
|
|
166
195
|
* Keep the launcher visible while open.
|
|
167
196
|
*/
|
|
168
197
|
launcher?: "hidden" | "visible" | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* Let the reader move the panel by its title bar.
|
|
200
|
+
*/
|
|
201
|
+
draggable?: boolean | undefined;
|
|
169
202
|
/**
|
|
170
203
|
* The title bar: whether it is there at all, its text, its image, and whether it offers a way back to the start.
|
|
171
204
|
*/
|
|
@@ -193,6 +226,10 @@ export type OpenTheme = {
|
|
|
193
226
|
assistant?: SpeakerTheme;
|
|
194
227
|
user?: SpeakerTheme;
|
|
195
228
|
} | undefined;
|
|
229
|
+
/**
|
|
230
|
+
* The wait for an answer.
|
|
231
|
+
*/
|
|
232
|
+
loading?: LoadingTheme | undefined;
|
|
196
233
|
animation?: (Animation & {
|
|
197
234
|
scroll?: "smooth" | "instant";
|
|
198
235
|
}) | undefined;
|
|
@@ -241,6 +278,7 @@ export type ResolvedClosed = {
|
|
|
241
278
|
y: number;
|
|
242
279
|
};
|
|
243
280
|
radius: number;
|
|
281
|
+
draggable: boolean;
|
|
244
282
|
image: string | null;
|
|
245
283
|
label: string | null;
|
|
246
284
|
component: (new () => HTMLElement) | string | null;
|
|
@@ -271,6 +309,7 @@ export type ResolvedOpen = {
|
|
|
271
309
|
};
|
|
272
310
|
radius: number;
|
|
273
311
|
launcher: "hidden" | "visible";
|
|
312
|
+
draggable: boolean;
|
|
274
313
|
header: {
|
|
275
314
|
visible: boolean;
|
|
276
315
|
title: string | null;
|
|
@@ -289,6 +328,7 @@ export type ResolvedOpen = {
|
|
|
289
328
|
assistant: Required<SpeakerTheme>;
|
|
290
329
|
user: Required<SpeakerTheme>;
|
|
291
330
|
};
|
|
331
|
+
loading: Required<LoadingTheme>;
|
|
292
332
|
animation: {
|
|
293
333
|
enter: Effect;
|
|
294
334
|
exit: Effect;
|
package/package.json
CHANGED
package/src/chit-ui.js
CHANGED
|
@@ -8,6 +8,7 @@ import { BreakpointController } from './controllers/breakpoint-controller.js';
|
|
|
8
8
|
import { ThemeController } from './controllers/theme-controller.js';
|
|
9
9
|
import { ScrollController } from './controllers/scroll-controller.js';
|
|
10
10
|
import { ComposerController } from './controllers/composer-controller.js';
|
|
11
|
+
import { DragController } from './controllers/drag-controller.js';
|
|
11
12
|
import { renderLauncher } from './render/launcher.js';
|
|
12
13
|
import { renderPanel } from './render/panel.js';
|
|
13
14
|
import { resolveLabels, resolveLocale } from './i18n/labels.js';
|
|
@@ -56,7 +57,8 @@ export class ChitUI extends LitElement {
|
|
|
56
57
|
state: { type: String, reflect: true, noAccessor: true },
|
|
57
58
|
theme: { type: Object },
|
|
58
59
|
messages: { type: Array },
|
|
59
|
-
typing: { type: Object },
|
|
60
|
+
typing: { type: Object, noAccessor: true },
|
|
61
|
+
loading: { type: Boolean, reflect: true, noAccessor: true },
|
|
60
62
|
busy: { type: Boolean, reflect: true },
|
|
61
63
|
inputDisabled: { type: Boolean, reflect: true, attribute: 'input-disabled' },
|
|
62
64
|
inputHidden: { type: Boolean, reflect: true, attribute: 'input-hidden' },
|
|
@@ -72,6 +74,12 @@ export class ChitUI extends LitElement {
|
|
|
72
74
|
/** @type {ChatState} */
|
|
73
75
|
#state = 'closed';
|
|
74
76
|
|
|
77
|
+
/** @type {boolean | { html: string }} */
|
|
78
|
+
#typing = false;
|
|
79
|
+
|
|
80
|
+
/** @type {boolean} */
|
|
81
|
+
#loading = false;
|
|
82
|
+
|
|
75
83
|
/**
|
|
76
84
|
* Set by the widget's own event handlers just before they move the state, so
|
|
77
85
|
* the events can say the move came from a person rather than from code.
|
|
@@ -115,9 +123,6 @@ export class ChitUI extends LitElement {
|
|
|
115
123
|
/** @type {Message[]} Rendered as given. The library never mutates this. */
|
|
116
124
|
this.messages = [];
|
|
117
125
|
|
|
118
|
-
/** @type {boolean | { html: string }} Show the "typing" bubble. */
|
|
119
|
-
this.typing = false;
|
|
120
|
-
|
|
121
126
|
/** @type {boolean} Lock the composer while a reply is in flight. */
|
|
122
127
|
this.busy = false;
|
|
123
128
|
|
|
@@ -175,6 +180,220 @@ export class ChitUI extends LitElement {
|
|
|
175
180
|
behavior: () => this.currentTheme.open.animation.scroll,
|
|
176
181
|
});
|
|
177
182
|
this.#composer = new ComposerController(this);
|
|
183
|
+
|
|
184
|
+
this.#launcherDrag = new DragController(this, {
|
|
185
|
+
name: 'launcher',
|
|
186
|
+
enabled: () => this.currentTheme.closed.draggable,
|
|
187
|
+
element: () => this.#part('launcher'),
|
|
188
|
+
size: () => boxOf(this.#part('launcher')),
|
|
189
|
+
corner: () => this.currentTheme.closed.position,
|
|
190
|
+
base: () => this.currentTheme.closed.offset,
|
|
191
|
+
current: () => this.#dragOffset,
|
|
192
|
+
onMove: (displacement) => this.#moveTo(displacement),
|
|
193
|
+
onSettle: (name) => this.#announceMove(name),
|
|
194
|
+
vars: { x: '--chit-launcher-offset-x', y: '--chit-launcher-offset-y' },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
this.#panelDrag = new DragController(this, {
|
|
198
|
+
name: 'panel',
|
|
199
|
+
// On a phone the panel is the whole screen; there is nowhere to move it.
|
|
200
|
+
enabled: () => this.currentTheme.open.draggable && this.device === 'pc',
|
|
201
|
+
element: () => this.#part('panel'),
|
|
202
|
+
// What the theme asks for, capped by the window — never the measured
|
|
203
|
+
// box, which shrinks as the panel is pushed towards its own corner.
|
|
204
|
+
size: () => {
|
|
205
|
+
const open = this.currentTheme.open;
|
|
206
|
+
return {
|
|
207
|
+
width: Math.min(open.width, window.innerWidth - 16),
|
|
208
|
+
height: Math.min(open.height, window.innerHeight - 16),
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
corner: () => this.currentTheme.open.position,
|
|
212
|
+
base: () => this.currentTheme.open.offset,
|
|
213
|
+
current: () => this.#dragOffset,
|
|
214
|
+
onMove: (displacement) => this.#moveTo(displacement),
|
|
215
|
+
onSettle: (name) => this.#announceMove(name),
|
|
216
|
+
vars: { x: '--chit-panel-offset-x', y: '--chit-panel-offset-y' },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** @type {DragController} */
|
|
221
|
+
#launcherDrag;
|
|
222
|
+
|
|
223
|
+
/** @type {DragController} */
|
|
224
|
+
#panelDrag;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} name
|
|
228
|
+
* @returns {HTMLElement | null}
|
|
229
|
+
*/
|
|
230
|
+
#part(name) {
|
|
231
|
+
return /** @type {HTMLElement | null} */ (
|
|
232
|
+
this.renderRoot?.querySelector(`[part~="${name}"]`) ?? null
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The launcher's drag, for the render function to hook up. */
|
|
237
|
+
get launcherDrag() {
|
|
238
|
+
return this.#launcherDrag;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The panel's drag, likewise. */
|
|
242
|
+
get panelDrag() {
|
|
243
|
+
return this.#panelDrag;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* How far the reader has dragged the widget from where the theme put it, in
|
|
248
|
+
* screen pixels, or null when it is still there.
|
|
249
|
+
*
|
|
250
|
+
* There is one of these for the whole widget rather than one per state: the
|
|
251
|
+
* launcher and the panel are the same widget wearing two shapes, so moving
|
|
252
|
+
* either moves both, and the panel opens where the launcher was left.
|
|
253
|
+
*
|
|
254
|
+
* @type {{ x: number, y: number } | null}
|
|
255
|
+
*/
|
|
256
|
+
get dragOffset() {
|
|
257
|
+
return this.#dragOffset;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
set dragOffset(value) {
|
|
261
|
+
this.#dragOffset = value ? { x: value.x, y: value.y } : null;
|
|
262
|
+
this.#place();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Forget the dragged position and go back to what the theme says. */
|
|
266
|
+
resetPosition() {
|
|
267
|
+
this.dragOffset = null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @type {{ x: number, y: number } | null} */
|
|
271
|
+
#dragOffset = null;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {{ x: number, y: number }} displacement
|
|
275
|
+
* @returns {void}
|
|
276
|
+
*/
|
|
277
|
+
#moveTo(displacement) {
|
|
278
|
+
this.#dragOffset = displacement;
|
|
279
|
+
this.#place();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Put both shapes where the displacement says. */
|
|
283
|
+
#place() {
|
|
284
|
+
this.#launcherDrag.place(this.#dragOffset);
|
|
285
|
+
this.#panelDrag.place(this.#dragOffset);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {'launcher' | 'panel'} name
|
|
290
|
+
* @returns {void}
|
|
291
|
+
*/
|
|
292
|
+
#announceMove(name) {
|
|
293
|
+
if (!this.#dragOffset) return;
|
|
294
|
+
const theme = this.currentTheme;
|
|
295
|
+
const side = name === 'launcher' ? theme.closed : theme.open;
|
|
296
|
+
const drag = name === 'launcher' ? this.#launcherDrag : this.#panelDrag;
|
|
297
|
+
emit(this, Events.MOVE, {
|
|
298
|
+
target: name,
|
|
299
|
+
position: side.position,
|
|
300
|
+
// The thing that was dragged is on screen by definition, so `place`
|
|
301
|
+
// gives the offset it actually landed on, clamp included.
|
|
302
|
+
offset: drag.place(this.#dragOffset) ?? side.offset,
|
|
303
|
+
displacement: { ...this.#dragOffset },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
308
|
+
#loadingTimer;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The ids present when the wait began. A reply is anything from the other
|
|
312
|
+
* side that was not among them; the consumer's own echo of what the reader
|
|
313
|
+
* just sent is not an answer and must not end the wait.
|
|
314
|
+
*
|
|
315
|
+
* @type {Set<string> | undefined}
|
|
316
|
+
*/
|
|
317
|
+
#waitFrom;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The composer calls this once a submit has gone out and no listener
|
|
321
|
+
* cancelled it. Listening for `chat-submit` here instead would be wrong:
|
|
322
|
+
* this element's own listener runs before the consumer's, so it cannot see
|
|
323
|
+
* a `preventDefault()` that is still to come.
|
|
324
|
+
*
|
|
325
|
+
* @returns {void}
|
|
326
|
+
*/
|
|
327
|
+
handleSubmitted() {
|
|
328
|
+
this.#startWaiting();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Begin the automatic wait, if the theme asked for one. */
|
|
332
|
+
#startWaiting() {
|
|
333
|
+
const loading = this.currentTheme.open.loading;
|
|
334
|
+
if (!loading.auto) return;
|
|
335
|
+
|
|
336
|
+
this.#waitFrom = new Set(this.messages.map((message) => message.id));
|
|
337
|
+
this.loading = true;
|
|
338
|
+
this.#clearLoadingTimer();
|
|
339
|
+
if (loading.timeout > 0) {
|
|
340
|
+
this.#loadingTimer = setTimeout(() => {
|
|
341
|
+
this.#loadingTimer = undefined;
|
|
342
|
+
this.#stopWaiting();
|
|
343
|
+
}, loading.timeout);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** End it, however it ended. */
|
|
348
|
+
#stopWaiting() {
|
|
349
|
+
this.#clearLoadingTimer();
|
|
350
|
+
this.#waitFrom = undefined;
|
|
351
|
+
this.loading = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#clearLoadingTimer() {
|
|
355
|
+
if (this.#loadingTimer !== undefined) clearTimeout(this.#loadingTimer);
|
|
356
|
+
this.#loadingTimer = undefined;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Show the "typing" bubble: `true`, or `{ html }` for your own markup.
|
|
361
|
+
*
|
|
362
|
+
* Turning this on turns `loading` off. The two describe the same pause in
|
|
363
|
+
* the conversation — one says a person is writing, the other that a server
|
|
364
|
+
* has not answered yet — and showing both would leave the reader to work
|
|
365
|
+
* out the difference.
|
|
366
|
+
*
|
|
367
|
+
* @type {boolean | { html: string }}
|
|
368
|
+
*/
|
|
369
|
+
get typing() {
|
|
370
|
+
return this.#typing;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
set typing(value) {
|
|
374
|
+
const previous = this.#typing;
|
|
375
|
+
if (value === previous) return;
|
|
376
|
+
this.#typing = value;
|
|
377
|
+
this.requestUpdate('typing', previous);
|
|
378
|
+
if (value) this.loading = false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Show the "waiting for an answer" indicator. Turning this on turns
|
|
383
|
+
* `typing` off, for the reason given above.
|
|
384
|
+
*
|
|
385
|
+
* @type {boolean}
|
|
386
|
+
*/
|
|
387
|
+
get loading() {
|
|
388
|
+
return this.#loading;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
set loading(value) {
|
|
392
|
+
const previous = this.#loading;
|
|
393
|
+
if (value === previous) return;
|
|
394
|
+
this.#loading = value;
|
|
395
|
+
this.requestUpdate('loading', previous);
|
|
396
|
+
if (value) this.typing = false;
|
|
178
397
|
}
|
|
179
398
|
|
|
180
399
|
/**
|
|
@@ -537,6 +756,26 @@ export class ChitUI extends LitElement {
|
|
|
537
756
|
const live = new Set(this.messages.map((message) => message.id));
|
|
538
757
|
pruneComponents(this, live);
|
|
539
758
|
this.#announceRenders(live);
|
|
759
|
+
|
|
760
|
+
// An answer has arrived when a message from the other side appears that
|
|
761
|
+
// was not there when the wait began.
|
|
762
|
+
// The shape on screen has just changed, so whatever is there now needs to
|
|
763
|
+
// be put where the drag left the widget — and clamped to today's window.
|
|
764
|
+
if (this.#dragOffset) this.#place();
|
|
765
|
+
|
|
766
|
+
if (this.#waitFrom) {
|
|
767
|
+
const answered = this.messages.some(
|
|
768
|
+
(message) =>
|
|
769
|
+
message.role !== 'user' && !(/** @type {Set<string>} */ (this.#waitFrom).has(message.id)),
|
|
770
|
+
);
|
|
771
|
+
if (answered) this.#stopWaiting();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** @override */
|
|
776
|
+
disconnectedCallback() {
|
|
777
|
+
super.disconnectedCallback();
|
|
778
|
+
this.#clearLoadingTimer();
|
|
540
779
|
}
|
|
541
780
|
|
|
542
781
|
/**
|
|
@@ -583,3 +822,15 @@ export class ChitUI extends LitElement {
|
|
|
583
822
|
`;
|
|
584
823
|
}
|
|
585
824
|
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* The size of something on screen, or nothing at all when it is not there.
|
|
828
|
+
*
|
|
829
|
+
* @param {HTMLElement | null} element
|
|
830
|
+
* @returns {{ width: number, height: number }}
|
|
831
|
+
*/
|
|
832
|
+
function boxOf(element) {
|
|
833
|
+
if (!element) return { width: 0, height: 0 };
|
|
834
|
+
const box = element.getBoundingClientRect();
|
|
835
|
+
return { width: box.width, height: box.height };
|
|
836
|
+
}
|