· 6 years ago · Mar 06, 2020, 02:12 PM
1// Utility
2var utils = (() => {
3 function dom (selector) {
4 if (selector[0] === '#') {
5 return document.getElementById(selector.slice(1))
6 }
7 return document.querySelectorAll(selector)
8 }
9
10 function copyJSON (obj) {
11 return JSON.parse(JSON.stringify(obj))
12 }
13
14 function isTouchDevice () {
15 return navigator.userAgent
16 .match(/(iPhone|iPod|iPad|Android|BlackBerry)/)
17 }
18
19 function getWorkerURLFromElement(selector) {
20 var element = dom(selector)
21 var content = babel.transform(element.innerText).code
22 var blob = new Blob([content], {type: 'text/javascript'})
23 return URL.createObjectURL(blob)
24 }
25
26// Will be used for restoring caret positions on rerenders.
27// Taken from:
28// http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
29 var cursorManager = (function () {
30 var cursorManager = {}
31
32 var voidNodeTags = [
33 'AREA', 'BASE', 'BR', 'COL', 'EMBED',
34 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK',
35 'MENUITEM', 'META', 'PARAM', 'SOURCE',
36 'TRACK', 'WBR', 'BASEFONT', 'BGSOUND',
37 'FRAME', 'ISINDEX'
38 ];
39
40 Array.prototype.contains = function(obj) {
41 var i = this.length;
42 while (i--) {
43 if (this[i] === obj) {
44 return true;
45 }
46 }
47 return false;
48 }
49
50 function canContainText(node) {
51 if(node.nodeType == 1) {
52 return !voidNodeTags.contains(node.nodeName);
53 } else {
54 return false;
55 }
56 };
57
58 function getLastChildElement(el){
59 var lc = el.lastChild;
60 while(lc && lc.nodeType != 1) {
61 if(lc.previousSibling)
62 lc = lc.previousSibling;
63 else
64 break;
65 }
66 return lc;
67 }
68 cursorManager.setEndOfContenteditable = function(contentEditableElement) {
69
70 while(getLastChildElement(contentEditableElement) &&
71 canContainText(getLastChildElement(contentEditableElement))) {
72 contentEditableElement = getLastChildElement(contentEditableElement);
73 }
74
75 var range,selection;
76 if(document.createRange) {
77 range = document.createRange();
78 range.selectNodeContents(contentEditableElement);
79 range.collapse(false);
80 selection = window.getSelection();
81 selection.removeAllRanges();
82 selection.addRange(range);
83 }
84 else if(document.selection)
85 {
86 range = document.body.createTextRange();
87 range.moveToElementText(contentEditableElement);
88 range.collapse(false);
89 range.select();
90 }
91 }
92
93 return cursorManager
94 })()
95
96 return {
97 copyJSON, cursorManager, dom,
98 getWorkerURLFromElement, isTouchDevice
99 }
100})();
101
102
103// API Adapter
104class SudokuAdapter {
105 constructor(url) {
106 this.worker = new Worker(url)
107 return this
108 }
109
110 _postMessage(options) {
111 this.worker.postMessage(JSON.stringify(options))
112 return new Promise((resolve, reject) => {
113 this.worker.onmessage = event => {
114 resolve(event.data)
115 }
116 })
117 }
118
119 generate(options) {
120 options = Object.assign
121 ({}, options, { method: 'generate' })
122
123 return this._postMessage(options)
124 }
125
126 validate(options) {
127 options = Object.assign
128 ({}, options, { method: 'validate' })
129
130 return this._postMessage(options)
131 }
132}
133
134
135// Client Side Settings
136const SUDOKU_APP_CONFIG = {
137 HINTS: 34,
138 TRY_LIMIT: 100000,
139 WORKER_URL: utils.getWorkerURLFromElement('#worker'),
140 DOM_TARGET: utils.dom('#sudoku-app')
141}
142
143
144// Client Side
145var SudokuApp = (config => {
146 const {
147 HINTS, TRY_LIMIT,
148 WORKER_URL, DOM_TARGET
149 } = config
150
151 var sudokuAdapter = new SudokuAdapter(WORKER_URL)
152
153 var state = {
154 success: null,
155 board: null,
156 solution: null,
157 solved: null,
158 errors: []
159 };
160 Object.observe(state, render)
161
162 var history = [state]
163 var historyStash = []
164
165
166 // Event listeners
167 var onClickGenerate = initialize
168
169 var onClickSolve = function () {
170 setState({
171 board: state.solution,
172 solved: true,
173 errors: []
174 })
175 }
176
177 var onKeyUpCell = function (event) {
178 var key = event.keyCode
179 if ( // a
180 key === 36 || // r
181 key === 37 || // r
182 key === 38 || // o
183 key === 39 || // w
184 key === 9 || // tab
185 // mod key flags are always false in keyup event
186 // keyIdentifier doesn't seem to be implemented
187 // in all browsers
188 key === 17 || // Control
189 key === 16 || // Shift
190 key === 91 || // Meta
191 key === 19 || // Alt
192 event.keyIdentifier === 'Control' ||
193 event.keyIdentifier === 'Shift' ||
194 event.keyIdentifier === 'Meta' ||
195 event.keyIdentifier === 'Alt'
196 ) return
197
198 var cell = event.target
199 var value = cell.innerText
200
201 if (value.length > 4) {
202 cell.innerText = value.slice(0, 4)
203 return false
204 }
205
206 var cellIndex = cell.getAttribute('data-cell-index')
207 cellIndex = parseInt(cellIndex, 10)
208 var rowIndex = Math.floor(cellIndex / 9)
209 var cellIndexInRow = cellIndex - (rowIndex * 9)
210
211 var board = Object.assign([], state.board)
212 board[rowIndex].splice(cellIndexInRow, 1, value)
213
214 validate(board).then(errors => {
215 historyStash = []
216 history.push({})
217 var solved = null
218 if (errors.indexOf(true) === -1) {
219 solved = true
220 board.forEach(row => {
221 row.forEach(value => {
222 if (!value || !parseInt(value, 10) || value.length > 1) {
223 solved = false
224 }
225 })
226 })
227 }
228 if (solved) {
229 board = Object.assign([], board).map(row => row.map(n => +n))
230 }
231 setState({ board, errors, solved }, (newState) => {
232 history[history.length - 1] = newState
233 restoreCaretPosition(cellIndex)
234 })
235 })
236 }
237
238 function keyDown (event) {
239 var keys = {
240 ctrlOrCmd: event.ctrlKey || event.metaKey,
241 shift: event.shiftKey,
242 z: event.keyCode === 90
243 }
244
245 if (keys.ctrlOrCmd && keys.z) {
246 if (keys.shift && historyStash.length) {
247 redo()
248 } else if (!keys.shift && history.length > 1) {
249 undo()
250 }
251 }
252 }
253
254 function undo () {
255 historyStash.push(history.pop())
256 setState(utils.copyJSON(history[history.length - 1]))
257 }
258
259 function redo () {
260 history.push(historyStash.pop())
261 setState(utils.copyJSON(history[history.length - 1]))
262 }
263
264
265 function initialize () {
266 unbindEvents()
267 render()
268 getSudoku().then(sudoku => {
269 setState({
270 success: sudoku.success,
271 board: sudoku.board,
272 solution: sudoku.solution,
273 errors: [],
274 solved: false
275 }, newState => {
276 history = [newState]
277 historyStash = []
278 })
279 })
280 }
281
282 function setState(newState, callback) {
283 requestAnimationFrame(() => {
284 Object.assign(state, newState)
285 if (typeof callback === 'function') {
286 var param = utils.copyJSON(state)
287 requestAnimationFrame(callback.bind(null, param))
288 }
289 })
290 }
291
292 function bindEvents() {
293 var generateButton = utils.dom('#generate-button')
294 var solveButton = utils.dom('#solve-button')
295 var undoButton = utils.dom('#undo-button')
296 var redoButton = utils.dom('#redo-button')
297 generateButton &&
298 generateButton
299 .addEventListener('click', onClickGenerate)
300 solveButton &&
301 solveButton
302 .addEventListener('click', onClickSolve)
303 undoButton &&
304 undoButton
305 .addEventListener('click', undo)
306 redoButton &&
307 redoButton
308 .addEventListener('click', redo)
309
310 var cells = utils.dom('.sudoku__table-cell')
311 ;[].forEach.call(cells, (cell) => {
312 cell.addEventListener('keyup', onKeyUpCell)
313 })
314
315 window.addEventListener('keydown', keyDown)
316 }
317
318 function unbindEvents() {
319 var generateButton = utils.dom('#generate-button')
320 var solveButton = utils.dom('#solve-button')
321 var undoButton = utils.dom('#undo-button')
322 var redoButton = utils.dom('#redo-button')
323 generateButton &&
324 generateButton
325 .removeEventListener('click', onClickGenerate)
326 solveButton &&
327 solveButton
328 .removeEventListener('click', onClickSolve)
329 undoButton &&
330 undoButton
331 .removeEventListener('click', undo)
332 redoButton &&
333 redoButton
334 .removeEventListener('click', redo)
335
336 var cells = utils.dom('.sudoku__table-cell')
337 ;[].forEach.call(cells, (cell) => {
338 cell.removeEventListener('keyup', onKeyUpCell)
339 })
340
341 window.removeEventListener('keydown', keyDown)
342 }
343
344 function restoreCaretPosition(cellIndex) {
345 utils.cursorManager.setEndOfContenteditable(
346 utils.dom(`[data-cell-index="${ cellIndex }"]`)[0]
347 )
348 }
349
350 function getSudoku() {
351 return sudokuAdapter.generate({
352 hints: HINTS,
353 limit: TRY_LIMIT
354 })
355 }
356
357 function validate(board) {
358 var map = board.reduce((memo, row) => {
359 for (let num of row) {
360 memo.push(num)
361 }
362 return memo
363 }, []).map((num) => parseInt(num, 10))
364
365 var validations = []
366
367 // Will validate one by one
368 for (let [index, number] of map.entries()) {
369 if (!number) {
370 validations.push(
371 new Promise(res => {
372 res({ result: { box: -1, col: -1, row: -1 } })
373 })
374 )
375 } else {
376 let all = Promise.all(validations)
377 validations.push(all.then(() => {
378 return sudokuAdapter.validate({map, number, index})
379 }))
380 }
381 }
382
383 return Promise.all(validations)
384 .then(values => {
385 var errors = []
386 for (let [index, validation] of values.entries()) {
387 let { box, col, row } = validation.result
388 let errorInBox = box.first !== box.last
389 let errorInCol = col.first !== col.last
390 let errorInRow = row.first !== row.last
391
392 let indexOfRow = Math.floor(index / 9)
393 let indexInRow = index - (indexOfRow * 9)
394
395 errors[index] = errorInRow || errorInCol || errorInBox
396 }
397
398 return errors
399 })
400 }
401
402 function render() {
403 unbindEvents()
404
405 DOM_TARGET.innerHTML = `
406 <div class='sudoku'>
407 ${ headerComponent() }
408 ${ contentComponent() }
409 </div>
410 `
411
412 bindEvents()
413 }
414
415 function buttonComponent(props) {
416 var { id, text, mods, classes } = props
417
418 var blockName = 'button'
419 var modifiers = {}
420 var modType = toString.call(mods)
421 if (modType === '[object String]') {
422 modifiers[mods] = true
423
424 } else if (modType === '[object Array]') {
425 for (let modName of mods) {
426 modifiers[modName] = true
427 }
428 }
429
430 var blockClasses = bem.makeClassName({
431 block: blockName,
432 modifiers: modifiers
433 });
434
435 var buttonTextClass = `${blockName}-text`
436 if (Object.keys(modifiers).length) {
437 buttonTextClass += (
438 Object.keys(modifiers).reduce((memo, curr) => {
439 return memo + ` ${blockName}--${curr}-text`
440 }, '')
441 )
442 }
443
444 var lgText = typeof text === 'string' ?
445 text : text[0]
446 var mdText = typeof text === 'string' ?
447 text : text[1]
448 var smText = typeof text === 'string' ?
449 text : text[2]
450
451 return (`
452 <button
453 id='${ id }'
454 class='${ blockClasses } ${ classes || "" }'>
455 <span class='show-on-sm ${buttonTextClass}'>
456 ${ smText }
457 </span>
458 <span class='show-on-md ${buttonTextClass}'>
459 ${ mdText }
460 </span>
461 <span class='show-on-lg ${buttonTextClass}'>
462 ${ lgText }
463 </span>
464 </button>
465 `)
466 }
467
468 function messageComponent(options) {
469 var { state, content } = options
470
471 var messageClass = bem.makeClassName({
472 block: 'message',
473 modifiers: state ? {
474 [state]: true
475 } : {}
476 })
477
478 return (`
479 <p class='${ messageClass }'>
480 ${ content }
481 </p>
482 `)
483 }
484
485 function descriptionComponent(options) {
486 var { className, infoLevel } = options
487
488 var technical = `
489 In this demo,
490 <a href='https://en.wikipedia.org/wiki/Backtracking'>
491 backtracking algorithm
492 </a> is used for <em>generating</em>
493 the sudoku.`
494
495 var description = `
496 Difficulty and solvability is
497 totally random as I randomly left a certain number of hints
498 from a full-filled board.
499 `
500
501 if (infoLevel === 'full') {
502 return (`
503 <p class='${ className || '' }'>
504 ${ technical } ${ description }
505 </p>
506 `)
507
508 } else if (infoLevel === 'mini') {
509 return (`
510 <p class='${ className || '' }'>
511 ${ description }
512 </p>
513 `)
514 }
515 }
516
517 function restoreScrollPosComponent() {
518 return `<div style='height: 540px'></div>`
519 }
520
521 function headerComponent() {
522 return (`
523 <div class='sudoku__header'>
524
525 <h1 class='sudoku__title'>
526
527 <span class='show-on-sm'>
528 Sudoku
529 </span>
530
531 <span class='show-on-md'>
532 Sudoku Puzzle
533 </span>
534
535 <span class='show-on-lg'>
536 Javascript Sudoku Puzzle Generator
537 </span>
538
539 </h1>
540
541 ${descriptionComponent({
542 infoLevel: 'mini',
543 className: 'sudoku__description show-on-md'
544 })}
545
546 ${descriptionComponent({
547 infoLevel: 'full',
548 className: 'sudoku__description show-on-lg'
549 })}
550
551 ${
552 state.success ? (`
553
554 ${buttonComponent({
555 id: 'generate-button',
556 text: ['New Board', 'New Board', 'New'],
557 mods: 'primary'
558 })}
559
560 ${ state.solved ?
561 buttonComponent({
562 id: 'solve-button',
563 text: 'Solved',
564 mods: ['tertiary', 'muted']
565 }) :
566 buttonComponent({
567 id: 'solve-button',
568 text: 'Solve',
569 mods: 'secondary'
570 })
571 }
572
573 `)
574
575 : (`
576
577 ${buttonComponent({
578 id: 'generate-button',
579 text: ['Generating', '', ''],
580 mods: ['disabled', 'loading']
581 })}
582
583 ${buttonComponent({
584 id: 'solve-button',
585 text: 'Solve',
586 mods: 'disabled'
587 })}
588 `)
589
590 }
591
592 ${ utils.isTouchDevice() ? (`
593
594 ${buttonComponent({
595 id: 'redo-button',
596 text: ['»', '»', '>', '>'],
597 classes: 'fr',
598 mods: [
599 'neutral',
600 'compound',
601 'compound-last',
602 `${ !historyStash.length ?
603 'disabled' :
604 ''
605 }`
606 ]
607 })}
608 ${buttonComponent({
609 id: 'undo-button',
610 text: ['«', '«', '<', '<'],
611 classes: 'fr',
612 mods: [
613 'neutral',
614 'compound',
615 'compound-first',
616 `${ history.length > 1 ?
617 '' :
618 'disabled'
619 }`
620 ]
621 })}
622
623 `) : ''}
624
625 </div>
626 `)
627 }
628
629 function contentComponent() {
630 var _isSeparator = (index) =>
631 !!index && !((index + 1) % 3)
632
633 var resultReady = !!state.board
634 var fail = resultReady && !state.success
635
636 if (!resultReady) {
637 return (`
638 ${messageComponent({
639 state: 'busy',
640 content: `Generating new board...`
641 })}
642 ${ restoreScrollPosComponent() }
643 `)
644 }
645
646 if (fail) {
647 return (`
648 ${messageComponent({
649 state: 'fail',
650 content: `Something went wrong with this board, try generating another one.`
651 })}
652 ${ restoreScrollPosComponent() }
653 `)
654 }
655
656 var rows = state.board
657
658 return (`
659 <table class='sudoku__table'>
660
661 ${rows.map((row, index) => {
662 let className = bem.makeClassName({
663 block: 'sudoku',
664 element: 'table-row',
665 modifiers: {
666 separator: _isSeparator(index)
667 }
668 });
669
670 return (
671 `<tr class='${ className }'>
672
673 ${row.map((num, _index) => {
674 let cellIndex = (index * 9) + _index
675 let separator = _isSeparator(_index)
676 let editable = typeof num !== 'number'
677 let error = state.errors[cellIndex]
678 let className = bem.makeClassName({
679 block: 'sudoku',
680 element: 'table-cell',
681 modifiers: {
682 separator,
683 editable,
684 error,
685 'editable-error': editable && error
686 }
687 });
688
689 return (
690 `\n\t
691 <td class='${ className }'
692 data-cell-index='${ cellIndex }'
693 ${ editable ? 'contenteditable' : ''}>
694 ${ num }
695 </td>`
696 )
697 }).join('')}
698
699 \n</tr>\n`
700 )
701
702 }).join('')}
703
704 </table>
705 `)
706 }
707
708 return { initialize }
709
710})( SUDOKU_APP_CONFIG ).initialize()