-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwanikani-level-filter.user.js
More file actions
1823 lines (1605 loc) · 63 KB
/
Copy pathwanikani-level-filter.user.js
File metadata and controls
1823 lines (1605 loc) · 63 KB
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
// ==UserScript==
// @name WaniKani Level Filter
// @namespace wanikani-level-filter
// @description Filter reviews by level during active review sessions
// @version 1.6.0
// @author doutatsu
// @match https://www.wanikani.com/*
// @match https://preview.wanikani.com/*
// @require https://greasyfork.org/scripts/462049-wanikani-queue-manipulator/code/WaniKani%20Queue%20Manipulator.user.js
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ============================================
// SECTION 1: CONSTANTS & CONFIGURATION
// ============================================
const STORAGE_KEY = 'wk-level-filter-selection';
const SORT_STORAGE_KEY = 'wk-level-filter-sort-direction';
const SORT_DESC = 'desc'; // Highest SRS stage first (default)
const SORT_ASC = 'asc'; // Lowest SRS stage first
const SORT_NONE = 'none'; // No sorting - keep the queue's original order
// Toggle button label/tooltip per mode; tooltips describe the next click.
// Declaration order doubles as the cycle order (see SORT_CYCLE below), so
// every mode necessarily has a label.
const SORT_LABELS = {
[SORT_DESC]: {
text: 'SRS ↓',
title: 'Sorting by SRS: highest first (click for lowest first)'
},
[SORT_ASC]: {
text: 'SRS ↑',
title: 'Sorting by SRS: lowest first (click to disable sorting)'
},
[SORT_NONE]: {
text: 'SRS —',
title: 'No SRS sorting: original order (click for highest first)'
}
};
// Order the toggle button cycles through on each click, derived from
// SORT_LABELS so the two can never drift apart.
const SORT_CYCLE = Object.keys(SORT_LABELS);
const HEADER_CHECK_INTERVAL = 100; // ms
const HEADER_TIMEOUT = 5000; // ms
const EMPTY_QUEUE_CLASS = 'level-filter-empty-queue';
const NOTIFICATION_CLASS = 'level-filter-notification';
const UI_IDS = {
container: 'level-filter-container',
dropdown: 'level-filter-dropdown',
sortToggle: 'level-filter-sort-toggle',
noItemsMessage: 'level-filter-no-items-message'
};
const STYLES = {
// Only the container is styled inline, because its positioning is swapped
// at runtime (absolute inside the scroll container vs fixed while the quiz
// is hidden). Everything else lives in UI_CSS so that hover/active/focus
// rules are not outranked by inline styles.
// Radius is concentric with the controls inside it: 6px inner + 4px of
// vertical padding = 10px outer.
containerBase: `
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.6);
border-radius: 10px;
box-shadow:
0 1px 2px oklch(0 0 0 / 0.24),
0 4px 12px oklch(0 0 0 / 0.16);
`,
containerAbsolute: `
position: absolute;
top: 50px;
left: 10px;
z-index: 1000;
`,
// Used only while the quiz subtree is hidden (empty queue): there is nothing
// to scroll then, and the menu has to sit outside that subtree to stay
// visible, so pinning it to the viewport is the right behaviour.
containerFixed: `
position: fixed;
top: 50px;
left: 10px;
z-index: 100001;
`,
noItemsMessage: `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 30px;
border-radius: 12px;
z-index: 100000;
max-width: 500px;
text-align: center;
box-shadow:
0 0 0 1px oklch(0.75 0.15 85 / 0.9),
0 2px 4px oklch(0 0 0 / 0.08),
0 12px 32px oklch(0 0 0 / 0.18);
`,
notification: `
position: fixed;
top: 20px;
left: 50%;
transform: translate(-50%, 0);
background: #4a90e2;
color: white;
padding: 12px 24px;
border-radius: 8px;
z-index: 100000;
font-size: 14px;
font-weight: 500;
box-shadow:
0 1px 2px oklch(0 0 0 / 0.16),
0 8px 24px oklch(0 0 0 / 0.18);
animation: wkLevelFilterSlideDown 0.24s cubic-bezier(0.2, 0, 0, 1);
`
};
const EMPTY_QUEUE_CSS = `
/* Hide quiz interface when no items are available */
body.${EMPTY_QUEUE_CLASS} .quiz {
display: none !important;
}
/* Hide the completion/wrap-up screens when filtering */
body.${EMPTY_QUEUE_CLASS} turbo-frame#quiz {
display: none !important;
}
/* Ensure the message is visible */
body.${EMPTY_QUEUE_CLASS} #${UI_IDS.noItemsMessage} {
display: block !important;
}
@keyframes wkLevelFilterSlideDown {
from { opacity: 0; transform: translate(-50%, -10px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes wkLevelFilterSlideUp {
from { opacity: 1; transform: translate(-50%, 0); }
to { opacity: 0; transform: translate(-50%, -10px); }
}
`;
// Styling for the menu's controls. Kept in a stylesheet rather than inline so
// that :hover/:active/:focus-visible can actually take effect - an inline
// style would outrank them - and scoped by id + parent id so WaniKani's own
// form styling does not win on specificity.
const UI_CSS = `
#${UI_IDS.container} label {
color: #fff;
font-size: 12px;
font-weight: 500;
/* Nudge off the pill's left edge so it is optically centred against the
control to its right, which carries a 1px ring of its own. */
padding-left: 1px;
}
#${UI_IDS.container} #${UI_IDS.dropdown},
#${UI_IDS.container} #${UI_IDS.sortToggle} {
margin: 0;
font-size: 11px;
line-height: 1;
color: #1a1a1a;
background: #fff;
border: 0;
border-radius: 6px;
cursor: pointer;
/* Elevation, not structure: a hairline ring plus a soft drop shadow,
both transparent so they sit correctly on any backdrop. */
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.14),
0 1px 2px oklch(0 0 0 / 0.12);
transition-property: background-color, box-shadow, scale;
transition-duration: 120ms;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
#${UI_IDS.container} #${UI_IDS.dropdown} {
padding: 4px 6px;
min-width: 100px;
}
#${UI_IDS.container} #${UI_IDS.sortToggle} {
padding: 4px 8px;
white-space: nowrap;
text-align: center;
/* Hold a constant width across "SRS ↓" / "SRS ↑" / "SRS —" so cycling the
mode does not resize the pill under the pointer. */
min-width: 58px;
}
#${UI_IDS.container} #${UI_IDS.dropdown}:hover,
#${UI_IDS.container} #${UI_IDS.sortToggle}:hover {
background: #f2f2f2;
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.2),
0 1px 3px oklch(0 0 0 / 0.16);
}
/* Tactile press. Only the toggle: a native select opens its popup on
pointer-down, so scaling it would animate against the open menu. */
#${UI_IDS.container} #${UI_IDS.sortToggle}:active {
background: #e8e8e8;
scale: 0.96;
}
#${UI_IDS.container} #${UI_IDS.dropdown}:focus-visible,
#${UI_IDS.container} #${UI_IDS.sortToggle}:focus-visible {
outline: none;
box-shadow:
0 0 0 1px oklch(0 0 0 / 0.2),
0 0 0 3px oklch(0.62 0.19 255 / 0.65);
}
@media (prefers-reduced-motion: reduce) {
#${UI_IDS.container} #${UI_IDS.dropdown},
#${UI_IDS.container} #${UI_IDS.sortToggle} {
transition-duration: 1ms;
}
#${UI_IDS.container} #${UI_IDS.sortToggle}:active {
scale: 1;
}
.${NOTIFICATION_CLASS} {
animation: none !important;
}
}
`;
// ============================================
// SECTION 2: GLOBAL STATE
// ============================================
const state = {
subjectLevelMap: {},
subjectSrsMap: {}, // Map of subject_id -> srs_stage
availableLevels: [], // Array of level numbers
levelCounts: {}, // Object mapping level -> count
dropdown: null,
initialized: false,
// Track levels with items in current queue (updated on each filter call)
currentQueueLevels: new Set(),
currentQueueLevelCounts: {},
// Levels finished this session. The queue we are handed keeps listing
// subjects the user has already answered (the same staleness that used to
// freeze the "to go" counter), so a level's queue count never reaching zero
// is not evidence it still has work. Without this, a finished level stays in
// the dropdown and the auto-switch sends the user back round to it.
exhaustedLevels: new Set(),
// Quiz-statistics tracking (see SECTION 10.5). sessionLevelSubjects maps a
// level to the Set of subject ids seen in the queue at any point this
// session, so its size is that level's session total.
// completedLevelSubjects maps a level to the Set of ids known to be done,
// fed by the queue (any previously seen id missing from a fresh queue must
// be finished) and topped up by the didCompleteSubject event.
sessionLevelSubjects: {},
completedLevelSubjects: {},
// Size of each level's completed set as of the last queue reconciliation,
// plus WaniKani's own completed counter at that same moment. The difference
// between that counter and its current value is how many subjects have been
// finished since - a third signal, used when the others are not moving.
reconciledBaseByLevel: {},
nativeCompletedAtReconcile: null,
statsListenersRegistered: false,
// Scroll container we forced to position:relative, so cleanup can undo it
patchedScrollContainer: null,
// Menu anchoring: whether the empty-queue state has parked the menu on
// <body>, and where to put it back afterwards.
emptyQueueLayout: false,
menuParentBeforeEmptyQueue: null,
scrollWatchAttached: false,
// Track if user intentionally clicked home button
userClickedHome: false,
// Avoid registering multiple filters on turbo navigation
queueFilterOwner: null,
queueFilterRegistered: false
};
// ============================================
// SECTION 3: WKOF CHECK
// ============================================
if (typeof wkof === 'undefined') {
return;
}
// ============================================
// SECTION 3.5: CSS INJECTION
// ============================================
/**
* Inject CSS styles for the filter
*/
function injectCSS() {
const style = document.createElement('style');
style.textContent = EMPTY_QUEUE_CSS + UI_CSS;
document.head.appendChild(style);
}
// Inject CSS immediately
injectCSS();
// Setup home button tracking and navigation interceptor early
setupHomeButtonTracking();
setupNavigationInterceptor();
// Listen for quiz lifecycle events so the per-level statistics stay in sync
setupQuizStatisticsTracking();
// ============================================
// SECTION 4: DATA LOADING FUNCTIONS
// ============================================
/**
* Load all items from WaniKani and build the level mapping
* @returns {Promise<Object>} Object with level counts
*/
function loadItemDataWithLevels() {
state.subjectLevelMap = {};
state.subjectSrsMap = {};
state.availableLevels = [];
state.levelCounts = {};
// Start the session's statistics tracking from a clean slate
state.sessionLevelSubjects = {};
state.completedLevelSubjects = {};
state.reconciledBaseByLevel = {};
state.nativeCompletedAtReconcile = null;
state.exhaustedLevels = new Set();
const config = {
wk_items: {
options: {
assignments: true
},
filters: {} // Get all items
}
};
return wkof.ItemData.get_items(config)
.then(items => {
buildSubjectLevelMap(items);
const counts = extractAvailableLevels(items);
state.levelCounts = counts; // Store globally
return counts;
})
.catch(error => {
// Swallow error logging to keep console clean.
alert('Level Filter: Failed to load level data. The filter will not work this session.');
// Fallback: return empty counts
return {};
});
}
/**
* Build a map of subject_id -> level for fast lookups
* @param {Array} items - Items from ItemData
*/
function buildSubjectLevelMap(items) {
state.subjectLevelMap = {};
state.subjectSrsMap = {};
items.forEach(item => {
if (!item || !item.data || !Number.isFinite(item.data.level)) {
return;
}
state.subjectLevelMap[item.id] = item.data.level;
// Record the SRS stage so the queue can be sorted by it
if (item.assignments && Number.isFinite(item.assignments.srs_stage)) {
state.subjectSrsMap[item.id] = item.assignments.srs_stage;
}
});
}
/**
* Extract levels with available reviews and count items per level
* @param {Array} items - Items from ItemData
* @returns {Object} Object mapping level -> count of available items
*/
function extractAvailableLevels(items) {
const levelCounts = {};
const now = new Date();
items.forEach(item => {
// Check if item has an assignment and is available for review
if (!item.assignments) {
return;
}
const assignment = item.assignments;
// Item is available for review if:
// 1. It has been started (srs_stage > 0 means it's been through lessons)
// 2. It's available_at time has passed
// 3. It's not burned (srs_stage < 9)
if (assignment.srs_stage > 0 &&
assignment.srs_stage < 9 &&
assignment.available_at) {
const availableAt = new Date(assignment.available_at);
// Only count if available_at is in the past (available for review now)
if (availableAt <= now) {
const level = item.data.level;
levelCounts[level] = (levelCounts[level] || 0) + 1;
}
}
});
// Convert to sorted array of levels
state.availableLevels = Object.keys(levelCounts)
.map(Number)
.sort((a, b) => a - b);
return levelCounts;
}
// ============================================
// SECTION 5: UI FUNCTIONS
// ============================================
/**
* Create a DOM element with common attributes
* @param {string} tag - The element tag name
* @param {Object} options - Element options
* @returns {HTMLElement} The created element
*/
function createElement(tag, options = {}) {
const element = document.createElement(tag);
if (options.id) {
element.id = options.id;
}
if (options.text !== undefined) {
element.textContent = options.text;
}
if (options.html !== undefined) {
element.innerHTML = options.html;
}
if (options.cssText) {
element.style.cssText = options.cssText;
}
if (options.attrs) {
Object.entries(options.attrs).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
return element;
}
/**
* Create the container for the dropdown UI
* @param {HTMLSelectElement} dropdown - The dropdown to insert
* @param {string} positionCss - Positioning CSS for the container
* @returns {HTMLDivElement} The container element
*/
function createDropdownContainer(dropdown, positionCss) {
const container = createElement('div', {
id: UI_IDS.container,
cssText: STYLES.containerBase + positionCss
});
const label = createElement('label', {
text: 'Level:',
attrs: { for: UI_IDS.dropdown }
});
container.appendChild(label);
container.appendChild(dropdown);
// Add the SRS sort-direction toggle next to the dropdown
container.appendChild(createSortToggle());
return container;
}
/**
* Create the button that cycles the SRS sort mode
* @returns {HTMLButtonElement} The toggle button element
*/
function createSortToggle() {
const button = createElement('button', {
id: UI_IDS.sortToggle,
attrs: { type: 'button' }
});
updateSortToggleLabel(button);
button.addEventListener('click', () => {
saveSortDirection(nextSortDirection(getSortDirection()));
updateSortToggleLabel(button);
// Remove empty queue message and class
clearEmptyQueueUI();
// Re-run the filter (and re-sort) by refreshing the queue
if (window.wkQueue && window.wkQueue.refresh) {
window.wkQueue.refresh();
}
});
return button;
}
/**
* Update the toggle button's label/tooltip to reflect the current sort mode.
* Tooltips describe what the next click will do.
* @param {HTMLButtonElement} button - The toggle button
*/
function updateSortToggleLabel(button) {
const { text, title } = SORT_LABELS[getSortDirection()];
button.textContent = text;
button.title = title;
button.setAttribute('aria-label', title);
}
/**
* Remove empty-queue UI and styling
*/
function clearEmptyQueueUI() {
const message = document.getElementById(UI_IDS.noItemsMessage);
if (message) {
message.remove();
}
if (document.body) {
document.body.classList.remove(EMPTY_QUEUE_CLASS);
}
// The quiz subtree is visible again, so the menu can go back to scrolling
// with it.
exitEmptyQueueLayout();
}
/**
* Create the level filter dropdown element
* @param {Object} counts - Object mapping level -> count
* @returns {HTMLSelectElement} The dropdown element
*/
function createLevelDropdown(counts) {
const dropdown = document.createElement('select');
dropdown.id = UI_IDS.dropdown;
dropdown.setAttribute('aria-label', 'Filter by level');
// Add "All Levels" option with total count
const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0);
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = `All Levels (${totalCount})`;
dropdown.appendChild(allOption);
// Add individual level options with counts
// Only show levels that have items available
const levelsToShow = state.availableLevels.length > 0
? state.availableLevels
: Object.keys(counts).map(Number).sort((a, b) => a - b);
levelsToShow.forEach(level => {
const count = counts[level] || 0;
if (count > 0) {
const option = document.createElement('option');
option.value = level;
option.textContent = `Level ${level} (${count})`;
dropdown.appendChild(option);
}
});
// Restore saved selection
const savedLevel = getSelectedLevel();
if (savedLevel) {
dropdown.value = savedLevel;
}
// Save selection on change
dropdown.addEventListener('change', (e) => {
const selected = e.target.value;
saveSelectedLevel(selected);
// Remove empty queue message and class
clearEmptyQueueUI();
// Trigger queue refresh if wkQueue is available
if (window.wkQueue && window.wkQueue.refresh) {
window.wkQueue.refresh();
}
});
return dropdown;
}
/**
* Update the dropdown options based on current queue state
* Called after filtering to reflect actual remaining items
*/
function updateDropdownOptions() {
if (!state.dropdown) return;
const counts = selectableQueueLevelCounts();
const currentValue = state.dropdown.value;
// Clear existing options
state.dropdown.innerHTML = '';
// Add "All Levels" option with total count
const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0);
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = `All Levels (${totalCount})`;
state.dropdown.appendChild(allOption);
// Add individual level options with counts (sorted)
const sortedLevels = Object.keys(counts)
.map(Number)
.filter(level => counts[level] > 0)
.sort((a, b) => a - b);
sortedLevels.forEach(level => {
const count = counts[level];
const option = document.createElement('option');
option.value = level;
option.textContent = `Level ${level} (${count})`;
state.dropdown.appendChild(option);
});
// Restore selection if it still exists, otherwise keep current
const parsedValue = Number.parseInt(currentValue, 10);
if (currentValue === 'all' || (Number.isFinite(parsedValue) && counts[parsedValue] > 0)) {
state.dropdown.value = currentValue;
} else {
// Current level no longer has items, this shouldn't happen
// as we switch levels before this, but just in case
state.dropdown.value = 'all';
}
}
/**
* Find the nearest scrollable ancestor of an element — the container whose
* own scrolling actually moves the page content.
*
* On WaniKani's review page the window/document does NOT scroll; an inner
* element does. That means an absolutely-positioned menu anchored to <body>
* is positioned against the (viewport-sized) initial containing block and
* appears to float, staying pinned on screen as you scroll. Anchoring it
* inside the real scroll container instead lets it scroll away with the
* content, since absolutely-positioned descendants of a scroll container
* participate in that container's scrollable overflow.
*
* A candidate that merely *permits* scrolling is only used when no ancestor is
* actually scrolling: WaniKani has non-scrolling `overflow-y: auto` wrappers,
* and anchoring inside one clips the menu.
*
* This is only the opening guess. Which element scrolls depends on styling we
* do not control and cannot reliably infer at insertion time (the content may
* not have grown yet), so watchForScrollContainer corrects it from the first
* real scroll event.
*
* @param {HTMLElement} start - Element to search upward from
* @returns {HTMLElement|null} The scroll container, or null if none found
*/
function findScrollContainer(start) {
let node = start || null;
let fallback = null;
while (node && node !== document.body && node !== document.documentElement) {
const overflowY = window.getComputedStyle(node).overflowY;
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
if (node.scrollHeight > node.clientHeight) {
return node; // Actually scrolling right now - this is the real one
}
if (!fallback) {
fallback = node;
}
}
node = node.parentElement;
}
return fallback;
}
/**
* Put the menu inside a given element, making that element a positioning
* context first so our absolutely-positioned menu resolves against it (and so
* scrolls with its content) rather than against the viewport.
* @param {HTMLElement} parent - The element to place the menu in
* @param {HTMLDivElement} container - The menu container to place
*/
function placeMenuIn(parent, container) {
if (parent !== document.body && window.getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative';
// Remember the patch so cleanupUI can put WaniKani's DOM back.
state.patchedScrollContainer = parent;
}
container.style.cssText = STYLES.containerBase + STYLES.containerAbsolute;
parent.appendChild(container);
}
/**
* Correct the menu's anchor using the first scroll event that actually
* happens. Reading the DOM can only tell us which element *may* scroll;
* a scroll event tells us which one *does*, whatever WaniKani's layout is
* doing. The listener is capturing because scroll events on elements do not
* bubble, and it stays attached so a later layout change (a Turbo render
* swapping the quiz subtree) is picked up too.
* @param {HTMLDivElement} container - The menu container to keep anchored
*/
function watchForScrollContainer(container) {
if (state.scrollWatchAttached) {
return;
}
state.scrollWatchAttached = true;
document.addEventListener('scroll', (event) => {
const target = event.target;
// The document/window scrolling means body-anchored positioning is
// already correct - an absolute menu on <body> scrolls with the page.
if (!target || target === document || target === document.documentElement ||
target === document.body || target.nodeType !== 1) {
return;
}
// Already anchored correctly, or the menu is not in the DOM right now
// (empty-queue state parks it on <body> on purpose).
if (target === container.parentElement || state.emptyQueueLayout) {
return;
}
placeMenuIn(target, container);
}, true);
}
/**
* Anchor the menu so it scrolls away with the content instead of floating.
* Starts from a best guess (see findScrollContainer) and then lets the first
* scroll event correct it.
* @param {HTMLDivElement} container - The menu container to place
* @param {HTMLElement|null} anchor - A known in-content element to search from
*/
function anchorMenuToScroll(container, anchor) {
const scrollContainer = findScrollContainer(anchor) ||
findScrollContainer(document.querySelector('.quiz'));
placeMenuIn(scrollContainer || document.body, container);
watchForScrollContainer(container);
}
/**
* Park the menu on <body>, pinned to the viewport, while the quiz subtree is
* hidden. The real scroll container is inside that subtree, so leaving the
* menu there would hide it at exactly the moment the "no items - pick another
* level" message asks the user to use it. Nothing scrolls in this state, so
* fixed positioning is right.
*/
function enterEmptyQueueLayout() {
const container = document.getElementById(UI_IDS.container);
if (!container || state.emptyQueueLayout) {
return;
}
state.emptyQueueLayout = true;
state.menuParentBeforeEmptyQueue = container.parentElement;
container.style.cssText = STYLES.containerBase + STYLES.containerFixed;
document.body.appendChild(container);
}
/**
* Put the menu back where it was before the empty-queue state parked it, so it
* resumes scrolling with the content.
*/
function exitEmptyQueueLayout() {
if (!state.emptyQueueLayout) {
return;
}
state.emptyQueueLayout = false;
const container = document.getElementById(UI_IDS.container);
const parent = state.menuParentBeforeEmptyQueue;
state.menuParentBeforeEmptyQueue = null;
if (!container) {
return;
}
// The old parent may have been swapped out by Turbo while we were away.
placeMenuIn(parent && parent.isConnected ? parent : document.body, container);
}
/**
* Insert the dropdown into the review page header
* @param {HTMLSelectElement} dropdown - The dropdown to insert
*/
function insertDropdownIntoPage(dropdown) {
let attempts = 0;
const maxAttempts = HEADER_TIMEOUT / HEADER_CHECK_INTERVAL;
const waitForHeader = setInterval(() => {
attempts++;
// Look for the home button or header area
const homeButton = document.querySelector('.wk-icon--home') ||
document.querySelector('[href="/"]');
const header = homeButton ? homeButton.closest('header') : document.querySelector('header');
if (homeButton || header) {
clearInterval(waitForHeader);
// Anchor inside the page's scroll container (not the sticky header) so
// the menu stays at its starting position and scrolls away with the
// content instead of floating as you scroll down.
const container = createDropdownContainer(dropdown, STYLES.containerAbsolute);
anchorMenuToScroll(container, header || homeButton);
} else if (attempts >= maxAttempts) {
clearInterval(waitForHeader);
// Fallback: insert at top-left corner, still anchored to the scroll
// container where possible so it scrolls with the page.
if (document.body) {
const container = createDropdownContainer(dropdown, STYLES.containerAbsolute);
anchorMenuToScroll(container, null);
}
}
}, HEADER_CHECK_INTERVAL);
}
/**
* Setup the UI by creating and inserting the dropdown
*/
function setupUI() {
if (document.getElementById(UI_IDS.container)) {
return;
}
const counts = Object.keys(state.levelCounts).length > 0
? state.levelCounts
: state.currentQueueLevelCounts;
if (Object.keys(counts).length === 0) {
return;
}
state.dropdown = createLevelDropdown(counts);
insertDropdownIntoPage(state.dropdown);
}
// ============================================
// SECTION 6: FILTERING LOGIC
// ============================================
/**
* Setup queue manipulation using wkQueue
*/
function setupQueueFilter() {
if (!window.wkQueue || !window.wkQueue.addTotalChange) {
return;
}
if (state.queueFilterOwner === window.wkQueue && state.queueFilterRegistered) {
return;
}
// Register our filter callback
window.wkQueue.addTotalChange(filterQueueByLevel, {
openFramework: true,
openFrameworkGetItemsConfig: 'assignments'
});
state.queueFilterOwner = window.wkQueue;
state.queueFilterRegistered = true;
}
/**
* Filter the queue to only include items from the selected level
* This function is called by wkQueue whenever the queue changes
*/
function filterQueueByLevel(queue) {
// Selection decides which items stay; sorting is applied once, uniformly.
return sortQueueBySrs(selectQueueForLevel(queue));
}
/**
* Pick the set of queue items to review based on the selected level, updating
* the tracking state and UI as a side effect. Returns the (unsorted) queue.
* @param {Array} queue - The current review queue
* @returns {Array} The queue items to review
*/
function selectQueueForLevel(queue) {
const selectedLevel = getSelectedLevel();
// Remove empty queue styling first
clearEmptyQueueUI();
// Track what levels are available in the current queue, and remember every
// subject we have ever seen so the statistics can work out each level's
// session total (see SECTION 10.5).
state.currentQueueLevels.clear();
state.currentQueueLevelCounts = {};
const idsStillQueued = new Set();
for (const queueItem of queue) {
const itemLevel = getQueueItemLevel(queueItem);
if (itemLevel !== null) {
state.currentQueueLevels.add(itemLevel);
state.currentQueueLevelCounts[itemLevel] = (state.currentQueueLevelCounts[itemLevel] || 0) + 1;
const subjectId = recordSessionSubject(itemLevel, queueItem);
if (subjectId !== null) {
idsStillQueued.add(subjectId);
}
}
}
reconcileCompletedSubjects(idsStillQueued);
// Ensure UI exists and update dropdown to reflect current queue state. The
// menu now lives inside the quiz subtree, which Turbo can replace wholesale
// and take the menu with it, so rebuild whenever it has left the document
// rather than trusting the state reference to still be attached.
if (!state.dropdown || !document.getElementById(UI_IDS.container)) {
state.dropdown = null;
setupUI();
}
updateDropdownOptions();
// If "all" or no selection, return the full queue
if (!selectedLevel || selectedLevel === 'all') {
return queue;
}
const selectedLevelNum = Number.parseInt(selectedLevel, 10);
if (!Number.isFinite(selectedLevelNum)) {
return queue;
}
// Filter queue items based on level
const filteredQueue = queue.filter(queueItem => {
const itemLevel = getQueueItemLevel(queueItem);
return itemLevel === selectedLevelNum;
});
// If no items match, find the closest level with items in the current queue
if (filteredQueue.length === 0) {
// The queue holds nothing for this level, so it is done - but only trust
// that if we recognised levels at all. Before the item data loads every
// item's level is unknown, which would otherwise look like every level
// being finished at once.
if (state.currentQueueLevels.size > 0) {
markLevelExhausted(selectedLevelNum);
}
const closestLevel = findClosestLevelWithItems(queue, selectedLevelNum);
if (closestLevel !== null) {
// Filter for the new level
const newLevelQueue = queue.filter(queueItem => {
const itemLevel = getQueueItemLevel(queueItem);
return itemLevel === closestLevel;
});
// Show notification to user with accurate count
showLevelSwitchNotification(selectedLevelNum, closestLevel, newLevelQueue.length);
// Update the saved level
saveSelectedLevel(closestLevel.toString());
// Update the dropdown UI
if (state.dropdown) {
state.dropdown.value = closestLevel.toString();
}
return newLevelQueue;
}
// No levels have items at all - show message. Move the menu out of the
// quiz subtree first, since the class about to be added hides it.
enterEmptyQueueLayout();
document.body.classList.add(EMPTY_QUEUE_CLASS);
showNoItemsMessage();
return queue; // Return original queue to prevent redirect
}
return filteredQueue;
}
/**
* Find the closest level that has available items in the current queue
* @param {Array} queue - The current review queue
* @param {number} targetLevel - The level to find closest match for
* @returns {number|null} Closest level with items, or null if none
*/
function findClosestLevelWithItems(queue, targetLevel) {
// Build a set of levels that actually have items left to do. A level the
// user has already finished still appears in the queue (see
// state.exhaustedLevels), so it must be excluded or we would hand the user
// straight back to a level they just cleared.
const levelsWithItems = new Set();
for (const queueItem of queue) {
const itemLevel = getQueueItemLevel(queueItem);
if (itemLevel !== null && itemLevel !== targetLevel && !isLevelExhausted(itemLevel)) {
levelsWithItems.add(itemLevel);
}
}
if (levelsWithItems.size === 0) {
return null;
}