@hidemikimura/chit-ui 0.1.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +314 -11
  3. package/dist/chit-ui.iife.min.js +416 -94
  4. package/dist/chit-ui.iife.min.js.map +1 -1
  5. package/dist/chit-ui.min.js +407 -85
  6. package/dist/chit-ui.min.js.map +1 -1
  7. package/dist/types/bundle.d.ts +1 -0
  8. package/dist/types/chit-ui.d.ts +89 -4
  9. package/dist/types/controllers/composer-controller.d.ts +4 -0
  10. package/dist/types/controllers/drag-controller.d.ts +118 -0
  11. package/dist/types/events.d.ts +3 -0
  12. package/dist/types/global.d.ts +9 -1
  13. package/dist/types/i18n/labels.d.ts +16 -0
  14. package/dist/types/render/composer.d.ts +0 -1
  15. package/dist/types/render/message-list.d.ts +0 -1
  16. package/dist/types/theme/default-theme.d.ts +30 -1
  17. package/dist/types/themes/green.d.ts +16 -0
  18. package/dist/types/themes/index.d.ts +1 -0
  19. package/dist/types/types.d.ts +105 -4
  20. package/package.json +6 -1
  21. package/src/bundle.js +1 -0
  22. package/src/chit-ui.js +292 -4
  23. package/src/controllers/composer-controller.js +2 -0
  24. package/src/controllers/drag-controller.js +319 -0
  25. package/src/events.js +3 -0
  26. package/src/global.d.ts +9 -1
  27. package/src/i18n/labels.js +12 -0
  28. package/src/render/composer.js +58 -0
  29. package/src/render/launcher.js +9 -1
  30. package/src/render/message-list.js +40 -10
  31. package/src/render/message.js +13 -4
  32. package/src/render/panel.js +73 -11
  33. package/src/styles/composer.css.js +39 -0
  34. package/src/styles/host.css.js +2 -0
  35. package/src/styles/launcher.css.js +13 -0
  36. package/src/styles/message.css.js +168 -9
  37. package/src/styles/panel.css.js +44 -4
  38. package/src/theme/default-theme.js +37 -2
  39. package/src/theme/theme-to-css.js +5 -0
  40. package/src/themes/green.js +47 -0
  41. package/src/themes/index.js +15 -0
  42. package/src/types.js +38 -6
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
  /**
@@ -387,6 +606,27 @@ export class ChitUI extends LitElement {
387
606
  else this.#composer.onCompositionEnd();
388
607
  }
389
608
 
609
+ /**
610
+ * Report files the reader picked with the attach button.
611
+ *
612
+ * Nothing is uploaded, previewed or added to the conversation here: the
613
+ * widget does not own `messages` and has nowhere to send bytes.
614
+ *
615
+ * @param {File[]} files
616
+ * @returns {void}
617
+ */
618
+ handleAttach(files) {
619
+ emit(this, Events.ATTACH, { files });
620
+ }
621
+
622
+ /** Open the file picker from code, as the attach button does. */
623
+ openAttach() {
624
+ const field = /** @type {HTMLInputElement | null} */ (
625
+ this.renderRoot.querySelector('[part~="attach-input"]')
626
+ );
627
+ field?.click();
628
+ }
629
+
390
630
  // --- internal ---------------------------------------------------------
391
631
 
392
632
  /**
@@ -442,6 +682,21 @@ export class ChitUI extends LitElement {
442
682
  return this.#go('closed', 'user');
443
683
  }
444
684
 
685
+ /**
686
+ * Ask to start the conversation over.
687
+ *
688
+ * The widget only announces it: `messages` belongs to the consumer, so
689
+ * clearing it, replaying a scenario or asking for confirmation first are all
690
+ * theirs to do. Cancelling the event is not offered for the same reason —
691
+ * there is nothing here to cancel.
692
+ *
693
+ * @param {Trigger} [trigger='api']
694
+ * @returns {void}
695
+ */
696
+ home(trigger = 'api') {
697
+ emit(this, Events.HOME, { trigger });
698
+ }
699
+
445
700
  /**
446
701
  * @param {ChatState} to
447
702
  * @param {Trigger} trigger
@@ -493,6 +748,7 @@ export class ChitUI extends LitElement {
493
748
  this.dataset.launcherPosition = theme.closed.position;
494
749
  this.dataset.panelPosition = theme.open.position;
495
750
  this.dataset.idle = theme.closed.animation.idle;
751
+ this.dataset.bubbleTail = theme.open.bubble.tail;
496
752
  }
497
753
 
498
754
  /** @override */
@@ -500,6 +756,26 @@ export class ChitUI extends LitElement {
500
756
  const live = new Set(this.messages.map((message) => message.id));
501
757
  pruneComponents(this, live);
502
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();
503
779
  }
504
780
 
505
781
  /**
@@ -546,3 +822,15 @@ export class ChitUI extends LitElement {
546
822
  `;
547
823
  }
548
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
+ }
@@ -152,6 +152,7 @@ export class ComposerController {
152
152
  if (!allowed) return false;
153
153
 
154
154
  this.clear();
155
+ this.#host.handleSubmitted();
155
156
  return true;
156
157
  }
157
158
 
@@ -212,4 +213,5 @@ export class ComposerController {
212
213
  * @property {boolean} sendOnEnter
213
214
  * @property {number | undefined} maxLength
214
215
  * @property {import('../i18n/labels.js').Labels} currentLabels
216
+ * @property {() => void} handleSubmitted Called after a submit that no listener cancelled.
215
217
  */
@@ -0,0 +1,319 @@
1
+ // @ts-check
2
+
3
+ /** @import { ReactiveController, LitElement } from 'lit' */
4
+ /** @import { Position } from '../types.js' */
5
+
6
+ /** How far a pointer may travel before the gesture counts as a drag, not a click. */
7
+ const SLOP = 4;
8
+
9
+ /** How close to the viewport edge the dragged thing may come. */
10
+ const MARGIN = 8;
11
+
12
+ /** Arrow-key step, and the bigger step Shift asks for. */
13
+ const STEP = 8;
14
+ const BIG_STEP = 32;
15
+
16
+ /**
17
+ * Moves one thing — the launcher or the panel — around the viewport.
18
+ *
19
+ * What a gesture produces is not a position but a displacement: how far the
20
+ * reader has dragged the widget from wherever the theme put it, in screen
21
+ * pixels. The host keeps one such displacement for the whole widget and hands
22
+ * it to both controllers, which is what makes the two states travel together:
23
+ * drag the launcher into a corner and the panel opens in that same corner,
24
+ * because both are the theme's position plus the same shift.
25
+ *
26
+ * Each controller then turns the displacement into the terms its own corner
27
+ * uses — at a right-hand corner, moving right means a smaller offset — and
28
+ * writes it into the two custom properties the stylesheet already reads. The
29
+ * offset lives on the host as an inline property, which outranks the theme's
30
+ * sheet and the page's CSS: a reader who dragged the widget somewhere means
31
+ * it, and until the drag is cleared theirs is the most specific word on where
32
+ * it goes.
33
+ *
34
+ * @implements {ReactiveController}
35
+ */
36
+ export class DragController {
37
+ /** @type {LitElement} */
38
+ #host;
39
+
40
+ /** @type {DragOptions} */
41
+ #options;
42
+
43
+ /**
44
+ * Pointer state, only while a gesture is in flight.
45
+ *
46
+ * @type {{
47
+ * pointer: { x: number, y: number },
48
+ * displacement: { x: number, y: number },
49
+ * size: { width: number, height: number },
50
+ * corner: Position,
51
+ * base: { x: number, y: number },
52
+ * moved: boolean,
53
+ * pointerId: number,
54
+ * target: HTMLElement,
55
+ * } | undefined}
56
+ */
57
+ #from;
58
+
59
+ /** True once a gesture has passed the slop, until the click that follows it. */
60
+ #dragged = false;
61
+
62
+ /**
63
+ * @param {LitElement} host
64
+ * @param {DragOptions} options
65
+ */
66
+ constructor(host, options) {
67
+ this.#host = host;
68
+ this.#options = options;
69
+ host.addController(this);
70
+ }
71
+
72
+ hostConnected() {
73
+ // A smaller window can leave the widget hanging off the edge; placing it
74
+ // again runs it back through the clamp.
75
+ this.#onResize = () => {
76
+ const now = this.#options.current();
77
+ if (now) this.#options.onMove(now);
78
+ };
79
+ window.addEventListener('resize', this.#onResize);
80
+ }
81
+
82
+ hostDisconnected() {
83
+ if (this.#onResize) window.removeEventListener('resize', this.#onResize);
84
+ this.#onResize = undefined;
85
+ }
86
+
87
+ /** @type {(() => void) | undefined} */
88
+ #onResize;
89
+
90
+ /**
91
+ * True when the gesture that just ended was a drag. Reading it clears it, so
92
+ * the click a pointer-up fires can be skipped exactly once.
93
+ *
94
+ * @returns {boolean}
95
+ */
96
+ consumeDrag() {
97
+ const dragged = this.#dragged;
98
+ this.#dragged = false;
99
+ return dragged;
100
+ }
101
+
102
+ /**
103
+ * Put this thing where the displacement says, within the viewport.
104
+ *
105
+ * @param {{ x: number, y: number } | null} displacement
106
+ * @returns {{ x: number, y: number } | undefined} Where it ended up, when it is on screen.
107
+ */
108
+ place(displacement) {
109
+ if (!displacement) {
110
+ for (const name of [this.#options.vars.x, this.#options.vars.y]) {
111
+ this.#host.style.removeProperty(name);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ const element = this.#options.element();
117
+ const offset = this.#toOffset(displacement);
118
+ // Nothing on screen yet: write it anyway, so the thing is already in
119
+ // place the moment it is rendered, and let the next pass clamp it.
120
+ const placed = element ? this.#clamp(offset, this.#options.size()) : offset;
121
+
122
+ this.#host.style.setProperty(this.#options.vars.x, `${placed.x}px`);
123
+ this.#host.style.setProperty(this.#options.vars.y, `${placed.y}px`);
124
+ return element ? placed : undefined;
125
+ }
126
+
127
+ /**
128
+ * Begin a drag.
129
+ *
130
+ * @param {PointerEvent} event
131
+ * @returns {void}
132
+ */
133
+ start(event) {
134
+ if (!this.#options.enabled() || event.button !== 0) return;
135
+ const element = this.#options.element();
136
+ if (!element) return;
137
+
138
+ this.#from = {
139
+ pointer: { x: event.clientX, y: event.clientY },
140
+ displacement: this.#options.current() ?? { x: 0, y: 0 },
141
+ size: this.#options.size(),
142
+ corner: this.#options.corner(),
143
+ base: this.#options.base(),
144
+ moved: false,
145
+ pointerId: event.pointerId,
146
+ target: /** @type {HTMLElement} */ (event.currentTarget),
147
+ };
148
+
149
+ // Capture keeps the moves coming even when the pointer leaves the handle.
150
+ // It throws if the pointer has already gone; the drag still works without
151
+ // it, so there is nothing to do about that but carry on.
152
+ try {
153
+ this.#from.target.setPointerCapture(event.pointerId);
154
+ } catch {
155
+ /* no capture available */
156
+ }
157
+
158
+ this.#from.target.addEventListener('pointermove', this.#onPointerMove);
159
+ this.#from.target.addEventListener('pointerup', this.#onPointerEnd);
160
+ this.#from.target.addEventListener('pointercancel', this.#onPointerEnd);
161
+ }
162
+
163
+ /** @param {PointerEvent} event */
164
+ #onPointerMove = (event) => {
165
+ const from = this.#from;
166
+ if (!from || event.pointerId !== from.pointerId) return;
167
+
168
+ const dx = event.clientX - from.pointer.x;
169
+ const dy = event.clientY - from.pointer.y;
170
+ if (!from.moved && Math.hypot(dx, dy) < SLOP) return;
171
+
172
+ from.moved = true;
173
+ // A drag must not also select text or scroll the page under the finger.
174
+ event.preventDefault();
175
+ this.#options.onMove(
176
+ this.#limit(
177
+ { x: from.displacement.x + dx, y: from.displacement.y + dy },
178
+ from.base,
179
+ from.corner,
180
+ from.size,
181
+ ),
182
+ );
183
+ };
184
+
185
+ /** @param {PointerEvent} event */
186
+ #onPointerEnd = (event) => {
187
+ const from = this.#from;
188
+ if (!from || event.pointerId !== from.pointerId) return;
189
+
190
+ from.target.removeEventListener('pointermove', this.#onPointerMove);
191
+ from.target.removeEventListener('pointerup', this.#onPointerEnd);
192
+ from.target.removeEventListener('pointercancel', this.#onPointerEnd);
193
+ try {
194
+ if (from.target.hasPointerCapture(event.pointerId)) {
195
+ from.target.releasePointerCapture(event.pointerId);
196
+ }
197
+ } catch {
198
+ /* never had it */
199
+ }
200
+
201
+ this.#from = undefined;
202
+ if (!from.moved) return;
203
+ this.#dragged = true;
204
+ this.#options.onSettle(this.#options.name);
205
+ };
206
+
207
+ /**
208
+ * Move with the arrow keys, so the same thing can be done without a pointer.
209
+ *
210
+ * @param {KeyboardEvent} event
211
+ * @returns {boolean} True when the key was used.
212
+ */
213
+ nudge(event) {
214
+ if (!this.#options.enabled()) return false;
215
+ if (event.altKey || event.ctrlKey || event.metaKey) return false;
216
+
217
+ /** @type {Record<string, [number, number]>} */
218
+ const directions = {
219
+ ArrowLeft: [-1, 0],
220
+ ArrowRight: [1, 0],
221
+ ArrowUp: [0, -1],
222
+ ArrowDown: [0, 1],
223
+ };
224
+ const direction = directions[event.key];
225
+ if (!direction) return false;
226
+
227
+ const element = this.#options.element();
228
+ if (!element) return false;
229
+
230
+ const step = event.shiftKey ? BIG_STEP : STEP;
231
+ const now = this.#options.current() ?? { x: 0, y: 0 };
232
+ event.preventDefault();
233
+ this.#options.onMove(
234
+ this.#limit(
235
+ { x: now.x + direction[0] * step, y: now.y + direction[1] * step },
236
+ this.#options.base(),
237
+ this.#options.corner(),
238
+ this.#options.size(),
239
+ ),
240
+ );
241
+ this.#options.onSettle(this.#options.name);
242
+ return true;
243
+ }
244
+
245
+ /**
246
+ * The displacement in the terms this corner counts in.
247
+ *
248
+ * @param {{ x: number, y: number }} displacement
249
+ * @param {{ x: number, y: number }} [base]
250
+ * @param {Position} [corner]
251
+ * @returns {{ x: number, y: number }}
252
+ */
253
+ #toOffset(displacement, base = this.#options.base(), corner = this.#options.corner()) {
254
+ return {
255
+ x: base.x + (corner.endsWith('right') ? -displacement.x : displacement.x),
256
+ y: base.y + (corner.startsWith('bottom') ? -displacement.y : displacement.y),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * As much of a displacement as this thing can take without leaving the
262
+ * viewport. The dragged thing sets the shared displacement, so the limit
263
+ * has to be expressed there rather than only in the offset it writes.
264
+ *
265
+ * The size is the size the thing means to be, not the one it happens to
266
+ * have: the panel's own stylesheet caps it against the space between its
267
+ * corner and the far edge, so measuring it while it is being pushed into
268
+ * that corner would read back a smaller box and let it be pushed further,
269
+ * squeezing it flat instead of stopping it.
270
+ *
271
+ * @param {{ x: number, y: number }} displacement
272
+ * @param {{ x: number, y: number }} base
273
+ * @param {Position} corner
274
+ * @param {{ width: number, height: number }} size
275
+ * @returns {{ x: number, y: number }}
276
+ */
277
+ #limit(displacement, base, corner, size) {
278
+ const offset = this.#clamp(this.#toOffset(displacement, base, corner), size);
279
+ return {
280
+ x: corner.endsWith('right') ? base.x - offset.x : offset.x - base.x,
281
+ y: corner.startsWith('bottom') ? base.y - offset.y : offset.y - base.y,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * @param {{ x: number, y: number }} offset
287
+ * @param {{ width: number, height: number }} size
288
+ * @returns {{ x: number, y: number }}
289
+ */
290
+ #clamp(offset, size) {
291
+ return {
292
+ x: clamp(offset.x, window.innerWidth - size.width),
293
+ y: clamp(offset.y, window.innerHeight - size.height),
294
+ };
295
+ }
296
+ }
297
+
298
+ /**
299
+ * @param {number} value
300
+ * @param {number} max The offset at which the far edge touches the viewport.
301
+ * @returns {number}
302
+ */
303
+ function clamp(value, max) {
304
+ return Math.round(Math.min(Math.max(value, MARGIN), Math.max(MARGIN, max - MARGIN)));
305
+ }
306
+
307
+ /**
308
+ * @typedef {Object} DragOptions
309
+ * @property {'launcher' | 'panel'} name
310
+ * @property {() => boolean} enabled
311
+ * @property {() => HTMLElement | null} element What moves.
312
+ * @property {() => { width: number, height: number }} size How big it means to be.
313
+ * @property {() => Position} corner Which corner the offset counts from.
314
+ * @property {() => { x: number, y: number }} base The theme's offset, before any drag.
315
+ * @property {() => { x: number, y: number } | null} current The widget's displacement now.
316
+ * @property {(displacement: { x: number, y: number }) => void} onMove A new displacement.
317
+ * @property {(name: 'launcher' | 'panel') => void} onSettle The gesture ended here.
318
+ * @property {{ x: string, y: string }} vars The custom properties to write.
319
+ */
package/src/events.js CHANGED
@@ -15,6 +15,9 @@ export const Events = Object.freeze({
15
15
  MESSAGE_CLICK: 'chat-message-click',
16
16
  SCROLL_TOP: 'chat-scroll-top',
17
17
  BREAKPOINT_CHANGE: 'chat-breakpoint-change',
18
+ HOME: 'chat-home',
19
+ ATTACH: 'chat-attach',
20
+ MOVE: 'chat-move',
18
21
  });
19
22
 
20
23
  /**