Rewrite reader.js with a reusable alpine function

This commit is contained in:
Alex Ling 2020-12-31 16:10:04 +00:00
parent e64908ad06
commit a68282b4bf
2 changed files with 257 additions and 297 deletions

View File

@ -1,77 +1,26 @@
let lastSavedPage = page; const readerComponent = () => {
let items = []; return {
let longPages = false; loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null,
longPages: false,
lastSavedPage: page,
$(() => { /**
getPages(); * Initialize the component by fetching the page dimensions
$('#page-select').change(() => {
const p = parseInt($('#page-select').val());
toPage(p);
});
$('#mode-select').change(() => {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
updateMode(mode, curIdx);
});
});
$(window).resize(() => {
const mode = getProp('mode');
if (mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
const propMode = wideScreen ? 'height' : 'width';
setProp('mode', propMode);
});
/**
* Update the reader mode
*
* @function updateMode
* @param {string} mode - The mode. Can be one of the followings:
* {'continuous', 'paged', 'height', 'width'}
* @param {number} targetPage - The one-based index of the target page
*/ */
const updateMode = (mode, targetPage) => { init(nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
setProp('mode', propMode);
if (mode === 'continuous') {
waitForPage(items.length, () => {
setupScroller();
});
}
waitForPage(targetPage, () => {
setTimeout(() => {
toPage(targetPage);
}, 100);
});
};
/**
* Get dimension of the pages in the entry from the API and update the view
*/
const getPages = () => {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) $.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => { .then(data => {
if (!data.success && data.error) if (!data.success && data.error)
throw new Error(resp.error); throw new Error(resp.error);
const dimensions = data.dimensions; const dimensions = data.dimensions;
items = dimensions.map((d, i) => { this.items = dimensions.map((d, i) => {
return { return {
id: i + 1, id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`, url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
@ -80,143 +29,141 @@ const getPages = () => {
}; };
}); });
const avgRatio = items.reduce((acc, cur) => { const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width return acc + cur.height / cur.width
}, 0) / items.length; }, 0) / this.items.length;
console.log(avgRatio); console.log(avgRatio);
longPages = avgRatio > 2; this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
setProp('items', items); // Here we save a copy of this.mode, and use the copy as
setProp('loading', false); // the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
const storedMode = localStorage.getItem('mode') || 'continuous'; // which are not available in mode-select
const mode = this.mode;
setProp('mode', storedMode); this.updateMode(this.mode, page, nextTick);
updateMode(storedMode, page); $('#mode-select').val(mode);
$('#mode-select').val(storedMode);
}) })
.catch(e => { .catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`; const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e); console.error(e);
setProp('alertClass', 'uk-alert-danger'); this.alertClass = 'uk-alert-danger';
setProp('msg', errMsg); this.msg = errMsg;
}) })
}; },
/**
/** * Handles the `change` event for the page selector
* Jump to a specific page */
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
this.toPage(newIdx);
if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
setTimeout(() => {
this.flipAnimation = null;
}, 500);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
* *
* @function toPage
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
*/ */
const toPage = (idx) => { toPage(idx) {
const mode = getProp('mode'); if (this.mode === 'continuous') {
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true); $(`#${idx}`).get(0).scrollIntoView(true);
} else { } else {
if (idx >= 1 && idx <= items.length) { if (idx >= 1 && idx <= this.items.length) {
setProp('curItem', items[idx - 1]); this.curItem = this.items[idx - 1];
} }
} }
replaceHistory(idx); this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide(); UIkit.modal($('#modal-sections')).hide();
}; },
/**
/** * Replace the address bar history and save the reading progress if necessary
* Check if a page exists every 100ms. If so, invoke the callback function.
* *
* @function waitForPage
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback function
*/ */
const waitForPage = (idx, cb) => { replaceHistory(idx) {
if ($(`#${idx}`).length > 0) return cb();
setTimeout(() => {
waitForPage(idx, cb)
}, 100);
};
/**
* Show the control modal
*
* @function showControl
* @param {string} idx - One-based index of the current page
*/
const showControl = (idx) => {
const pageCount = $('#page-select > option').length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
}
/**
* Redirect to a URL
*
* @function redirect
* @param {string} url - The target URL
*/
const redirect = (url) => {
window.location.replace(url);
}
/**
* Replace the address bar history and save th ereading progress if necessary
*
* @function replaceHistory
* @param {number} idx - One-based index of the current page
*/
const replaceHistory = (idx) => {
const ary = window.location.pathname.split('/'); const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx; ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/` ary.shift(); // remove leading `/`
ary.unshift(window.location.origin); ary.unshift(window.location.origin);
const url = ary.join('/'); const url = ary.join('/');
saveProgress(idx); this.saveProgress(idx);
history.replaceState(null, "", url); history.replaceState(null, "", url);
} },
/**
/** * Updates the backend reading progress if:
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*
* @function setupScroller
*/
const setupScroller = () => {
const mode = getProp('mode');
if (mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
setProp('curItem', getProp('items')[current - 1]);
replaceHistory(current);
}
});
});
};
/**
* Update the backend reading progress if:
* 1) the current page is more than five pages away from the last * 1) the current page is more than five pages away from the last
* saved page, or * saved page, or
* 2) the average height/width ratio of the pages is over 2, or * 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or * 3) the current page is the first page, or
* 4) the current page is the last page * 4) the current page is the last page
* *
* @function saveProgress
* @param {number} idx - One-based index of the page * @param {number} idx - One-based index of the page
* @param {function} cb - Callback * @param {function} cb - Callback
*/ */
const saveProgress = (idx, cb) => { saveProgress(idx, cb) {
idx = parseInt(idx); idx = parseInt(idx);
if (Math.abs(idx - lastSavedPage) >= 5 || if (Math.abs(idx - this.lastSavedPage) >= 5 ||
longPages || this.longPages ||
idx === 1 || idx === items.length idx === 1 || idx === this.items.length
) { ) {
lastSavedPage = idx; this.lastSavedPage = idx;
console.log('saving progress', idx); console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`; const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
@ -234,60 +181,84 @@ const saveProgress = (idx, cb) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
}); });
} }
}; },
/**
/** * Updates the reader mode
* Mark progress to 100% and redirect to the next entry *
* Used as the onclick handler for the "Next Entry" button * @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
showControl(event) {
const idx = event.currentTarget.id;
const pageCount = this.items.length;
const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`;
$('#progress-label').text(progressText);
$('#page-select').val(idx);
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('#root img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
* *
* @function nextEntry
* @param {string} nextUrl - URL of the next entry * @param {string} nextUrl - URL of the next entry
*/ */
const nextEntry = (nextUrl) => { nextEntry(nextUrl) {
saveProgress(items.length, () => { this.saveProgress(items.length, () => {
redirect(nextUrl); this.redirect(nextUrl);
}); });
}; }
};
/** }
* Show the next or the previous page
*
* @function flipPage
* @param {bool} isNext - Whether we are going to the next page
*/
const flipPage = (isNext) => {
const curItem = getProp('curItem');
const idx = parseInt(curItem.id);
const delta = isNext ? 1 : -1;
const newIdx = idx + delta;
toPage(newIdx);
if (isNext)
setProp('flipAnimation', 'right');
else
setProp('flipAnimation', 'left');
setTimeout(() => {
setProp('flipAnimation', null);
}, 500);
replaceHistory(newIdx);
saveProgress(newIdx);
};
/**
* Handle the global keydown events
*
* @function keyHandler
* @param {event} event - The $event object
*/
const keyHandler = (event) => {
const mode = getProp('mode');
if (mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
flipPage(false);
if (event.key === 'ArrowRight' || event.key === 'j')
flipPage(true);
};

View File

@ -4,19 +4,8 @@
<% page = "Reader" %> <% page = "Reader" %>
<%= render_component "head" %> <%= render_component "head" %>
<body style="position:relative;"> <body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" <div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
id="root"
:style="mode === 'continuous' ? '' : 'padding:0'"
x-data="{
loading: true,
mode: 'continuous', // can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
flipAnimation: null
}">
<div @keydown.window.debounce="keyHandler($event)"></div> <div @keydown.window.debounce="keyHandler($event)"></div>
@ -40,7 +29,7 @@
:width="item.width" :width="item.width"
:height="item.height" :height="item.height"
:id="item.id" :id="item.id"
:onclick="`showControl('${item.id}')`" @click="showControl($event)"
/> />
</template> </template>
<%- if next_entry_url -%> <%- if next_entry_url -%>
@ -56,7 +45,7 @@
'uk-align-center': true, 'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right' 'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="` }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'}; width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0; margin-bottom:0;
@ -83,7 +72,7 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="page-select">Jump to page</label> <label class="uk-form-label" for="page-select">Jump to page</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="page-select" class="uk-select"> <select id="page-select" class="uk-select" @change="pageChanged()">
<%- (1..entry.pages).each do |p| -%> <%- (1..entry.pages).each do |p| -%>
<option value="<%= p %>"><%= p %></option> <option value="<%= p %>"><%= p %></option>
<%- end -%> <%- end -%>
@ -93,7 +82,7 @@
<div class="uk-margin"> <div class="uk-margin">
<label class="uk-form-label" for="mode-select">Mode</label> <label class="uk-form-label" for="mode-select">Mode</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<select id="mode-select" class="uk-select"> <select id="mode-select" class="uk-select" @change="modeChanged($nextTick)">
<option value="continuous">Continuous</option> <option value="continuous">Continuous</option>
<option value="paged">Paged</option> <option value="paged">Paged</option>
</select> </select>