PageSourceSearch

https://sudokupad.app/feature-endless.js?v=0.611.0

js sudokupad.app collected 2026-10-03 19:05:37 UTC 19,312 bytes, 556 lines download raw bytes

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.