-
Notifications
You must be signed in to change notification settings - Fork 0
/
jigsaw.js
1856 lines (1606 loc) · 69.1 KB
/
jigsaw.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Priority TODOs
// * Code cleanup - ES6 pass and split out sections to different files where possible. This file is getting too large.
// * Icons for main menu buttons (create buzzle, create folder, delete, return home, move puzzle home) to go with the text
// * Create a help menu (describes controls; drag and drop and mouse/keys for when playing)
// * Auto save board state for continuing later
// * Create a first pass at stat tracking
// * Add handling of filesystem.js exceptions so that menu's continue to load if a file is missing and that an error is raised if a failure happens saving any of the images during puzzle creation
// * Add a Rename button
// * Advanced play options - auto-sorting by shape/color onto board or into buckets
// * Add loading message for puzzle creation (and maybe other areas like waiting on puzzle images)
// * Simplify/clean up CSS. Like display: individual none/inline's using .remove and .add instead
// * Consider using inline HTML format to create objects instead of separate lines for each attribute.
// Or find ways to reduce attribute setting. Maybe remove IDs that are identical to class name and look up object by class
// * Puzzle piece CSS; Look into custom filter to warp the piece image around the edge
// * Work more on pan aspect ratio. Consider if it should be based on board instead of puzzle image
// * Puzzle buckets to sort pieces into. Common with doing physical puzzles, but have limited necessity in digital due to ease of stacking pieces vertically
// Tracks the active folder for the puzzle menu
var ACTIVE_FID = "root";
// Tracks the singleton FabricJS canvas object used for rendering the play board
var BOARD;
// Tracks the singleton image cropper object used for creating new puzzles
var CROPPER;
// Tracks the difficulty labels
const DIFFICULTIES = [
"Have a moment",
"Commercial break",
"Quick jaunt",
"Break time",
"Lazy afternoon",
"Let's get serious",
"Up all night",
"Eat, sleep, jigsaw, repeat"
];
window.onresize = function() {
if (CROPPER) {
// Reset the cropper positioning on window resize since the image may scale
// NOTE: Does not appear a debounce is needed for performance, but keep an eye on it
CROPPER.reset();
}
if (BOARD) {
BOARD.setWidth(window.innerWidth - 20);
BOARD.setHeight(window.innerHeight - 20);
if (isOverlayCover()) {
document.getElementById("overlayCover").click();
}
}
}
async function loadMainMenu() {
// Clear the puzzles first, as this may be a reload
var mainMenu = document.getElementById("mainMenu");
var menuItems = mainMenu.getElementsByClassName("menuItem")
while (menuItems.length > 0) {
mainMenu.removeChild(menuItems[menuItems.length - 1]);
}
// Load the saved puzzles
let puzzles = getPuzzles(ACTIVE_FID);
for (const [key, value] of Object.entries(puzzles).sort(sortPuzzles)) {
await loadMenuItem(key, value["title"] || value);
}
// Folder button text update
let text = (ACTIVE_FID != "root" ? "Return Home" : "Create Folder");
document.getElementById("createFolderButton").innerText = text;
// Refresh estimated storage usage
refreshStorageUsage();
}
// Sort folders before puzzles, then alpha order puzzle title and folder name
// @param [aK, aV] - [string, string] - [puzzle/folder ID, title]
// @param [bK, bV] - [string, string] - [puzzle/folder ID, title]
function sortPuzzles([aK,aV],[bK,bV]) {
// First character of key is 'f' for folder or 'p' for puzzle
if (aK.charAt(0) < bK.charAt(0)) {
return -1;
} else if (aK.charAt(0) > bK.charAt(0)) {
return 1;
}
if (aK.charAt(0) == "p") {
return aV["title"].localeCompare(bV["title"]);
}
return aV.localeCompare(bV);
}
// @param menuItem - DOM object - menu item DIV
// @param event - DOM Event
function menuItemClick(menuItem, event){
audio('click');
// If in delete mode, toggle checked and disable dragging
let checkbox = menuItem.querySelector(".menuItemDelete");
if (checkbox.checkVisibility()) {
checkbox.checked = !checkbox.checked;
checkbox.checked ? menuItem.classList.add("menuItemChecked") : menuItem.classList.remove("menuItemChecked");
// Update delete button text
let deleteButton = document.getElementById("deleteButton");
const count = document.querySelectorAll(".menuItemChecked").length;
deleteButton.innerText = "Delete (" + count + ")";
return;
}
if (menuItem.id.startsWith("f")) {
ACTIVE_FID = menuItem.id;
loadMainMenu();
} else {
// TODO: If this puzzle has a saved game, ask if they want to start it instead of showing the overlay
loadPlayOverlay(menuItem, event);
}
}
// @param menuItem - DOM object - menu item DIV
function menuItemDropTarget(menuItem) {
menuItem.addEventListener("dragover", function(event){
event.preventDefault();
menuItem.classList.add("dropTargetHighlight");
});
menuItem.addEventListener("dragleave", function(){
menuItem.classList.remove("dropTargetHighlight");
});
menuItem.addEventListener("drop", function(event){
event.preventDefault();
var pid = event.dataTransfer.getData("text");
movePuzzle(ACTIVE_FID, event.target.id, pid);
document.getElementById(pid).remove();
menuItem.classList.remove("dropTargetHighlight");
});
}
// @param menuItem - DOM object - menu item DIV
function menuItemDragTarget(menuItem) {
menuItem.addEventListener("dragstart", function(event){
event.dataTransfer.setData("text", event.target.id);
this.classList.add("dragging");
if (ACTIVE_FID != "root") {
// Turn the folder button into a drop point for moving a puzzle home
let createFolderButton = document.getElementById("createFolderButton");
createFolderButton.innerText = "Move Puzzle to Home";
createFolderButton.ondragover = function(event){
event.preventDefault();
createFolderButton.classList.add("dropTargetHighlight");
};
createFolderButton.ondragleave = function(event){
createFolderButton.classList.remove("dropTargetHighlight");
}
createFolderButton.ondrop = function(event){
event.preventDefault();
var pid = event.dataTransfer.getData("text");
movePuzzle(ACTIVE_FID, "root", pid);
document.getElementById(pid).remove();
createFolderButton.classList.remove("dropTargetHighlight");
};
}
});
menuItem.addEventListener("dragend", function(){
this.classList.remove("dragging");
if (ACTIVE_FID != "root") {
// Turn the folder button back into return home
let createFolderButton = document.getElementById("createFolderButton");
createFolderButton.innerText = "Return Home";
createFolderButton.ondragover = null;
createFolderButton.ondragleave = null;
createFolderButton.ondrop = null;
}
});
}
// @param id - string - Puzzle or folder ID
// @param title - string - Title of the stored puzzle or folder
async function loadMenuItem(id, title) {
var isFolder = id.startsWith("f");
// Create menu item container from the clone template
var menuItem = window.document.getElementById("menuItemClone").cloneNode(true);
menuItem.draggable = !isFolder;
menuItem.id = id;
if (isFolder) {
menuItemDropTarget(menuItem);
} else {
menuItemDragTarget(menuItem);
}
menuItem.querySelector(".menuItemTitle").textContent = title;
// If the item is a folder, show the folder image. Otherwise show the custom puzzle portrait
if (isFolder) {
menuItem.style.backgroundImage = "url('assets/folder.png')";
} else {
const filename = id + "preview.png";
const png = await readFile("puzzles", filename);
menuItem.style.backgroundImage = "url('" + URL.createObjectURL(png) + "')";
}
// Add menu item to menu grid
var mainMenu = document.getElementById("mainMenu");
mainMenu.appendChild(menuItem);
}
// @param buttonId - string - ID of the DOM object button
// @param textField - DOM object - INPUT text field with name of puzzle of folder
// @param event - DOM Event
function checkButtonStatus(buttonId, textField, event) {
// Disable passed button when no name is provided
var button = document.getElementById(buttonId);
button.disabled = textField.value.trim().length == 0;
// Auto submit on enter key
if (event.key === 'Enter') {
button.click();
}
}
function makeFolder() {
let fid = getAndIncrementNextFolderID();
var name = document.getElementById("createFolderName").value.trim();
addFolder(fid, name);
// TODO: Consider manually inserting the new DOM folder instead of reloading them all.
// Need to determine where to insert it for alpha order of folders
loadMainMenu();
closeFolderOverlay();
}
function closeFolderOverlay() {
// Hide overlays
document.getElementById("createFolderOverlay").classList.add("remove");
hideOverlayCover();
// Reset name component
document.getElementById("createFolderName").value = "";
}
function createFolder() {
// Show overlays
showOverlayCover(closeFolderOverlay);
document.getElementById("createFolderOverlay").classList.remove("remove");
// Change focus to the name input
setTimeout(function() { document.getElementById("createFolderName").focus(); }, 50);
}
async function returnHome() {
ACTIVE_FID = "root";
await loadMainMenu();
}
function deletePuzzlesMode() {
// Update menu buttons
document.getElementById("createPuzzleButton").disabled = true;
document.getElementById("createFolderButton").disabled = true;
document.getElementById("deleteButton").innerText = "Delete (0)";
document.getElementById("cancelButton").classList.remove("hidden");
// Show the checkboxes
let checkboxes = document.getElementsByClassName("menuItemDelete");
for (let checkbox of checkboxes) {
checkbox.style.display = "inline";
}
// Disable dragging for puzzles
let items = document.getElementsByClassName("menuItem");
for (let item of items) {
if (item.id.charAt(0) == "p") {
item.draggable = false;
}
}
}
function deletePuzzles() {
let itemsToDelete = document.querySelectorAll(".menuItemChecked");
if (confirm("Are you sure you want to delete " + itemsToDelete.length + " entries?") === true) {
// Delete chosen folders/puzzles, their related image files, and remove them from the menu
for (let item of itemsToDelete) {
let menuItem = item.closest(".menuItem");
if (menuItem.id.charAt(0) == "f") {
let puzzles = getPuzzles(menuItem.id);
for (const id of Object.keys(puzzles)) {
deleteFile("puzzles", id + ".png");
deleteFile("puzzles", id + "preview.png");
deletePuzzle(menuItem.id, id);
}
deleteFolder(menuItem.id);
} else {
deleteFile("puzzles", menuItem.id + ".png");
deleteFile("puzzles", menuItem.id + "preview.png");
deletePuzzle(ACTIVE_FID, menuItem.id);
}
menuItem.remove();
}
deletePuzzlesCancel();
}
}
function deletePuzzlesCancel() {
// Update menu buttons
document.getElementById("createPuzzleButton").disabled = false;
document.getElementById("createFolderButton").disabled = false;
document.getElementById("deleteButton").innerText = "Delete";
document.getElementById("cancelButton").classList.add("hidden");
// Hide the checkboxes
let checkboxes = document.getElementsByClassName("menuItemDelete");
for (let checkbox of checkboxes) {
checkbox.style.display = "none";
}
// Enable dragging for puzzles
let items = document.getElementsByClassName("menuItem");
for (let item of items) {
if (item.id.charAt(0) == "p") {
item.draggable = true;
}
}
}
// @param difficulty - DOM object - DIV representing user selected difficulty
function playOverlayDifficultyClick(difficulty) {
audio('click');
let selected = difficulty.parentElement.querySelector(".playOverlayDifficultySelected");
if (selected && difficulty != selected) {
selected.classList.remove("playOverlayDifficultySelected");
}
difficulty.classList.add("playOverlayDifficultySelected");
document.getElementById("playButton").disabled = false;
}
function playOverlayOrientationInfoClick() {
const msg = "Piece orientation refers to how the pieces will be laid out on the board." +
"<ul>" +
"<li><font class='orientationOption0'>In standard mode</font>, pieces will always be oriented in the correct direction. A flat edge at the top of the piece is a top edge piece.</li>" +
"<li><font class='orientationOption1'>In intermediate mode</font>, pieces can be oriented two ways (north, south). A flat edge at the top of the piece could be a top or bottom edge piece. A flat edge on the left side is a left edge piece.</li>" +
"<li><font class='orientationOption2'>In advanced mode</font>, pieces can be oriented in four ways (north, south, east, west). A flat edge at the top could be an edge for any side of the puzzle. This best represents physical jigsaw puzzles and will make the puzzle far more challenging.</li></ul>" +
"TIP: To rotate a piece, hover over or pick up the piece. Then hit the [Alt] key to rotate.";
document.getElementById("infoOverlayText").innerHTML = msg;
document.getElementById("infoOverlay").classList.remove("remove");
}
// @param orientationSelect - DOM Object - SELECT representing user selected orientation setting
function playOverlayOrientationSelectChange(orientationSelect) {
audio('click');
let span = orientationSelect.parentElement.querySelector(".playOverlayOrientationSpan");
if (orientationSelect.value != 0) {
span.classList.remove("playOverlayHidden");
span.classList.add("playOverlayVisible");
} else {
span.classList.add("playOverlayHidden");
span.classList.remove("playOverlayVisible");
}
orientationSelect.className = "orientationSelect orientationOption" + orientationSelect.value;
}
// @param playButton - DOM Object - BUTTON starting play of a puzzle
function playOverlayPlayButtonClick(playButton) {
audio('click');
// Capture the user's play selections
let playOverlay = playButton.parentElement;
const puzzleId = playOverlay.id.substring(1);
const difficulty = playOverlay.querySelector(".playOverlayDifficultySelected").id;
const orientation = parseInt(playOverlay.querySelector(".orientationSelect").value);
hideOverlayCover();
playOverlay.remove();
// Transition to the create puzzle page
displayPage("page1", false, false);
setTimeout(function(){
displayPage("page2", true, false);
startPuzzle(puzzleId, difficulty, orientation);
}, 600);
}
function playOverlayCancelButtonClick() {
audio('click');
hideOverlayCover();
document.getElementById("cancelPlayButton").parentElement.remove();
}
// @param menuItem - DOM object - menu item DIV
// @param event - DOM Event
function loadPlayOverlay(menuItem, event) {
// Clone the menuItem to use as the play overlay
// Position it absolute right on top of its parent to start
var playOverlay = menuItem.cloneNode(true);
playOverlay.id = "_" + playOverlay.id; // Assure unique id
playOverlay.className = "playOverlay";
playOverlay.style.backgroundImage = menuItem.style.backgroundImage; // TODO: Is this needed?
playOverlay.style.position = "absolute";
playOverlay.style.left = (event.pageX - event.offsetX) + "px";
playOverlay.style.top = (event.pageY - event.offsetY) + "px";
playOverlay.draggable = false;
playOverlay.onclick=null;
playOverlay.ondragstart=null;
playOverlay.ondragend=null;
// Select for difficulty - # of pieces
let difficulties = window.document.createElement('div');
difficulties.className = "playOverlayDifficulties playOverlayHidden hidden";
let index = 0;
let puzzle = getPuzzle(ACTIVE_FID, menuItem.id);
let availableDifficulties = getDifficulties(puzzle["aspectRatio"]);
for (let [pieces, dimensions] of Object.entries(availableDifficulties)) {
// Create difficulty from the clone template
let difficulty = window.document.getElementById("playOverlayDifficultyClone").cloneNode(true);
difficulty.id = dimensions;
difficulty.querySelector(".difficultyHeader").classList.add("difficulty" + index)
difficulty.querySelector(".difficultyTitle").textContent = pieces + " pieces";
difficulty.querySelector(".difficultySpan").textContent = DIFFICULTIES[index];
difficulties.appendChild(difficulty);
index++;
}
playOverlay.appendChild(difficulties);
// Create orientation container from the clone template
let orientation = window.document.getElementById("playOverlayOrientationClone").cloneNode(true);
orientation.id = "playOverlayOrientation";
playOverlay.appendChild(orientation);
// Play button
var playButton = document.getElementById("playButtonClone").cloneNode(true);
playButton.id = "playButton";
playOverlay.appendChild(playButton);
// Cancel button to exit folder creation
var cancelButton = document.getElementById("cancelButtonClone").cloneNode(true);
cancelButton.id = "cancelPlayButton";
playOverlay.appendChild(cancelButton);
// Render the play overlay
document.body.appendChild(playOverlay);
// Transition to the centered play overlay. Must be delayed for playOverlay to be drawn on screen or animations won't trigger.
setTimeout(function() {
// Bring up the overlay cover
showOverlayCover(playOverlayCancelButtonClick);
// Apply the CSS class to trigger the transition position from menu item to play overlay
playOverlay.classList.add("playOverlayTransition");
// Set background transparency to make play options more visible
playOverlay.style.backgroundImage = "linear-gradient(rgba(255,255,255,0.5), rgba(255,255,255,0.5)), " + playOverlay.style.backgroundImage;
// Make play options visible after the transition animations finish
setTimeout(function() {
// Bring them into the DOM before starting transitions
difficulties.classList.remove("hidden");
orientation.classList.remove("hidden");
playButton.classList.remove("hidden");
cancelButton.classList.remove("hidden");
// Start transitions
difficulties.classList.add("playOverlayVisible");
difficulties.classList.remove("playOverlayHidden");
orientation.classList.add("playOverlayVisible");
orientation.classList.remove("playOverlayHidden");
playButton.classList.add("playOverlayVisible");
playButton.classList.remove("playOverlayHidden");
cancelButton.classList.add("playOverlayVisible");
cancelButton.classList.remove("playOverlayHidden");
}, 1000);
}, 100);
}
function createPuzzle() {
// Prompt user to choose an image
document.getElementById("fileOpener").click();
}
// @param fileOpener - DOM Object - INPUT tracking user selected local puzzle image
function fileOpenerChange(fileOpener) {
if (fileOpener.files.length > 0) {
// Default title to file name
var createPuzzleName = document.getElementById("createPuzzleName");
createPuzzleName.value = formatTitle(fileOpener.files[0].name);
// Populate the image preview
var createPuzzlePreview = document.getElementById("createPuzzlePreview");
createPuzzlePreview.src = URL.createObjectURL(fileOpener.files[0]);
createPuzzlePreview.onload = function() {
// TODO: If image resolution is too small, kick back to main menu page and alert user to choose a different image
// Default to nearest supported aspect ratio of uploaded photo
let aspectRatio = determineAspectRatio({ width: this.width, height: this.height });
document.getElementById("createPuzzleAspectRatio").value = aspectRatio[0] + ":" + aspectRatio[1];
}
// Show the overlays and reset the input
showOverlayCover(closePuzzleOverlay);
document.getElementById("createPuzzleOverlay").classList.remove("remove");
fileOpener.value = '';
// Change focus to the name input
setTimeout(function() { createPuzzleName.focus(); }, 50);
}
}
function createPuzzleStep2() {
// Transition overlay to step 2 (choosing aspect ratio and cropping image)
document.getElementById("createPuzzleOverlay").classList.add("createPuzzleOverlayTransition");
document.getElementById("createPuzzleNameLabel").classList.add("remove");
document.getElementById("createPuzzleTitle").innerText = document.getElementById("createPuzzleName").value.trim();
document.getElementById("createPuzzleAspectRatioWrapper").classList.remove("remove");
document.getElementById("createPuzzlePreview").classList.remove("createPuzzleThumbnail");
document.getElementById("createPuzzlePreview").classList.add("createPuzzlePreview");
document.getElementById("createPuzzleNextButton").classList.add("remove");
document.getElementById("createPuzzleSaveButton").classList.remove("remove");
// Instanciate the image cropper tool
let aspectRatio = document.getElementById("createPuzzleAspectRatio").value.split(":");
aspectRatio = parseInt(aspectRatio[1]) / parseInt(aspectRatio[0]);
cropImage(aspectRatio);
}
// Destroy and recreate the cropper with teh new aspect ratio setting
// @param aspectRatio - string - "x:y"
function changeAspectRatio(aspectRatio) {
aspectRatio = aspectRatio.split(":");
CROPPER.options.aspectRatio = parseInt(aspectRatio[1]) / parseInt(aspectRatio[0]);
CROPPER.reset();
}
// @param aspectRatio - [integer, integer] - [width, height]
function cropImage(aspectRatio) {
CROPPER = new Croppr('#createPuzzlePreview', {
minSize: { width: 175, height: 175 },
aspectRatio: aspectRatio
});
// Due to a bug somewhere in the Croppr lib, a reset after rendering is required to catch the correct sizing of the parent container
setTimeout(function() { CROPPER.reset(); }, 100);
}
function destroyCropper() {
if (CROPPER) {
CROPPER.destroy();
CROPPER = null;
}
}
// TODO: Change this function to be called on initial JS load. Put a warning icon in the top right corner if this function returns false.
// Move the alert text to an onclick of the warning icon.
async function configurePersistedStorage() {
// Check if site's storage has been marked as persistent
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persisted();
if (isPersisted) {
return;
}
const isPersist = await navigator.storage.persist();
if (isPersist) {
return;
}
alert("Detected persisted browser storage is off. Although unlikely, puzzles could be deleted. Export regularly to keep a backup.");
return;
}
}
function refreshStorageUsage() {
navigator.storage.estimate().then((estimate) => {
var percent = ((estimate.usage / estimate.quota) * 100).toFixed(2) + "%";
var quota = (estimate.quota / 1024 / 1024).toFixed(2);
if (quota < 1024) {
quota += "MB";
} else {
quota = (quota / 1024).toFixed(2);
if (quota < 1024) {
quota += "GB";
} else {
quota = (quota / 1024).toFixed(2);
quota += "TB";
}
}
document.getElementById("diskSpaceText").innerText = "Disk space: " + percent + " of " + quota;
});
}
async function makePuzzle() {
// Verify the user provided a name for the puzzle
var createPuzzleName = document.getElementById("createPuzzleName");
if (createPuzzleName.value.trim() == "") {
alert("A name must be provided for the puzzle");
return;
}
// Grab the crop details from the user
var cropSize = CROPPER.getValue();
// TODO: Consider setting a minimum standard for final image size
// Destroy cropper for resource cleanup
destroyCropper();
// Generate the unique puzzle ID
const pid = getAndIncrementNextPuzzleID();
// Generate the cropped puzzle images (Full size and preview)
await generatePuzzleImages(pid, cropSize);
// Save the new puzzle
var title = createPuzzleName.value.trim();
let aspectRatio = document.getElementById("createPuzzleAspectRatio").value.split(":");
aspectRatio =[parseInt(aspectRatio[0]), parseInt(aspectRatio[1])];
savePuzzleFromAttrs(ACTIVE_FID, pid, title, aspectRatio, cropSize.width, cropSize.height);
// Reload the puzzles to include the new entry
await loadMainMenu();
closePuzzleOverlay();
}
// @param dimensions - { width: integer, height: integer }
// @return [integer, integer] - A supported aspect ratio
function determineAspectRatio(dimensions) {
const ratio = dimensions.width / dimensions.height;
// Supported ratios
// 2:3 = 0.66...
// 3:4 = 0.75
// 1:1 = 1
// 4:3 = 1.33...
// 3:2 = 1.5
if (ratio <= .71) {
return [2,3];
} else if (ratio <= .875) {
return [3,4];
} else if (ratio <= 1.17) {
return [1,1];
} else if (ratio <= 1.42) {
return [4,3];
} else {
return [3,2];
}
}
// @param aspectRatio - [integer, integer] - Aspect ratio of chosen puzzle
function getDifficulties(aspectRatio) {
let difficulties = {};
const x = aspectRatio[0];
const y = aspectRatio[1];
// Support for these aspect ratios: 1:1, 3:2, 2:3, 4:3, 3:4
if (x == 1 && y == 1) {
difficulties[64] = "8x8";
difficulties[100] = "10x10";
difficulties[144] = "12x12";
difficulties[256] = "16x16";
difficulties[400] = "20x20";
difficulties[576] = "24x24";
difficulties[784] = "28x28";
difficulties[1024] = "32x32";
} else if (x == 3 && y == 2) {
difficulties[54] = "9x6";
difficulties[96] = "12x8";
difficulties[150] = "15x10";
difficulties[216] = "18x12";
difficulties[294] = "21x14";
difficulties[486] = "27x18";
difficulties[726] = "33x22";
difficulties[1014] = "39x26";
} else if (x == 2 && y == 3) {
difficulties[54] = "6x9";
difficulties[96] = "8x12";
difficulties[150] = "10x15";
difficulties[216] = "12x18";
difficulties[294] = "14x21";
difficulties[486] = "18x27";
difficulties[726] = "22x33";
difficulties[1014] = "26x39";
} else if (x == 4 && y == 3) {
difficulties[48] = "8x6";
difficulties[108] = "12x9";
difficulties[192] = "16x12";
difficulties[300] = "20x15";
difficulties[432] = "24x18";
difficulties[588] = "28x21";
difficulties[768] = "32x24";
difficulties[972] = "36x27";
} else if (x == 3 && y == 4) {
difficulties[48] = "6x8";
difficulties[108] = "9x12";
difficulties[192] = "12x16";
difficulties[300] = "15x20";
difficulties[432] = "18x24";
difficulties[588] = "21x28";
difficulties[768] = "24x32";
difficulties[972] = "27x36";
}
return difficulties;
}
// @param canvas - HTML5 canvas
// @param dir - string - directory to save image in
// @param filename - string - filename to use when saving the image
async function saveCanvasToPng(canvas, dir, filename) {
// Convert canvas to PNG blob
const pngBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
// Create a new filehandle
var newFile = await createFile(dir, filename);
// Write the blob to file
await writeFile(newFile, pngBlob);
}
function closePuzzleOverlay() {
// Hide overlays
document.getElementById("createPuzzleOverlay").classList.add("remove");
hideOverlayCover();
// Destroy cropper for resource cleanup
destroyCropper();
// Reset overlay components (reverse of step 1/2)
document.getElementById("createPuzzleOverlay").classList.remove("createPuzzleOverlayTransition");
document.getElementById("createPuzzleNameLabel").classList.remove("remove");
document.getElementById("createPuzzleName").value = "";
document.getElementById("createPuzzleTitle").innerText = "Name your new puzzle";
document.getElementById("createPuzzleAspectRatioWrapper").classList.add("remove");
document.getElementById("createPuzzlePreview").src = "#";
document.getElementById("createPuzzlePreview").classList.add("createPuzzleThumbnail");
document.getElementById("createPuzzlePreview").classList.remove("createPuzzlePreview");
document.getElementById("createPuzzleNextButton").classList.remove("remove");
document.getElementById("createPuzzleSaveButton").classList.add("remove");
}
// @param title - string - user provided title of puzzle or folder
function formatTitle(title) {
// Trim file type
title = title.substring(0, title.lastIndexOf("."));
// Replace some common filename separator characters with spaces for readability
title = title.replaceAll(/[\_\-\.]/g, " ");
// Proper case
title = title.toLowerCase()
.split(" ")
.map((s) => s.charAt(0).toUpperCase() + s.substring(1))
.join(" ");
// Trim to 20 character limit
title = title.substring(0, 20);
return title.trim();
}
// @param id - string - DOM id
// @param show - boolean - true if the page represented by the pased id should be visible
// @param showHeader - boolean - true if the DOM objects above the page DIV should be visible
function displayPage(id, show, showHeader) {
var page = document.getElementById(id);
if (show) {
page.style.display = "";
}
page.classList.remove(show ? "hidden" : "visible");
page.classList.add(show ? "visible" : "hidden");
// Header objects
let title = document.getElementById("title");
let diskSpace = document.getElementById("diskSpace");
let exp = document.getElementById("export");
let imp = document.getElementById("import");
if (showHeader) {
title.classList.remove("remove");
diskSpace.classList.remove("remove");
exp.classList.remove("remove");
imp.classList.remove("remove");
} else {
title.classList.add("remove");
diskSpace.classList.add("remove");
exp.classList.add("remove");
imp.classList.add("remove");
}
// Wait 550ms for the CSS transition to complete, then run cleanup
if (!show) {
setTimeout(function() { page.style.display = "none"; }, 550);
}
}
// @param pid - integer - puzzle ID
// @param dimensions - {x, y, width, height}
async function generatePuzzleImages(pid, dimensions) {
// Get the user selected image
const image = document.getElementById('createPuzzlePreview');
// Verify passed dimensions against image size
// Image cropper lib appears to have slight inaccuracies, maybe rounding or CSS-based
// If width or height are within 10px of full size, change to full size
if (Math.abs((dimensions.x + dimensions.width) - image.naturalWidth) <= 10) {
dimensions.x = 0;
dimensions.width = image.naturalWidth;
}
if (Math.abs((dimensions.y + dimensions.height) - image.naturalHeight) <= 10) {
dimensions.y = 0;
dimensions.height = image.naturalHeight;
}
// Create a canvas
const canvas = document.createElement('canvas');
// Set the canvas width and height
canvas.width = dimensions.width;
canvas.height = dimensions.height;
// Get the canvas context
const ctx = canvas.getContext('2d');
// Draw the image on the canvas
ctx.drawImage(image, dimensions.x, dimensions.y, dimensions.width, dimensions.height, 0, 0, dimensions.width, dimensions.height);
// Save the canvas as a png image
const png1 = pid + ".png";
await saveCanvasToPng(canvas, "puzzles", png1);
// Set the canvas ratio to 1:1 for preview image
var previewSize = Math.min(dimensions.width, dimensions.height);
canvas.width = previewSize;
canvas.height = previewSize;
// Determine the shift for the larger dimension so the crop happens evenly on each side
var dx, dy;
if (dimensions.width < dimensions.height) {
dx = 0;
dy = (dimensions.height - dimensions.width) / 2;
} else {
dx = (dimensions.width - dimensions.height) / 2;
dy = 0;
}
// Redraw the image for the new scale and source image crop dimensions
ctx.drawImage(image, dimensions.x + dx, dimensions.y + dy, dimensions.width - (dx * 2), dimensions.height - (dy * 2), 0, 0, previewSize, previewSize);
// Save the canvas as a base64 encoded image
const png2 = pid + "preview.png";
await saveCanvasToPng(canvas, "puzzles", png2)
}
function toggleDiskSpaceText() {
var diskSpaceText = document.getElementById("diskSpaceText");
diskSpaceText.style.display = diskSpaceText.style.display !== "inline" ? "inline" : "none";
}
function closeInfoOverlay() {
document.getElementById("infoOverlayText").innerHTML = "";
document.getElementById("infoOverlay").classList.add("remove");
}
// @param onclickFunc - JS function - function to be executed upon overlay cover click
function showOverlayCover(onclickFunc) {
let cover = document.getElementById("overlayCover");
cover.classList.remove("remove");
if (onclickFunc) {
cover.onclick = onclickFunc;
}
}
function hideOverlayCover() {
let cover = document.getElementById("overlayCover");
cover.classList.add("remove");
cover.onclick = null;
}
function isOverlayCover() {
return !document.getElementById("overlayCover").classList.contains("remove");
}
function getRandomColor() {
var letters = '0123456789ABCDEF';
var color = '#';
for (var i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
// @param object - fabric.Object - puzzle piece
// @param pos - string - (tl|tr|br|bl)
function getAbsolutePosition(object, pos) {
if (object.group) {
// Groups use relative positioning for children. Oddly relative to the center of the group, a FabricJS choice.
var matrix = object.group.calcTransformMatrix();
var point = object.getPointByOrigin(pos.charAt(1) == 'l' ? 'left' : 'right', pos.charAt(0) == 't' ? 'top' : 'bottom');
// TODO: Do we need to transformPoint() point by the object.calcTransformMatrix() first? Needs deeper snap testing
var pointOnCanvas = fabric.util.transformPoint(point, matrix);
return pointOnCanvas;
}
return object.aCoords[pos];
}
// Try to keep the board view (BOARD.width/BOARD.height) view within pan boundaries (BOARD.panWidth/BOARD.panHeight).
// At widest zoom levels, depending on the puzzle and window ratios, the width or height may be completely in view.
// If so, balance the "blank" (unusable) space between top/bottom or left/right.
//
// EX: If zooming out while mouse is in the [0,0] corner, user should never see to the left or
// above [0,0]. So adjust the "center" of the view to keep the edge at [0,0] as long as possible.
// At zoom levels showing entire width or height, we will start showing below [0,0].
// @param event - Event
function respectBoardPanBoundaries(event) {
// var vpt = this.viewportTransform;
// var vpb = BOARD.calcViewportBoundaries();
// var pAbs = BOARD.getPointer(event, true);
// var pRel = BOARD.getPointer(event, false);
// vpt[4] = x coord
// vpt[5] = y coord
// BOARD.backgroundImage = the "pan board" rectangle, maybe its coords can help determine if shifting x/y is needed
// TODO: Determine if any visible point of the board is outside BOARD.panWidth/BOARD.panHeight
}
// @param zoom - number - zoom value looking to be set
function respectZoomMinMax(zoom) {
let puzzle = BOARD.puzzle;
let ratio = Math.sqrt((puzzle.width * puzzle.height) / (BOARD.getWidth() * BOARD.getHeight()));
let min = .25/ratio;
let max = 3/ratio;
if (zoom > max) {
zoom = max;
} else if (zoom < min) {
zoom = min;
}
return zoom;
}
// Decimal percent to zoom, must be between .25 and 3
// @param percent - number
function zoomTo(percent) {
let puzzle = BOARD.puzzle;
let ratio = Math.sqrt((puzzle.width * puzzle.height) / (BOARD.getWidth() * BOARD.getHeight()));
let zoom = percent/ratio;
return respectZoomMinMax(zoom);
}
function ctrlSelectionDrop() {
if (BOARD.ctrlSelectionObjects.length > 0) {
// Drop the selected pieces
audio('down');
for (let obj of BOARD.ctrlSelectionObjects) {
let pieces = (obj.isType('path') ? [obj] : obj.getObjects());
for (let piece of pieces) {
piece.set('stroke', piece._stroke);
piece.set('strokeWidth', piece._strokeWidth);
piece._stroke = undefined;
piece._strokeWidth = undefined;
}
//snapPathOrGroup(obj); // TODO: Should we do this like we do for single drop? Could trigger multiple snap sounds
}
BOARD.renderAll();
}
BOARD.ctrlSelection = false;
BOARD.ctrlSelectionObjects = [];
}
// @param path - fabric.Path - the piece being zoomed into
// @param curValue - number - the animated path attribute's current value
function setZoomPosition(path, curValue) {
// Center the piece as zoom occurs
let per = (curValue - path._zoomScale)/(path._maxScale - path._zoomScale);
let endX = BOARD.getVpCenter().x - (path.getScaledWidth() / 2);
let endY = BOARD.getVpCenter().y - (path.getScaledHeight() / 2);
if ((path.angle) % 360 == 0) {
// Nothing additional to do
} else if ((path.angle) % 360 == 90) {
endX += path.getScaledWidth();
} else if ((path.angle) % 360 == 180) {
endX += path.getScaledWidth();
endY += path.getScaledHeight();
} else { // 270
endY += path.getScaledHeight();
}