@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.
- package/CHANGELOG.md +91 -0
- package/README.md +314 -11
- package/dist/chit-ui.iife.min.js +416 -94
- package/dist/chit-ui.iife.min.js.map +1 -1
- package/dist/chit-ui.min.js +407 -85
- package/dist/chit-ui.min.js.map +1 -1
- package/dist/types/bundle.d.ts +1 -0
- package/dist/types/chit-ui.d.ts +89 -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 +3 -0
- package/dist/types/global.d.ts +9 -1
- package/dist/types/i18n/labels.d.ts +16 -0
- package/dist/types/render/composer.d.ts +0 -1
- package/dist/types/render/message-list.d.ts +0 -1
- package/dist/types/theme/default-theme.d.ts +30 -1
- package/dist/types/themes/green.d.ts +16 -0
- package/dist/types/themes/index.d.ts +1 -0
- package/dist/types/types.d.ts +105 -4
- package/package.json +6 -1
- package/src/bundle.js +1 -0
- package/src/chit-ui.js +292 -4
- package/src/controllers/composer-controller.js +2 -0
- package/src/controllers/drag-controller.js +319 -0
- package/src/events.js +3 -0
- package/src/global.d.ts +9 -1
- package/src/i18n/labels.js +12 -0
- package/src/render/composer.js +58 -0
- package/src/render/launcher.js +9 -1
- package/src/render/message-list.js +40 -10
- package/src/render/message.js +13 -4
- package/src/render/panel.js +73 -11
- package/src/styles/composer.css.js +39 -0
- package/src/styles/host.css.js +2 -0
- package/src/styles/launcher.css.js +13 -0
- package/src/styles/message.css.js +168 -9
- package/src/styles/panel.css.js +44 -4
- package/src/theme/default-theme.js +37 -2
- package/src/theme/theme-to-css.js +5 -0
- package/src/themes/green.js +47 -0
- package/src/themes/index.js +15 -0
- 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