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,293 +1,264 @@
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
*/
init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
$('#page-select').change(() => { this.items = dimensions.map((d, i) => {
const p = parseInt($('#page-select').val()); return {
toPage(p); id: i + 1,
}); url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
});
$('#mode-select').change(() => { const avgRatio = this.items.reduce((acc, cur) => {
const mode = $('#mode-select').val(); return acc + cur.height / cur.width
const curIdx = parseInt($('#page-select').val()); }, 0) / this.items.length;
updateMode(mode, curIdx); console.log(avgRatio);
}); this.longPages = avgRatio > 2;
}); this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
$(window).resize(() => { // Here we save a copy of this.mode, and use the copy as
const mode = getProp('mode'); // the model-select value. This is because `updateMode`
if (mode === 'continuous') return; // might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
})
},
/**
* Handles the `change` event for the page selector
*/
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());
const wideScreen = $(window).width() > $(window).height(); this.updateMode(mode, curIdx, nextTick);
const propMode = wideScreen ? 'height' : 'width'; },
setProp('mode', propMode); /**
}); * Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
/** const wideScreen = $(window).width() > $(window).height();
* Update the reader mode this.mode = wideScreen ? 'height' : 'width';
* },
* @function updateMode /**
* @param {string} mode - The mode. Can be one of the followings: * Handles the window `keydown` event
* {'continuous', 'paged', 'height', 'width'} *
* @param {number} targetPage - The one-based index of the target page * @param {Event} event - The triggering event
*/ */
const updateMode = (mode, targetPage) => { keyHandler(event) {
localStorage.setItem('mode', mode); if (this.mode === 'continuous') return;
// The mode to be put into the `mode` prop. It can't be `screen` if (event.key === 'ArrowLeft' || event.key === 'k')
let propMode = mode; 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;
if (mode === 'paged') { this.toPage(newIdx);
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
setProp('mode', propMode); if (isNext)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
if (mode === 'continuous') { setTimeout(() => {
waitForPage(items.length, () => { this.flipAnimation = null;
setupScroller(); }, 500);
});
}
waitForPage(targetPage, () => { this.replaceHistory(newIdx);
setTimeout(() => { },
toPage(targetPage); /**
}, 100); * Jumps to a specific page
}); *
}; * @param {number} idx - One-based index of the page
*/
/** toPage(idx) {
* Get dimension of the pages in the entry from the API and update the view if (this.mode === 'continuous') {
*/ $(`#${idx}`).get(0).scrollIntoView(true);
const getPages = () => { } else {
$.get(`${base_url}api/dimensions/${tid}/${eid}`) if (idx >= 1 && idx <= this.items.length) {
.then(data => { this.curItem = this.items[idx - 1];
if (!data.success && data.error) }
throw new Error(resp.error);
const dimensions = data.dimensions;
items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width,
height: d.height
};
});
const avgRatio = items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / items.length;
console.log(avgRatio);
longPages = avgRatio > 2;
setProp('items', items);
setProp('loading', false);
const storedMode = localStorage.getItem('mode') || 'continuous';
setProp('mode', storedMode);
updateMode(storedMode, page);
$('#mode-select').val(storedMode);
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
setProp('alertClass', 'uk-alert-danger');
setProp('msg', errMsg);
})
};
/**
* Jump to a specific page
*
* @function toPage
* @param {number} idx - One-based index of the page
*/
const toPage = (idx) => {
const mode = getProp('mode');
if (mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= items.length) {
setProp('curItem', items[idx - 1]);
}
}
replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
};
/**
* 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 {function} cb - Callback function
*/
const waitForPage = (idx, cb) => {
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('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
saveProgress(idx);
history.replaceState(null, "", url);
}
/**
* 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);
} }
}); this.replaceHistory(idx);
}); UIkit.modal($('#modal-sections')).hide();
}; },
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
/** const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
* Update the backend reading progress if: $.ajax({
* 1) the current page is more than five pages away from the last method: 'PUT',
* saved page, or url: url,
* 2) the average height/width ratio of the pages is over 2, or dataType: 'json'
* 3) the current page is the first page, or })
* 4) the current page is the last page .done(data => {
* if (data.error)
* @function saveProgress alert('danger', data.error);
* @param {number} idx - One-based index of the page if (cb) cb();
* @param {function} cb - Callback })
*/ .fail((jqXHR, status) => {
const saveProgress = (idx, cb) => { alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
idx = parseInt(idx); });
if (Math.abs(idx - lastSavedPage) >= 5 || }
longPages || },
idx === 1 || idx === items.length /**
) { * Updates the reader mode
lastSavedPage = idx; *
console.log('saving progress', idx); * @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);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`; // The mode to be put into the `mode` prop. It can't be `screen`
$.ajax({ let propMode = mode;
method: 'PUT',
url: url, if (mode === 'paged') {
dataType: 'json' const wideScreen = $(window).width() > $(window).height();
}) propMode = wideScreen ? 'height' : 'width';
.done(data => { }
if (data.error)
alert('danger', data.error); this.mode = propMode;
if (cb) cb();
}) if (mode === 'continuous') {
.fail((jqXHR, status) => { nextTick(() => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); 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];
* Mark progress to 100% and redirect to the next entry this.replaceHistory(current);
* Used as the onclick handler for the "Next Entry" button }
* });
* @function nextEntry });
* @param {string} nextUrl - URL of the next entry },
*/ /**
const nextEntry = (nextUrl) => { * Marks progress as 100% and jumps to the next entry
saveProgress(items.length, () => { *
redirect(nextUrl); * @param {string} nextUrl - URL of the next entry
}); */
}; nextEntry(nextUrl) {
this.saveProgress(items.length, () => {
/** 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,11 +45,11 @@
'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;
`" /> `" />
<div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div> <div style="position:absolute;z-index:1; top:0;left:0; width:30%;height:100%;" @click="flipPage(false)"></div>
<div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div> <div style="position:absolute;z-index:1; top:0;right:0; width:30%;height:100%;" @click="flipPage(true)"></div>
@ -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>