1 2const FeatureEndless = (() => { 3 // Helpers 4 const {ThumbSettingsExclude, puzzleToSvg, svgToDataUri} = PuzzleTools; 5 const {fetchPuzzle, parsePuzzleData, resolvePuzzleData} = PuzzleLoader; 6 const isInViewport = (el) =>{ 7 const rect = el.getBoundingClientRect(); 8 return ( 9 rect.top >= 0 && 10 rect.left >= 0 && 11 rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && 12 rect.right <= (window.innerWidth || document.documentElement.clientWidth) 13 ); 14 }; 15 16 function FeatureEndless() { 17 bindHandlers(this); 18 PortableEvents.mixin(this); 19 this.featureEnabled = false; 20 this.puzzlesets = []; 21 this.puzzles = []; 22 this.thumbQueue = []; 23 this.thumbIsProcessing = false; 24 this.pThumbs = Promise.resolve(); 25 this.savedSettings = {}; 26 } 27 const C = FeatureEndless, P = Object.assign(C.prototype, {constructor: C}); 28 C.Name = 'endless'; 29 C.SettingName = C.Name; 30 C.featureStyle = ` 31 .endlessdialog { 32 /* 33 border: none; 34 border-radius: 0; 35 width: 100%; height: 100%;*/ 36 } 37 .endlesspuzzle { 38 position: relative; 39 } 40 .endlessdialog .endless-btn-skip { 41 position: absolute; 42 top: 0; 43 right: 0; 44 width: 20px; height: 20px; 45 line-height: 20px; 46 padding: 0; margin: 4px; 47 font-size: 18px; 48 font-weight: 800; 49 border-radius: 4px; 50 opacity: 0.8; 51 } 52 `; 53 C.DataVersion = '1.0.0'; 54 C.DataKey = 'endlesspuzzles'; 55 C.InitialPuzzleSets = '/assets/endlesspuzzles.json'; 56 C.SetPuzzleCount = 3; 57 C.icon = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M220-260q-92 0-156-64T0-480q0-92 64-156t156-64q37 0 71 13t61 37l68 62-60 54-62-56q-16-14-36-22t-42-8q-58 0-99 41t-41 99q0 58 41 99t99 41q22 0 42-8t36-22l310-280q27-24 61-37t71-13q92 0 156 64t64 156q0 92-64 156t-156 64q-37 0-71-13t-61-37l-68-62 60-54 62 56q16 14 36 22t42 8q58 0 99-41t41-99q0-58-41-99t-99-41q-22 0-42 8t-36 22L352-310q-27 24-61 37t-71 13Z"/></svg>`; 58 // API 59 C.create = async function() { 60 const feature = new C(); 61 Framework.withApp(() => feature.addFeature()); 62 }; 63 P.init = async function() { 64 Framework.features = Framework.features || {}; 65 if(Framework.features[C.Name] !== undefined) { 66 console.error('Feature "%s" already exists.', C.Name); 67 } 68 else { 69 Framework.features[C.Name] = this; 70 } 71 const proto = Object.getPrototypeOf(this); 72 for await (const prop of Object.getOwnPropertyNames(proto)) { 73 if('function' !== typeof this[prop] || !/^handleInit.*/.test(prop)) continue; 74 await this[prop](); 75 } 76 this.trigger('init'); 77 if(C.featureStyle) this.featureStylesheet = await attachStylesheet(C.featureStyle); 78 }; 79 P.addFeature = async function() { 80 this.init(); 81 }; 82 P.removeFeature = async function() { 83 this.featureEnabled = false; 84 if(this.featureStylesheet) this.featureStylesheet.remove(); 85 }; 86 // Puzzle 87 C.setPuzzleLocation = async function(puzzleId) { 88 let pathname = document.location.pathname.replace(/\/(sudoku\/)?(.*)/, '/$1'); 89 let qs = new URLSearchParams(document.location.search); 90 qs.delete('puzzleid'); 91 let qstr = qs.toString(); 92 let nextUrl = `${pathname}${puzzleId}${qstr ? '?' + qstr : ''}`; 93 window.history.pushState({}, 'SudokuPad', nextUrl); 94 }; 95 P.getPuzzle = function(shortid) { 96 return this.puzzles.find(p => p.shortid === shortid); 97 }; 98 P.hasPuzzle = function(shortid) { 99 return this.puzzles.find(p => p.shortid === shortid); 100 }; 101 P.loadPuzzleData = async function(shortid, res = {}) { 102 //console.warn('FeatureEndless.loadPuzzleData:', shortid); 103 // TODO: Turn this into a cache 104 let puzzleId, puzzle, id; 105 try { 106 puzzleId = await fetchPuzzle(shortid); 107 puzzle = await parsePuzzleData(puzzleId); 108 id = String(puzzle.id).replace(/[^0-9]/g, ''); 109 return Object.assign(res, {shortid, id, puzzleId, puzzle}); 110 } 111 catch(err) { 112 console.error('loadPuzzleData:', err); 113 console.log(' shortid:', shortid); 114 console.log(' puzzleId:', puzzleId); 115 console.log(' puzzle:', puzzle); 116 console.log(' id:', id); 117 } 118 }; 119 // Settings 120 C.ForcedSettings = { 121 labelrowscols: false, 122 outlinesondigits: false, 123 outlinesonlines: false, 124 dashedgrid: false, 125 largedigits: true, 126 hidecolours: false, 127 }; 128 P.preserveSettings = function() { 129 this.restoreSettings(); 130 for(const [name, val] of Object.entries(C.ForcedSettings)) { 131 this.savedSettings[name] = Framework.getSetting(name); 132 Framework.setSetting(name, val); 133 } 134 }; 135 P.restoreSettings = function() { 136 for(const [name, val] of Object.entries(this.savedSettings)) { 137 Framework.setSetting(name, val); 138 delete this.savedSettings[name]; 139 } 140 }; 141 // Puzzle Thumbs 142 C.LoadingThumb = svgToDataUri(Framework.icons.loadingThumb); 143 P.thumbsDone = function() { 144 const {setLoading, thumbQueue, puzzles} = this; 145 return !setLoading && thumbQueue.length === 0 && puzzles.find(p => p.observer !== undefined) === undefined; 146 }; 147 P.waitForThumbs = async function() { 148 if(this.thumbsDone()) return; 149 let handleThumbsdone; 150 await new Promise(resolve => { 151 handleThumbsdone = resolve; 152 this.on('thumbsdone', handleThumbsdone); 153 }); 154 this.off('thumbsdone', handleThumbsdone); 155 }; 156 P.refreshThumbs = function() { 157 const thumbImgs = document.querySelectorAll(`img.endless-thumb`);
158 thumbImgs.forEach(img => { 159 let id = img.dataset.puzzleid; 160 let puzzleData = this.getPuzzle(id); 161 let thumbUrl = puzzleData.thumb; 162 try { 163 if(thumbUrl && img.src !== thumbUrl) { 164 img.addEventListener('error', this.handleThumbError); 165 img.src = thumbUrl; 166 if(puzzleData.observer) { 167 puzzleData.observer.unobserve(img); 168 puzzleData.observer.disconnect(); 169 delete puzzleData.observer; 170 } 171 } 172 } 173 catch(err) { 174 console.error('refreshThumbs:', err); 175 console.log(' id:', id); 176 console.log(' puzzleData:', puzzleData); 177 } 178 }); 179 if(this.thumbsDone()) this.trigger('thumbsdone'); 180 }; 181 P.createThumb = async function(puzzleData) { 182 if(puzzleData.thumb) return puzzleData.thumb; 183 const {app} = Framework; 184 //const timeLabel = `createthumb("${puzzleData.id}")`; 185 //console.time(timeLabel); 186 //app.puzzle.replayPlaying = true; 187 this.isPuzzleLoading = true; 188 app.puzzle.puzzleId = undefined; 189 const puzzleClone = JSON.parse(JSON.stringify(puzzleData.puzzle)); 190 await app.loadCTCPuzzle(puzzleClone); 191 this.throttledRestoreCurrentPuzzle(); 192 puzzleData.isCompleted = app.puzzle.isCompleted();
193 this.isPuzzleLoading = false; 194 this.trigger('puzzleloadingdone'); 195 //app.puzzle.replayPlaying = true; 196 this.preserveSettings(); 197 puzzleData.thumb = svgToDataUri(await puzzleToSvg({width: 512, height: 512})); 198 this.restoreSettings(); 199 //console.timeEnd(timeLabel); 200 }; 201 P.removeFromQueue = function(puzzleData) { 202 const {thumbQueue} = this; 203 let idx = thumbQueue.indexOf(puzzleData); 204 if(idx !== -1) thumbQueue.splice(idx, 1); 205 }; 206 P.handleProcessThumb = async function() { 207 const {thumbQueue} = this; 208 if(thumbQueue.length === 0) return false; 209 const puzzleData = thumbQueue.shift(); 210 if(puzzleData.thumb) return true; 211 let res = false; 212 this.thumbIsProcessing = true; 213 try { 214 await this.createThumb(puzzleData); 215 this.refreshThumbs(); 216 res = true; 217 } 218 catch(err) { 219 console.error('handleProcessThumb:', err); 220 console.log(' puzzleData:', puzzleData); 221 } 222 this.thumbIsProcessing = false; 223 return res; 224 }; 225 P.enqueueThumb = function(puzzleData, priority = false) { 226 const {thumbQueue} = this; 227 if(puzzleData !== undefined) { 228 if(priority) { 229 this.removeFromQueue(puzzleData); 230 thumbQueue.unshift(puzzleData); 231 } 232 else { 233 let idx = thumbQueue.indexOf(puzzleData); 234 if(idx === -1) thumbQueue.push(puzzleData); 235 } 236 } 237 }; 238 P.requestThumb = async function(puzzleData, priority = false) { 239 const {thumbQueue} = this; 240 if(puzzleData.thumb !== undefined) { 241 console.warn('skip?!', puzzleData); 242 return this.pThumbs; 243 } 244 if(puzzleData !== undefined) this.enqueueThumb(puzzleData, priority); 245 await this.pThumbs; 246 if(thumbQueue.length > 0) { 247 this.pThumbs = this.pThumbs.then(() => this.handleProcessThumb()); 248 } 249 return this.pThumbs; 250 }; 251 P.handleThumbIntersection = async function(entries) { 252 const {thumbQueue} = this; 253 const {target: img, intersectionRatio: ratio, isIntersecting} = entries[0]; 254 let id = img.dataset.puzzleid; 255 let puzzleData = this.getPuzzle(id); 256 if(isIntersecting) { 257 this.requestThumb(puzzleData, ratio > 0.5); 258 } 259 else { 260 this.removeFromQueue(puzzleData); 261 } 262 } 263 P.observeThumb = function(img) { 264 let id = img.dataset.puzzleid; 265 let puzzleData = this.getPuzzle(id); 266 const opts = { 267 root: document.querySelector('.endlessdialog'), 268 threshold: [0, 0.5] 269 }; 270 let observer = new IntersectionObserver(this.handleThumbIntersection, opts); 271 observer.observe(img); 272 puzzleData.observer = observer; 273 }; 274 P.handleThumbError = async function(event) { 275 let img = event.target, id = img.dataset.puzzleid; 276 console.error('handleThumbError("%s");', id); 277 console.log(' event:', event); 278 console.log(' event.type:', event.type); 279 console.log(' img:', img); 280 }; 281 // Puzzle Listing 282 P.puzzleGetHtml = function(puzzleData) { 283 const {shortid, thumb = C.LoadingThumb, puzzle} = puzzleData; 284 let w = 128, h = 128, {nextpuzzle} = puzzle.metadata || {}; 285 return ` 286 <div class="endlesspuzzle" data-id="${shortid}" style="display: inline-block; padding: 16px;"> 287 <a href="/${shortid}" class="display: block;"><img class="endless-thumb" data-puzzleid="${shortid}" src="${thumb}" loading="lazy" width="${w}" height="${h}" style="display: block;"></a> 288 <button class="endless-btn-skip" data-id="${shortid}" title="Skip">â</button> 289 </div> 290 `; 291 }; 292 P.puzzleRender = function(set, puzzleData) { 293 let setElem = document.querySelector(`.endlessset[data-setid="${set.start}"] .setscroll`); 294 setElem.insertAdjacentHTML('beforeend', this.puzzleGetHtml(puzzleData)); 295 let puzElem = setElem.lastElementChild; 296 this.observeThumb(puzElem.querySelector('.endless-thumb')); 297 //puzElem.querySelector('.endless-thumb').addEventListener('error', this.handleThumbError); 298 puzElem.querySelector('.endless-btn-skip').addEventListener('click', this.handleSkip); 299 return puzElem; 300 }; 301 P.handleSkip = async function(event) { 302 let id = event.target.dataset.id; 303 return this.skipPuzzle(id); 304 }; 305 // Puzzle Sets 306 P.setRenderPuzzles = async function(set) { 307 const {start, current} = set; 308 let setElem = document.querySelector(`.endlessset[data-setid="${start}"] .setscroll`); 309 const setHiddenElem = document.querySelector(`.endlessset[data-setid="${start}"] .set-hidden`); 310 if(setHiddenElem) setHiddenElem.innerHTML = `(${set.archive.length} puzzles hidden)`; 311 let puzzleElems = setElem.querySelectorAll(`.endlesspuzzle`);
312 puzzleElems.forEach(elem => { 313 if(!current.includes(elem.dataset.id)) elem.remove(); 314 }); 315 for(let i = 0; i < current.length; i++) { 316 let shortid = current[i]; 317 if(document.querySelector(`.endlesspuzzle[data-id="${shortid}"]`)) continue; 318 let puzzleData = this.getPuzzle(shortid); 319 await this.puzzleRender(set, puzzleData); 320 } 321 setElem.scrollLeft = 1000; 322 /* 323 if(!this.setLoading) { 324 await this.waitForThumbs(); 325 this.restoreCurrentPuzzle(); 326 } 327 */ 328 }; 329 P.setGetHtml = function (set) { 330 return ` 331 <div class="endlessset" data-setid="${set.start}" style="overflow: hidden;"> 332 <h2>${set.title}</h2> 333 <div style="margin: 0 1rem; font-size: 80%;" class="set-hidden">(${set.archive.length} puzzle${set.archive.length===1?'':'s'} hidden)</div> 334 <div class="setscroll" style="white-space: nowrap; overflow-x: scroll;"> 335 </div> 336 </div> 337 `; 338 }; 339 P.setRender = async function(set) { 340 let rootElem = document.querySelector('#puzzlesets'); 341 rootElem.insertAdjacentHTML('beforeend', this.setGetHtml(set)); 342 /* 343 let shortid = set.start; 344 for(let i = 0; i < C.SetPuzzleCount; i++) { 345 let puzzleData = await this.addPuzzle(set, shortid); 346 shortid = puzzleData.puzzle.metadata.nextpuzzle; 347 if(shortid === undefined) break; 348 } 349 */ 350 await this.setRenderPuzzles(set); 351 }; 352 P.addPuzzle = async function(set, shortid) { 353 let puzzleData = this.getPuzzle(shortid); 354 if(puzzleData === undefined) { 355 puzzleData = await this.loadPuzzleData(shortid); 356 this.puzzles.push(puzzleData); 357 } 358 set.current.push(puzzleData.shortid); 359 puzzleData.set = set; 360 return puzzleData; 361 }; 362 P.skipPuzzle = async function(shortid) { 363 let puzzleData = this.getPuzzle(shortid); 364 if(puzzleData === undefined) throw new Error('setSkipPuzzle > puzzle not found: ' + shortid); 365 const {set} = puzzleData; 366 let idx = set.current.indexOf(shortid); 367 if(idx === -1) return console.warn('Puzzle not found in set', shortid, set); 368 // Find next shortid 369 let lastPuzzle = this.getPuzzle(set.current[set.current.length - 1]); 370 let nextPuzzleId = lastPuzzle.puzzle.metadata.nextpuzzle; 371 // Archive puzzle 372 set.archive.push(set.current.splice(idx, 1)[0]); 373 // Add next 374 await this.addPuzzle(set, nextPuzzleId); 375 this.throttledSaveData(); 376 await this.setRenderPuzzles(set); 377 }; 378 P.addSet = function(start, title) { 379 const setExists = this.puzzlesets.find(set => set.start === start) !== undefined; 380 if(setExists) throw new Error(`Set "${start}" already exists.`); 381 const set = {title, start, current: [], archive: []}; 382 this.puzzlesets.push(set); 383 return set; 384 }; 385 // Dialog 386 P.handleAppMenu = async function(event) { 387 event.preventDefault(); 388 this.handleShowDialog(); 389 }; 390 P.handleInit = async function() { 391 Framework.addSetting({ 392 group: 'experimental', name: C.SettingName, 393 tag: 'button', 394 content: 'Endless Puzzles', 395 handler: this.handleShowDialog 396 }); 397 Framework.on('togglesetting', this.handleToggleSetting); 398 const appmenuElem = document.querySelector('#appmenuitems'); 399 if(appmenuElem) { 400 appmenuElem.insertAdjacentHTML('beforeend',
401 `<a class="mdc-list-item menu-link-endless" href="#" id="appmenu-endless"> 402 <div class="icon">${C.icon}</div> 403 <span class="mdc-list-item-text">Endless Puzzles (Demo)</span> 404 </a>` 405 ); 406 addHandler(appmenuElem.lastChild, 'click', this.handleAppMenu, {passive: false, capture: true}); 407 } 408 }; 409 P.handlePuzzleSolved = async function() { 410 /* 411 let puzzleId = getPuzzleId(); 412 for await(const set of this.puzzlesets) { 413 //console.log(' set:', set.current); 414 if(set.current.indexOf(puzzleId) !== -1) { 415 //console.warn('ENDLESS: Remove current puzzle:', puzzleId); 416 //await this.skipPuzzle(puzzleId); 417 } 418 } 419 */ 420 }; 421 P.handleToggleSetting = function(name, prev, next) { 422 if(ThumbSettingsExclude.includes(name)) { 423 for(const puzzleData of this.puzzles) delete puzzleData.thumb; 424 } 425 }; 426 P.restoreCurrentPuzzle = async function() { 427 const {app} = Framework; 428 const {thumbQueue} = this; 429 thumbQueue.length = 0; 430 if(this.isPuzzleLoading) { 431 await new Promise(resolve => { 432 const handleLoadingDone = () => { 433 this.off('puzzleloadingdone', handleLoadingDone); 434 resolve(); 435 }; 436 this.on('puzzleloadingdone', handleLoadingDone); 437 }); 438 } 439 if(this.currentPuzzle && app.puzzle.puzzleId !== this.currentPuzzleId) { 440 app.puzzle.puzzleId = undefined; 441 await app.loadCTCPuzzle(await resolvePuzzleData(this.currentPuzzle)); 442 } 443 }; 444 P.throttledRestoreCurrentPuzzle = throttleFunc(P.restoreCurrentPuzzle, 200, 5000); 445 P.loadData = async function() { 446 let data = Framework.getData(C.DataKey); 447 //console.log(' data:', data); 448 if(data === null) { 449 let initialSets = await (await fetchWithTimeout(C.InitialPuzzleSets)).json(); 450 for await (const {start, title} of initialSets) { 451 const set = this.addSet(start, title); 452 //console.log(' preparing set:', set); 453 let shortid = set.start; 454 for(let i = 0; i < C.SetPuzzleCount; i++) { 455 let puzzleData = await this.addPuzzle(set, shortid); 456 shortid = puzzleData.puzzle.metadata.nextpuzzle; 457 if(shortid === undefined) break; 458 } 459 } 460 } 461 else { 462 for await (const {start, title, current, archive} of data.sets) { 463 const setExists = this.puzzlesets.find(set => set.start === start) !== undefined; 464 //console.log('Adding set:', start, title, setExists); 465 if(setExists) continue; 466 const set = this.addSet(start, title); 467 for await (const idx of current) { 468 await this.addPuzzle(set, data.puzzles[idx]); 469 } 470 set.archive.length = 0; 471 set.archive.push(...archive.map(idx => data.puzzles[idx])); 472 } 473 } 474 //console.log(' puzzles:', this.puzzles); 475 //console.log(' puzzlesets:', this.puzzlesets); 476 }; 477 P.saveData = async function() { 478 //console.log(' puzzles:', this.puzzles); 479 //console.log(' puzzlesets:', this.puzzlesets); 480 const shortids = [...new Set(this.puzzlesets.flatMap(({current, archive}) => [...current, ...archive]))]; 481 //console.log(' shortids:', shortids); 482 /* 483 const completed = shortids.map(id => { 484 let puz = this.hasPuzzle(id); 485 if(puz === undefined) return -1; 486 return puz.isCompleted ? 1 : 0; 487 }); 488 */ 489 const data = { 490 version: C.DataVersion, 491 puzzles: shortids, 492 //completed, 493 sets: this.puzzlesets.map(({title, start, current, archive}) => ({ 494 title, start, 495 current: current.map(id => shortids.indexOf(id)), 496 archive: archive.map(id => shortids.indexOf(id)), 497 })) 498 } 499 //console.log(' data:', data); 500 //console.log(' data:', JSON.stringify(data).length, JSON.stringify(data)); 501 Framework.setData(C.DataKey, data); 502 }; 503 P.resetData = async function() { 504 Framework.removeData(C.DataKey); 505 this.puzzles.length = 0; 506 this.puzzlesets.length = 0; 507 this.handleShowDialog(); 508 }; 509 P.throttledSaveData = throttleFunc(P.saveData, 500, 2000); 510 P.handleDialogClose = async function() { 511 //console.warn('FeatureEndless.handleDialogClose();'); 512 this.throttledRestoreCurrentPuzzle.force.call(this); 513 }; 514 P.handleDialogButton = async function(btn) { 515 //console.warn('FeatureEndless.handleDialogButton();'); 516 switch(btn) { 517 case 'Close': 518 this.handleDialogClose(); 519 Framework.closeDialog(); 520 break; 521 case 'Reset All': 522 this.resetData(); 523 break; 524 } 525 }; 526 P.handleShowDialog = async function() { 527 //console.warn('FeatureEndless.handleShowDialog();'); 528 this.currentPuzzle = getPuzzleId(); 529 this.currentPuzzleId = Framework.app.puzzle.puzzleId; 530 Framework.closeDialog(); 531 Framework.showDialog({ 532 parts: [ 533 {tag: 'title', innerHTML: 'Endless Puzzles', style: 'text-align: center'}, 534 {tag: 'div', id: 'puzzlesets'}, 535 {tag: 'options', options: ['Close', 'Reset All']}, 536 ], 537 overlayClass: 'endlessoverlay', 538 dialogClass: 'endlessdialog', 539 autoClose: false, 540 centerOverBoard: true, 541 onButton: this.handleDialogButton, 542 onCancel: this.handleDialogClose, 543 }); 544 await this.loadData(); 545 this.setLoading = true; 546 console.time('load sets'); 547 for await (const set of this.puzzlesets) await this.setRender(set); 548 console.timeEnd('load sets'); 549 this.setLoading = false;
550 this.throttledSaveData(); 551 }; 552 553 return C; 554})(); 555 556FeatureEndless.create();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.