@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.
- package/.eslintignore +8 -0
- package/.eslintrc.json +13 -0
- package/README.md +63 -0
- package/dist/cjs/index.css +6837 -0
- package/dist/cjs/index.js +3046 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.css +6837 -0
- package/dist/esm/index.js +3046 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +63 -0
- package/rollup.config.js +60 -0
- package/src/components/index.ts +2 -0
- package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
- package/src/components/netskrafl/Netskrafl.tsx +135 -0
- package/src/components/netskrafl/Netskrafl.types.ts +7 -0
- package/src/components/netskrafl/index.ts +2 -0
- package/src/css/fonts.css +4 -0
- package/src/css/glyphs.css +224 -0
- package/src/css/skrafl-explo.css +6616 -0
- package/src/fonts/glyphicons-regular.eot +0 -0
- package/src/fonts/glyphicons-regular.ttf +0 -0
- package/src/fonts/glyphicons-regular.woff +0 -0
- package/src/index.ts +2 -0
- package/src/messages/messages.json +1576 -0
- package/src/mithril/actions.ts +319 -0
- package/src/mithril/bag.ts +65 -0
- package/src/mithril/bestdisplay.ts +74 -0
- package/src/mithril/blankdialog.ts +94 -0
- package/src/mithril/board.ts +336 -0
- package/src/mithril/buttons.ts +303 -0
- package/src/mithril/challengedialog.ts +186 -0
- package/src/mithril/channel.ts +162 -0
- package/src/mithril/chat.ts +228 -0
- package/src/mithril/components.ts +496 -0
- package/src/mithril/dragdrop.ts +219 -0
- package/src/mithril/elopage.ts +180 -0
- package/src/mithril/friend.ts +227 -0
- package/src/mithril/game.ts +1378 -0
- package/src/mithril/gameview.ts +111 -0
- package/src/mithril/globalstate.ts +33 -0
- package/src/mithril/i18n.ts +186 -0
- package/src/mithril/localstorage.ts +133 -0
- package/src/mithril/login.ts +122 -0
- package/src/mithril/logo.ts +270 -0
- package/src/mithril/main.ts +737 -0
- package/src/mithril/mithril.ts +29 -0
- package/src/mithril/model.ts +817 -0
- package/src/mithril/movelistitem.ts +226 -0
- package/src/mithril/page.ts +852 -0
- package/src/mithril/playername.ts +91 -0
- package/src/mithril/promodialog.ts +82 -0
- package/src/mithril/recentlist.ts +148 -0
- package/src/mithril/request.ts +52 -0
- package/src/mithril/review.ts +634 -0
- package/src/mithril/rightcolumn.ts +398 -0
- package/src/mithril/searchbutton.ts +118 -0
- package/src/mithril/statsdisplay.ts +109 -0
- package/src/mithril/tabs.ts +169 -0
- package/src/mithril/tile.ts +145 -0
- package/src/mithril/twoletter.ts +76 -0
- package/src/mithril/types.ts +379 -0
- package/src/mithril/userinfodialog.ts +171 -0
- package/src/mithril/util.ts +304 -0
- package/src/mithril/wait.ts +246 -0
- package/src/mithril/wordcheck.ts +102 -0
- package/tsconfig.json +28 -0
- 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
|
+
};
|