@mideind/netskrafl-react 1.0.0-beta.1

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 (68) hide show
  1. package/.eslintignore +8 -0
  2. package/.eslintrc.json +13 -0
  3. package/README.md +63 -0
  4. package/dist/cjs/index.css +6837 -0
  5. package/dist/cjs/index.js +3046 -0
  6. package/dist/cjs/index.js.map +1 -0
  7. package/dist/esm/index.css +6837 -0
  8. package/dist/esm/index.js +3046 -0
  9. package/dist/esm/index.js.map +1 -0
  10. package/dist/types.d.ts +41 -0
  11. package/package.json +63 -0
  12. package/rollup.config.js +60 -0
  13. package/src/components/index.ts +2 -0
  14. package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
  15. package/src/components/netskrafl/Netskrafl.tsx +135 -0
  16. package/src/components/netskrafl/Netskrafl.types.ts +7 -0
  17. package/src/components/netskrafl/index.ts +2 -0
  18. package/src/css/fonts.css +4 -0
  19. package/src/css/glyphs.css +224 -0
  20. package/src/css/skrafl-explo.css +6616 -0
  21. package/src/fonts/glyphicons-regular.eot +0 -0
  22. package/src/fonts/glyphicons-regular.ttf +0 -0
  23. package/src/fonts/glyphicons-regular.woff +0 -0
  24. package/src/index.ts +2 -0
  25. package/src/messages/messages.json +1576 -0
  26. package/src/mithril/actions.ts +319 -0
  27. package/src/mithril/bag.ts +65 -0
  28. package/src/mithril/bestdisplay.ts +74 -0
  29. package/src/mithril/blankdialog.ts +94 -0
  30. package/src/mithril/board.ts +336 -0
  31. package/src/mithril/buttons.ts +303 -0
  32. package/src/mithril/challengedialog.ts +186 -0
  33. package/src/mithril/channel.ts +162 -0
  34. package/src/mithril/chat.ts +228 -0
  35. package/src/mithril/components.ts +496 -0
  36. package/src/mithril/dragdrop.ts +219 -0
  37. package/src/mithril/elopage.ts +180 -0
  38. package/src/mithril/friend.ts +227 -0
  39. package/src/mithril/game.ts +1378 -0
  40. package/src/mithril/gameview.ts +111 -0
  41. package/src/mithril/globalstate.ts +33 -0
  42. package/src/mithril/i18n.ts +186 -0
  43. package/src/mithril/localstorage.ts +133 -0
  44. package/src/mithril/login.ts +122 -0
  45. package/src/mithril/logo.ts +270 -0
  46. package/src/mithril/main.ts +737 -0
  47. package/src/mithril/mithril.ts +29 -0
  48. package/src/mithril/model.ts +817 -0
  49. package/src/mithril/movelistitem.ts +226 -0
  50. package/src/mithril/page.ts +852 -0
  51. package/src/mithril/playername.ts +91 -0
  52. package/src/mithril/promodialog.ts +82 -0
  53. package/src/mithril/recentlist.ts +148 -0
  54. package/src/mithril/request.ts +52 -0
  55. package/src/mithril/review.ts +634 -0
  56. package/src/mithril/rightcolumn.ts +398 -0
  57. package/src/mithril/searchbutton.ts +118 -0
  58. package/src/mithril/statsdisplay.ts +109 -0
  59. package/src/mithril/tabs.ts +169 -0
  60. package/src/mithril/tile.ts +145 -0
  61. package/src/mithril/twoletter.ts +76 -0
  62. package/src/mithril/types.ts +379 -0
  63. package/src/mithril/userinfodialog.ts +171 -0
  64. package/src/mithril/util.ts +304 -0
  65. package/src/mithril/wait.ts +246 -0
  66. package/src/mithril/wordcheck.ts +102 -0
  67. package/tsconfig.json +28 -0
  68. package/vite.config.ts +12 -0
@@ -0,0 +1,496 @@
1
+
2
+ import { IView, UserListItem } from "./types";
3
+ import { VnodeAttrs, Component, m, ComponentFunc, EventHandler, VnodeDOM, VnodeChildren } from "./mithril";
4
+ import { AnimatedExploLogo, ExploLogoOnly } from "./logo";
5
+ import { buttonOut, buttonOver, glyph, nbsp, playAudio } from "./util";
6
+ import { ts } from "./i18n";
7
+ import { request } from "./request";
8
+
9
+ const SPINNER_INITIAL_DELAY = 800; // milliseconds
10
+
11
+ export const Spinner: Component<
12
+ {},
13
+ {
14
+ ival: ReturnType<typeof setTimeout> | number;
15
+ show: boolean;
16
+ }
17
+ > = {
18
+ // Show a spinner wait box, after an initial delay
19
+ oninit: (vnode) => {
20
+ vnode.state.show = false;
21
+ vnode.state.ival = setTimeout(() => {
22
+ vnode.state.show = true; vnode.state.ival = 0; m.redraw();
23
+ }, SPINNER_INITIAL_DELAY);
24
+ },
25
+ onremove: (vnode) => {
26
+ if (vnode.state.ival)
27
+ clearTimeout(vnode.state.ival);
28
+ vnode.state.ival = 0;
29
+ },
30
+ view: (vnode) => {
31
+ if (!vnode.state.show)
32
+ return undefined;
33
+ return m(
34
+ ".modal-dialog",
35
+ { id: 'spinner-dialog', style: { visibility: 'visible' } },
36
+ m("div.animated-spinner",
37
+ m(AnimatedExploLogo, { msStepTime: 200, width: 120, withCircle: true })
38
+ )
39
+ );
40
+ }
41
+ };
42
+
43
+ export const TextInput: ComponentFunc<{
44
+ initialValue: string;
45
+ class: string;
46
+ maxlength: number;
47
+ id: string;
48
+ tabindex?: number;
49
+ autocomplete?: string;
50
+ }> = () => {
51
+
52
+ // Generic text input field
53
+
54
+ let value = "";
55
+ let cls = "";
56
+
57
+ return {
58
+ oninit: (vnode) => {
59
+ value = vnode.attrs.initialValue + "";
60
+ cls = vnode.attrs.class;
61
+ if (cls)
62
+ cls = "." + cls.split(" ").join(".");
63
+ else
64
+ cls = "";
65
+ },
66
+ view: (vnode) => {
67
+ const { id, maxlength, tabindex, autocomplete } = vnode.attrs;
68
+ return m("input.text" + cls,
69
+ {
70
+ id,
71
+ name: id,
72
+ maxlength,
73
+ tabindex,
74
+ autocomplete,
75
+ value,
76
+ oninput: (ev: Event) => { value = (ev.target as HTMLInputElement).value + ""; }
77
+ }
78
+ );
79
+ }
80
+ };
81
+
82
+ }
83
+
84
+ export const DialogButton: Component<{
85
+ id: string;
86
+ title: string;
87
+ onclick: EventHandler;
88
+ tabindex?: number;
89
+ }> = {
90
+ view: (vnode) => {
91
+ const attrs: VnodeAttrs = {
92
+ onmouseout: buttonOut,
93
+ onmouseover: buttonOver
94
+ };
95
+ Object.assign(attrs, vnode.attrs);
96
+ return m(".modal-close", attrs, vnode.children);
97
+ }
98
+ };
99
+
100
+ export const ChallengeButton: Component<{
101
+ view: IView;
102
+ item: UserListItem;
103
+ }> = {
104
+ view: (vnode) => {
105
+ const item = vnode.attrs.item;
106
+ const outerView = vnode.attrs.view;
107
+ const model = outerView.model;
108
+ const isRobot = item.userid.indexOf("robot-") === 0;
109
+
110
+ function modifyChallenge() {
111
+ if (item.chall) {
112
+ // Retracting challenge
113
+ item.chall = false;
114
+ // Note: the effect of this is to retract all challenges
115
+ // that this user has issued to the destination user
116
+ model.modifyChallenge({ destuser: item.userid, action: "retract" });
117
+ }
118
+ else if (isRobot) {
119
+ // Challenging a robot: game starts immediately
120
+ model.newGame(item.userid, false);
121
+ } else {
122
+ // Challenging a user: show a challenge dialog
123
+ outerView.pushDialog("challenge", item);
124
+ }
125
+ }
126
+
127
+ return m("span.list-ch",
128
+ {
129
+ title: ts("Skora á"),
130
+ onclick: (ev: Event) => {
131
+ modifyChallenge();
132
+ ev.preventDefault();
133
+ }
134
+ },
135
+ glyph("hand-right", undefined, !item.chall),
136
+ );
137
+ }
138
+ };
139
+
140
+ export const UserInfoButton: Component<{
141
+ view: IView;
142
+ item: { userid: string; nick: string; fullname: string; };
143
+ }> = {
144
+ view: (vnode) => {
145
+ const item = vnode.attrs.item;
146
+ const outerView = vnode.attrs.view;
147
+ const isRobot = !item.userid || item.userid.indexOf("robot-") === 0;
148
+ return m("span.list-info",
149
+ {
150
+ title: ts("Skoða feril"),
151
+ // Show opponent track record
152
+ onclick: (ev: Event) => {
153
+ if (!isRobot)
154
+ outerView.showUserInfo(item.userid, item.nick, item.fullname);
155
+ ev.preventDefault();
156
+ }
157
+ },
158
+ isRobot ? "" : m("span.usr-info"),
159
+ );
160
+ }
161
+ };
162
+
163
+ export const OnlinePresence: ComponentFunc<{
164
+ id: string; userId: string; online?: boolean;
165
+ }> = (initialVnode) => {
166
+
167
+ // Shows an icon in grey or green depending on whether a given user
168
+ // is online or not. If attrs.online is given (i.e. not undefined),
169
+ // that value is used and displayed; otherwise the server is asked.
170
+
171
+ const attrs = initialVnode.attrs;
172
+ let online = attrs.online ? true : false;
173
+ const askServer = attrs.online === undefined;
174
+ const id = attrs.id;
175
+ const userId = attrs.userId;
176
+
177
+ async function _update() {
178
+ if (askServer) {
179
+ const json = await request<{ online: boolean; }>({
180
+ method: "POST",
181
+ url: "/onlinecheck",
182
+ body: { user: userId }
183
+ });
184
+ online = json && json.online;
185
+ }
186
+ }
187
+
188
+ return {
189
+ oninit: _update,
190
+
191
+ view: (vnode) => {
192
+ if (!askServer)
193
+ // Display the state of the online attribute as-is
194
+ online = vnode.attrs?.online ?? false;
195
+ return m("span",
196
+ {
197
+ id: id,
198
+ title: online ? ts("Er álínis") : ts("Álínis?"),
199
+ class: online ? "online" : ""
200
+ }
201
+ );
202
+ }
203
+ };
204
+
205
+ };
206
+
207
+ export const UserId: Component<{ view: IView }> = {
208
+ // User identifier at top right, opens user preferences
209
+ view: (vnode) => {
210
+ const view = vnode.attrs.view;
211
+ const model = view.model;
212
+ if (!model.state?.userId)
213
+ // Don't show the button if there is no logged-in user
214
+ return "";
215
+ return m(".userid",
216
+ {
217
+ title: ts("player_info"), // "Player information"
218
+ onclick: (ev: Event) => {
219
+ // Overlay the userprefs dialog
220
+ view.pushDialog("userprefs");
221
+ ev.preventDefault();
222
+ }
223
+ },
224
+ [glyph("address-book"), nbsp(), model.state.userNick]
225
+ );
226
+ }
227
+ };
228
+
229
+ export const MultiSelection: ComponentFunc<{
230
+ initialSelection?: number;
231
+ defaultClass?: string;
232
+ selectedClass?: string;
233
+ }> = (initialVnode) => {
234
+
235
+ // A multiple-selection div where users can click on child nodes
236
+ // to select them, giving them an addional selection class,
237
+ // typically .selected
238
+
239
+ let sel = initialVnode.attrs.initialSelection ?? 0;
240
+ const defaultClass = initialVnode.attrs.defaultClass ?? "";
241
+ const selectedClass = initialVnode.attrs.selectedClass || "selected";
242
+
243
+ return {
244
+ view: (vnode) => {
245
+ const children = vnode.children;
246
+ if (!Array.isArray(children)) return undefined;
247
+ return m("div",
248
+ {
249
+ onclick: (ev: Event) => {
250
+ // Catch clicks that are propagated from children up
251
+ // to the parent div. Find which child originated the
252
+ // click (possibly in descendant nodes) and set
253
+ // the current selection accordingly.
254
+ const childNodes = children.map((c) => (c as VnodeDOM).dom);
255
+ for (let i = 0; i < childNodes.length; i++)
256
+ if (childNodes[i].contains(ev.target as Node))
257
+ sel = i;
258
+ ev.stopPropagation();
259
+ }
260
+ },
261
+ children.map((item, i) => {
262
+ // A pretty gross approach, but it works: clobber the childrens' className
263
+ // attribute depending on whether they are selected or not
264
+ const it = item as m.Vnode<{ className: string }>;
265
+ if (i === sel)
266
+ it.attrs.className = defaultClass + " " + selectedClass;
267
+ else
268
+ it.attrs.className = defaultClass;
269
+ return item;
270
+ })
271
+ );
272
+ }
273
+ };
274
+
275
+ };
276
+
277
+ export const Info: Component = {
278
+ // Info icon, invoking the help screen
279
+ view: () => m(".info",
280
+ { title: ts("Upplýsingar og hjálp") },
281
+ m(m.route.Link,
282
+ { href: "/help", class: "iconlink" },
283
+ glyph("info-sign")
284
+ )
285
+ )
286
+ };
287
+
288
+ export const LeftLogo: Component = {
289
+ view: () => m(".logo",
290
+ m(m.route.Link,
291
+ { href: '/main', class: "nodecorate" },
292
+ m(ExploLogoOnly, { scale: 1.6 })
293
+ )
294
+ )
295
+ };
296
+
297
+ // A nice graphical toggler control
298
+
299
+ interface ITogglerAttributes {
300
+ id: string;
301
+ state: boolean;
302
+ tabindex: number;
303
+ opt1: VnodeChildren;
304
+ opt2: VnodeChildren;
305
+ funcToggle?: (state: boolean) => void;
306
+ small?: boolean;
307
+ title?: string;
308
+ }
309
+
310
+ export const Toggler: ComponentFunc<ITogglerAttributes> = () => {
311
+
312
+ function doToggle(togglerId: string, funcToggle?: (state: boolean) => void) {
313
+ // Perform the toggling, on a mouse click or keyboard input (space bar)
314
+ const cls1 = document.querySelector("#" + togglerId + " #opt1")?.classList;
315
+ const cls2 = document.querySelector("#" + togglerId + " #opt2")?.classList;
316
+ cls1 && cls1.toggle("selected");
317
+ cls2 && cls2.toggle("selected");
318
+ if (funcToggle !== undefined && cls2)
319
+ // Toggling the switch and we have an associated function:
320
+ // call it with the boolean state of the switch
321
+ funcToggle(cls2.contains("selected"));
322
+ }
323
+
324
+ return {
325
+ view: ({attrs: { id, small, funcToggle, state, tabindex, title, opt1, opt2 }}) => {
326
+ const togglerId = id + "-toggler";
327
+ const optionClass = ".option" + (small ? ".small" : "");
328
+ return m.fragment({}, [
329
+ m("input.checkbox." + id,
330
+ {
331
+ type: "checkbox",
332
+ id: id,
333
+ name: id,
334
+ checked: state,
335
+ value: 'True'
336
+ }
337
+ ),
338
+ m(".toggler",
339
+ {
340
+ id: togglerId,
341
+ tabindex,
342
+ title,
343
+ onclick: (ev: Event) => {
344
+ doToggle(togglerId, funcToggle);
345
+ ev.preventDefault();
346
+ },
347
+ onkeypress: (ev: KeyboardEvent) => {
348
+ if (ev.key == " ") {
349
+ doToggle(togglerId, funcToggle);
350
+ ev.preventDefault();
351
+ }
352
+ }
353
+ },
354
+ [
355
+ m(optionClass + (state ? "" : ".selected"), { id: "opt1" }, opt1),
356
+ m(optionClass + (state ? ".selected" : ""), { id: "opt2" }, opt2)
357
+ ]
358
+ )
359
+ ]);
360
+ }
361
+ };
362
+ };
363
+
364
+ export const TogglerReady: ComponentFunc<{ view: IView }> = (initialVnode) => {
365
+ // Toggle on left-hand side of main screen:
366
+ // User ready and willing to accept challenges
367
+
368
+ const outerView = initialVnode.attrs.view;
369
+ const model = outerView.model;
370
+
371
+ function toggleFunc(state: boolean) {
372
+ if (model.state) {
373
+ model.state.ready = state;
374
+ model.setUserPref({ ready: state });
375
+ }
376
+ }
377
+
378
+ return {
379
+ view: () => m(Toggler, {
380
+ id: "ready",
381
+ state: model.state?.ready ?? false,
382
+ tabindex: 2,
383
+ opt1: nbsp(),
384
+ opt2: glyph("thumbs-up"),
385
+ funcToggle: toggleFunc,
386
+ small: true,
387
+ title: ts("Tek við áskorunum!"),
388
+ })
389
+ };
390
+ };
391
+
392
+ export const TogglerReadyTimed: ComponentFunc<{ view: IView }> = (initialVnode) => {
393
+ // Toggle on left-hand side of main screen:
394
+ // User ready and willing to accept timed challenges
395
+
396
+ const outerView = initialVnode.attrs.view;
397
+ const model = outerView.model;
398
+
399
+ function toggleFunc(state: boolean) {
400
+ if (model.state) {
401
+ model.state.readyTimed = state;
402
+ model.setUserPref({ ready_timed: state });
403
+ }
404
+ }
405
+
406
+ return {
407
+ view: () => m(Toggler, {
408
+ id: "timed",
409
+ state: model.state?.readyTimed ?? false,
410
+ tabindex: 3,
411
+ opt1: nbsp(),
412
+ opt2: glyph("time"),
413
+ funcToggle: toggleFunc,
414
+ small: true,
415
+ title: ts("Til í viðureign með klukku!"),
416
+ })
417
+ };
418
+ };
419
+
420
+ interface IBooleanAttributes {
421
+ view: IView;
422
+ state: boolean;
423
+ tabindex: number;
424
+ }
425
+
426
+ export const TogglerAudio: ComponentFunc<IBooleanAttributes> = () => {
427
+ // Toggle for audio on/off
428
+
429
+ function toggleFunc(state: boolean) {
430
+ if (state) playAudio("your-turn");
431
+ }
432
+
433
+ return {
434
+ view: ({attrs: { state, tabindex }}) => m(Toggler, {
435
+ id: "audio",
436
+ state,
437
+ tabindex,
438
+ opt1: glyph("volume-off"),
439
+ opt2: glyph("volume-up"),
440
+ funcToggle: toggleFunc,
441
+ small: true,
442
+ title: ts("Hljóð á/af"),
443
+ })
444
+ };
445
+ };
446
+
447
+ export const TogglerFanfare: ComponentFunc<IBooleanAttributes> = () => {
448
+ // Toggle for fanfare on/off
449
+
450
+ function toggleFunc(state: boolean) {
451
+ if (state) playAudio("you-win");
452
+ }
453
+
454
+ return {
455
+ view: ({attrs: { state, tabindex }}) => m(Toggler, {
456
+ id: "fanfare",
457
+ state,
458
+ tabindex,
459
+ opt1: glyph("volume-off"),
460
+ opt2: glyph("volume-up"),
461
+ funcToggle: toggleFunc,
462
+ small: true,
463
+ title: ts("Lúðraþytur á/af"),
464
+ })
465
+ };
466
+ };
467
+
468
+ export const TogglerBeginner: ComponentFunc<IBooleanAttributes> = () => {
469
+ // Toggle for beginner mode, on/off
470
+
471
+ return {
472
+ view: ({attrs: { state, tabindex }}) => m(Toggler, {
473
+ id: "beginner",
474
+ state,
475
+ tabindex,
476
+ opt1: nbsp(),
477
+ opt2: glyph("ok"),
478
+ small: true,
479
+ })
480
+ };
481
+ };
482
+
483
+ export const TogglerFairplay: ComponentFunc<IBooleanAttributes> = () => {
484
+ // Toggle for fairplay setting, on/off
485
+
486
+ return {
487
+ view: ({attrs: { state, tabindex }}) => m(Toggler, {
488
+ id: "fairplay",
489
+ state,
490
+ tabindex,
491
+ opt1: nbsp(),
492
+ opt2: glyph("edit"),
493
+ small: true,
494
+ })
495
+ };
496
+ };
@@ -0,0 +1,219 @@
1
+ /*
2
+
3
+ Dragdrop.ts
4
+
5
+ Single page UI for Explo using the Mithril library
6
+
7
+ Copyright (C) 2025 Miðeind ehf.
8
+ Author: Vilhjálmur Þorsteinsson
9
+
10
+ The Creative Commons Attribution-NonCommercial 4.0
11
+ International Public License (CC-BY-NC 4.0) applies to this software.
12
+ For further information, see https://github.com/mideind/Netskrafl
13
+
14
+ This module implements drag-and-drop functionality for rack tiles,
15
+ using custom handlers for mouse and touch events. Standard HTML5
16
+ drag-and-drop does not offer enough flexibility for our requirements.
17
+
18
+ */
19
+
20
+ interface Point {
21
+ x: number;
22
+ y: number;
23
+ }
24
+
25
+ type DropHandler = (draggedTile: HTMLElement, dropTarget: HTMLElement) => void;
26
+
27
+ class DragManager {
28
+
29
+ // Global drag/drop state. Note that only one drag/drop operation
30
+ // can be active at each time.
31
+
32
+ draggedElement: HTMLElement;
33
+ parentElement: HTMLElement | null = null;
34
+ parentRect: DOMRect | null = null;
35
+ offsetX: number = 0;
36
+ offsetY: number = 0;
37
+ centerX: number = 0;
38
+ centerY: number = 0;
39
+ lastX: number = 0;
40
+ lastY: number = 0;
41
+ currentDropTarget: HTMLElement | null = null;
42
+ boundEventHandlers: { [key: string]: (e: MouseEvent | TouchEvent) => void };
43
+ dropHandler: DropHandler;
44
+
45
+ getEventCoordinates(e: MouseEvent | TouchEvent): Point {
46
+ if (e instanceof MouseEvent) {
47
+ return { x: e.clientX, y: e.clientY };
48
+ }
49
+ if (e instanceof TouchEvent) {
50
+ const touch = e.touches[0];
51
+ return touch ? { x: touch.clientX, y: touch.clientY } : { x: this.lastX, y: this.lastY };
52
+ }
53
+ return { x: 0, y: 0 };
54
+ }
55
+
56
+ static rectContains(rect: DOMRect, p: Point): boolean {
57
+ return (p.x >= rect.left) && (p.x < rect.right) && (p.y >= rect.top) && (p.y < rect.bottom);
58
+ }
59
+
60
+ constructor(e: MouseEvent | TouchEvent, dropHandler: DropHandler) {
61
+ e.preventDefault();
62
+ // Note: We use e.currentTarget here, not e.target, as we may be
63
+ // handling events that were originally targeted at a child element
64
+ // (such as the letter or score elements within a tile).
65
+ const dragged = e.currentTarget as HTMLElement;
66
+ const coords = this.getEventCoordinates(e);
67
+ this.lastX = coords.x;
68
+ this.lastY = coords.y;
69
+ this.draggedElement = dragged;
70
+ this.dropHandler = dropHandler;
71
+ this.parentElement = dragged.parentElement;
72
+ this.parentRect = this.parentElement?.getBoundingClientRect() ?? null;
73
+ // Find out the bounding rectangle of the element
74
+ // before starting to apply modifications
75
+ const rect = dragged.getBoundingClientRect();
76
+ const { offsetWidth: originalWidth, offsetHeight: originalHeight } = dragged;
77
+ // Add a class to the dragged element to indicate that it is being dragged.
78
+ // This may change its size and appearance.
79
+ dragged.classList.add("dragging");
80
+ // Make the dragged element an immediate child of the document body,
81
+ // allowing it to be dragged anywhere on the screen and not subject to
82
+ // clipping (overflow: hidden).
83
+ document.body.appendChild(dragged);
84
+ // Find out the dimensions of the element in its dragged state
85
+ const { offsetWidth, offsetHeight } = dragged;
86
+ // Offset of the click or touch within the dragged element
87
+ this.offsetX = coords.x - rect.left + (offsetWidth - originalWidth) / 2;
88
+ this.offsetY = coords.y - rect.top + (offsetHeight - originalHeight) / 2;
89
+ this.centerX = offsetWidth / 2;
90
+ this.centerY = offsetHeight / 2;
91
+ // Create bound event handlers that properly assign 'this'
92
+ this.boundEventHandlers = {
93
+ drag: this.drag.bind(this),
94
+ endDrag: this.endDrag.bind(this),
95
+ };
96
+ const { drag, endDrag } = this.boundEventHandlers;
97
+ if (e instanceof MouseEvent) {
98
+ document.addEventListener("mousemove", drag);
99
+ document.addEventListener("mouseup", endDrag);
100
+ } else if (e instanceof TouchEvent) {
101
+ document.addEventListener("touchmove", drag, { passive: false });
102
+ document.addEventListener("touchend", endDrag);
103
+ }
104
+ // Do an initial position update, as the size of the dragged element
105
+ // may have changed, and it should remain centered
106
+ this.updatePosition(coords.x, coords.y);
107
+ }
108
+
109
+ removeDragListeners(): void {
110
+ const { drag, endDrag } = this.boundEventHandlers;
111
+ document.removeEventListener("mousemove", drag);
112
+ document.removeEventListener("mouseup", endDrag);
113
+ document.removeEventListener("touchmove", drag);
114
+ document.removeEventListener("touchend", endDrag);
115
+ }
116
+
117
+ elementCenter(clientX: number, clientY: number): Point {
118
+ return {
119
+ x: clientX - this.offsetX + this.centerX,
120
+ y: clientY - this.offsetY + this.centerY,
121
+ };
122
+ }
123
+
124
+ findDropTargetAtPoint(x: number, y: number): HTMLElement | null {
125
+ // Look for a drop target element at the given coordinates.
126
+ // A drop target is identified by the 'drop-target' class.
127
+ // To stop the search at a particular type of element,
128
+ // such as a previously played board tile, mark it with
129
+ // the 'not-target' class.
130
+ const { x: centerX, y: centerY } = this.elementCenter(x, y);
131
+ for (const el of document.elementsFromPoint(centerX, centerY)) {
132
+ if (el.classList.contains("drop-target")) {
133
+ return el as HTMLElement;
134
+ }
135
+ if (el.classList.contains("not-target")) {
136
+ // To stop the search at a particular type of element,
137
+ // such as a previously played board tile, mark it with
138
+ // the 'not-target' class.
139
+ return null;
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ updatePosition(clientX: number, clientY: number): void {
146
+ this.lastX = clientX;
147
+ this.lastY = clientY;
148
+ this.draggedElement.style.left = `${clientX - this.offsetX}px`;
149
+ this.draggedElement.style.top = `${clientY - this.offsetY}px`;
150
+ }
151
+
152
+ updateDropTarget(x: number, y: number): void {
153
+ const dropTarget = this.findDropTargetAtPoint(x, y);
154
+ if (dropTarget === this.currentDropTarget) return;
155
+ if (this.currentDropTarget) {
156
+ this.currentDropTarget.classList.remove("over");
157
+ }
158
+ if (dropTarget) {
159
+ // The element is droppable here
160
+ dropTarget.classList.add("over");
161
+ }
162
+ this.currentDropTarget = dropTarget;
163
+ }
164
+
165
+ drag(e: MouseEvent | TouchEvent): void {
166
+ e.preventDefault();
167
+ const coords = this.getEventCoordinates(e);
168
+ // Update position for both mouse and touch events
169
+ this.updatePosition(coords.x, coords.y);
170
+ this.updateDropTarget(coords.x, coords.y);
171
+ // If a drop is not allowed, add the "no-drop" class
172
+ // to the dragged element. However, a hack applies:
173
+ // if the dragged element is still over its original
174
+ // parent element, we show the drop as allowed in any case.
175
+ const allowDrop = (
176
+ (this.currentDropTarget !== null) ||
177
+ (this.parentRect && DragManager.rectContains(this.parentRect, coords))
178
+ );
179
+ this.draggedElement.classList.toggle(
180
+ "no-drop",
181
+ !allowDrop,
182
+ );
183
+ }
184
+
185
+ resetPosition(): void {
186
+ this.draggedElement.style.left = "";
187
+ this.draggedElement.style.top = "";
188
+ this.draggedElement.classList.remove("dragging", "no-drop");
189
+ if (this.parentElement) {
190
+ // Restore the original parent of the dragged element
191
+ this.parentElement.appendChild(this.draggedElement);
192
+ }
193
+ }
194
+
195
+ endDrag(e: MouseEvent | TouchEvent): void {
196
+ e.preventDefault();
197
+ const coords = this.getEventCoordinates(e);
198
+ const dropTarget = this.findDropTargetAtPoint(coords.x, coords.y);
199
+ if (this.currentDropTarget) {
200
+ this.currentDropTarget.classList.remove("over");
201
+ }
202
+ this.removeDragListeners();
203
+ // Avoid flicker by hiding the element while we are manipulating its
204
+ // position in the DOM tree and completing the drop operation
205
+ this.draggedElement.style.visibility = "hidden";
206
+ this.resetPosition();
207
+ if (dropTarget) {
208
+ // Complete the drop operation by calling the provided handler
209
+ this.dropHandler(this.draggedElement, dropTarget);
210
+ }
211
+ setTimeout(() => {
212
+ this.draggedElement.style.visibility = "visible";
213
+ });
214
+ }
215
+ }
216
+
217
+ export const startDrag = (e: MouseEvent | TouchEvent, dropHandler: DropHandler): void => {
218
+ new DragManager(e, dropHandler);
219
+ };