diff --git a/assets/Defer.js b/assets/Defer.js new file mode 100644 index 0000000..71c9d8c --- /dev/null +++ b/assets/Defer.js @@ -0,0 +1,87 @@ +export default function (count = 20) { + return { + data() { + return { + displayPriority: 0, + }; + }, + + mounted() { + this.runDisplayPriority(); + }, + + computed: { + numShuffles() { + return this.$store.state.numShuffles; + }, + + layout() { + return this.$store.state.layout; + }, + + showNsfw() { + return this.$store.state.showNsfw; + }, + + filterNsfw() { + return this.$store.state.filterNsfw; + }, + }, + + watch: { + // Watch the store values that cause the most intense operations + // when they change so mounting posts can be deferred. The display + // priority val has to be reset each time deferring is needed. + numShuffles: { + deep: false, + handler() { + this.resetDisplayPriority(); + }, + }, + + layout: { + deep: false, + handler() { + this.resetDisplayPriority(); + }, + }, + + showNsfw: { + deep: false, + handler() { + this.resetDisplayPriority(); + }, + }, + + filterNsfw: { + deep: false, + handler() { + this.resetDisplayPriority(); + }, + }, + }, + + methods: { + runDisplayPriority() { + const step = () => { + requestAnimationFrame(() => { + this.displayPriority++; + if (this.displayPriority < count) { + step(); + } + }); + } + step(); + }, + + defer(priority) { + return this.displayPriority >= priority; + }, + + resetDisplayPriority() { + this.displayPriority = 0; + this.runDisplayPriority(); + } + }, + }; +} diff --git a/assets/Shuffle.js b/assets/Shuffle.js new file mode 100644 index 0000000..430d4c5 --- /dev/null +++ b/assets/Shuffle.js @@ -0,0 +1,35 @@ +// Randomize array elements without mutating the original array using the +// Durstenfeld shuffle algorithm. +export function shuffleArray(array) { + let shuffledArray = [...array]; + for (let i = shuffledArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]]; + } + return shuffledArray; +} + +// Randomize array elements without mutating the original array using a +// modification of Mike Bostock's implementation of the Fisher-Yates algorithm. +export function shuffleArrayWithSeed(array, seed) { + let shuffledArray = [...array]; + let toShuffleCount = shuffledArray.length; + + while (toShuffleCount) { + let randIndex = Math.floor(random(seed) * toShuffleCount--); + + let swapTmp = shuffledArray[toShuffleCount]; + shuffledArray[toShuffleCount] = shuffledArray[randIndex]; + shuffledArray[randIndex] = swapTmp; + + ++seed; + } + + return shuffledArray; +} + +function random(seed) { + let x = Math.sin(seed++) * 10000; + let r = x - Math.floor(x); + return r; +} diff --git a/components/Grid.vue b/components/Grid.vue index 7c8630d..fa4e67a 100644 --- a/components/Grid.vue +++ b/components/Grid.vue @@ -13,13 +13,23 @@ :gutter="{ default: '30px' }" > + @@ -97,6 +107,7 @@ diff --git a/components/Toolbar.vue b/components/Toolbar.vue index 5eda9b1..d1a98de 100644 --- a/components/Toolbar.vue +++ b/components/Toolbar.vue @@ -18,6 +18,42 @@ + + + + @@ -64,12 +106,6 @@ z-index: 1; } - button { - color: var(--color-button-dark-text); - background: transparent; - border: none; - } - .toolbar__controls { display: flex; @@ -84,6 +120,8 @@ justify-content: center; align-items: center; + @include font-main(); + @include respond-above(sm) { padding-left: 0; @@ -114,7 +152,6 @@ height: 40px; padding: 0 20px; - @include font-main(); font-size: 18px; line-height: 40px; @@ -147,20 +184,52 @@ color: var(--color-button-text); span { - @include font-main(); font-size: 16px; line-height: 1; } } - .toolbar__reload, .toolbar__menu { + .toolbar__tooltip { + position: absolute; + opacity: 0; + z-index: 2; + + bottom: -25px; + left: 0px; + + padding: 6px; + + font-size: small; + white-space: nowrap; + + border-radius: 5px; + color: var(--color-text-copy); + background-color: var(--color-background-faded); + + transition: opacity .1s; + } + + button { + position: relative; + + color: var(--color-button-dark-text); + background: transparent; + border: none; + + &:hover .toolbar__tooltip { + opacity: 1; + transition: opacity .1s linear 1s; + } + } + .toolbar__reload, .toolbar__menu, .toolbar__extra-icon { flex: 0 0 auto; - margin-left: 10px; + margin-left: 5px; + padding: 0; - width: 40px; - height: 40px; + width: 35px; + height: 35px; border: none; background: none; @@ -171,8 +240,6 @@ .toolbar__menu { position: relative; - margin-left: 0; - &:after { content: ''; @@ -200,8 +267,6 @@ } .toolbar__reload { - margin-left: 12px; - &.toolbar__reload--loading { i { opacity: 0.4; @@ -213,6 +278,21 @@ } } } + + @keyframes grow { + 0% { + scale: 0; + margin-left: 0; + } + 100% { + scale: 1; + margin-left: 12px; + } + } + + .toolbar__extra-icon { + animation: grow .1s; + } diff --git a/store/index.js b/store/index.js index 755b5b5..84d6884 100644 --- a/store/index.js +++ b/store/index.js @@ -4,6 +4,7 @@ import decode from 'unescape'; import sanitizeHtml from 'sanitize-html'; import aes from 'crypto-js/aes'; import encUtf8 from 'crypto-js/enc-utf8'; +import { shuffleArray, shuffleArrayWithSeed } from '~/assets/Shuffle'; // When enabled, use naive filtering/searching algorithm instead of Fuse.js. const useNaiveFilter = false; @@ -78,6 +79,31 @@ export const state = () => ({ * Toast. */ toast: null, + + /** + * The number of times the user has shuffled posts. + */ + numShuffles: 0, + + /** + * The number of shuffled posts to show at a time. 30 is the default because + * it is a good middle ground between only showing a few shuffled posts and + * showing all shuffled posts while still being clear to the user that not + * all posts have been shuffled. Shuffling all posts is not the default + * because it can hurt performance to do it repeatedly with a lot of posts + * (more than several hundred because of the time to unmount then remount + * that many elements at once). If users want to shuffle all posts at + * once, they will have to navigate to the menu and see the message about it + * impacting performance first - not to dicourage them from shuffling all + * posts, but to make sure they are not shuffling all posts if what they + * really want is just a few shuffled at a time. + */ + numShuffledPosts: 30, + + /** + * The seed to use when shuffling posts with a seed. + */ + shuffleSeed: Math.floor(Math.random() * 100), }); /** @@ -90,6 +116,8 @@ function saveSettings(state) { filterNsfw: state.filterNsfw, subreddits: state.subreddits, layout: state.layout, + numShuffledPosts: state.numShuffledPosts, + shuffleSeed: state.shuffleSeed, })); } @@ -104,14 +132,19 @@ function restoreSettings(state) { savedSettings = JSON.parse(savedSettings); } catch (e) { savedSettings = null; + } + + if (savedSettings) { + state.theme = savedSettings.theme != undefined ? savedSettings.theme : state.theme; + state.showNsfw = savedSettings.showNsfw != undefined ? savedSettings.showNsfw : state.showNsfw; + state.filterNsfw = savedSettings.filterNsfw != undefined ? savedSettings.filterNsfw : state.filterNsfw; + state.subreddits = savedSettings.subreddits != undefined ? savedSettings.subreddits : state.subreddits; + state.numShuffledPosts = savedSettings.numShuffledPosts != undefined ? savedSettings.numShuffledPosts : state.numShuffledPosts; + // only restore the shuffle seed if it was set to null by the user + state.shuffleSeed = savedSettings.shuffleSeed === null ? null : state.shuffleSeed; + + state.layout = savedSettings.layout ? savedSettings.layout : 'fixed'; } - - state.theme = savedSettings && savedSettings.theme != null ? savedSettings.theme : state.theme; - state.showNsfw = savedSettings && savedSettings.showNsfw != null ? savedSettings.showNsfw : state.showNsfw; - state.filterNsfw = savedSettings && savedSettings.showNsfw != null ? savedSettings.filterNsfw : state.filterNsfw; - state.subreddits = savedSettings && savedSettings.showNsfw != null ? savedSettings.subreddits : state.subreddits; - - state.layout = savedSettings && savedSettings.layout ? savedSettings.layout : 'fixed'; } /** @@ -276,6 +309,32 @@ export const getters = { posts = posts.filter(p => validSubreddits.includes(p.subreddit)); } + // Apply randomized shuffle. Do this last so users can shuffle a subset + // of posts based on search, filters, or subreddits. + if (state.numShuffles && posts.length) { + const shuffleAllPosts = + state.numShuffledPosts <= 0 || + state.numShuffledPosts >= posts.length; + + const useTrueRandomization = state.shuffleSeed === null; + + if (shuffleAllPosts) { + posts = shuffleArray(posts); + } else if (useTrueRandomization) { + posts = shuffleArray(posts).slice(0, state.numShuffledPosts); + } else { + // Guaruntees no post is shown twice before all other posts have + // been shown. + posts = shuffleArrayWithSeed(posts, state.shuffleSeed); + + let start = state.numShuffles * state.numShuffledPosts % posts.length; + let end = start + state.numShuffledPosts; + let overflow = Math.max(end - posts.length, 0); + + posts = [...posts.slice(start, end), ...posts.slice(0, overflow)]; + } + } + return posts; }, }; @@ -406,6 +465,26 @@ export const mutations = { message, }; }, + + SET_SHUFFLE(state) { + state.numShuffles++; + }, + + RESET_SHUFFLE(state) { + state.numShuffles = 0; + }, + + SET_NUM_SHUFFLED_POSTS(state, num) { + state.numShuffledPosts = num; + + saveSettings(state); + }, + + SET_SHUFFLE_SEED(state, seed) { + state.shuffleSeed = seed; + + saveSettings(state); + }, }; export const actions = { @@ -694,4 +773,41 @@ export const actions = { setTheme({ commit }, theme) { commit('SET_THEME', theme); }, + + /** + * Sets true randomization true or false, changing the shuffle seed when + * setting to false so users get a new seeded shuffle every time they + * toggle the option off and back on. + */ + setTrueRandomization({ commit }, trueRandomization) { + if (trueRandomization) { + commit('SET_SHUFFLE_SEED', null); + } else { + commit('SET_SHUFFLE_SEED', Math.floor(Math.random() * 100)); + } + }, + + /** + * Shuffles posts. + */ + shuffle({ commit }) { + commit('SET_SHUFFLE'); + }, + + /** + * Resets shuffled posts so they are displayed in original order again. + */ + resetShuffle({ commit, state }) { + commit('RESET_SHUFFLE'); + if (state.shuffleSeed) { + commit('SET_SHUFFLE_SEED', Math.floor(Math.random() * 100)); + } + }, + + /** + * Sets the number of shuffled posts to display. + */ + setNumShuffledPosts({ commit }, num) { + commit('SET_NUM_SHUFFLED_POSTS', num); + }, };