diff --git a/public/js/hyperlist.js b/public/js/hyperlist.js new file mode 100644 index 0000000..19cfa44 --- /dev/null +++ b/public/js/hyperlist.js @@ -0,0 +1,590 @@ +(function(f) { + if (typeof exports === "object" && typeof module !== "undefined") { + module.exports = f() + } else if (typeof define === "function" && define.amd) { + define([], f) + } else { + var g; + if (typeof window !== "undefined") { + g = window + } else if (typeof global !== "undefined") { + g = global + } else if (typeof self !== "undefined") { + g = self + } else { + g = this + } + g.HyperList = f() + } +})(function() { + var define, module, exports; + return (function() { + function r(e, n, t) { + function o(i, f) { + if (!n[i]) { + if (!e[i]) { + var c = "function" == typeof require && require; + if (!f && c) return c(i, !0); + if (u) return u(i, !0); + var a = new Error("Cannot find module '" + i + "'"); + throw a.code = "MODULE_NOT_FOUND", a + } + var p = n[i] = { + exports: {} + }; + e[i][0].call(p.exports, function(r) { + var n = e[i][1][r]; + return o(n || r) + }, p, p.exports, r, e, n, t) + } + return n[i].exports + } + for (var u = "function" == typeof require && require, i = 0; i < t.length; i++) o(t[i]); + return o + } + return r + })()({ + 1: [function(_dereq_, module, exports) { + 'use strict'; + + // Default configuration. + + Object.defineProperty(exports, "__esModule", { + value: true + }); + + var _createClass = function() { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + return function(Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; + }(); + + function _defineProperty(obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + return obj; + } + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + var defaultConfig = { + width: '100%', + height: '100%' + + // Check for valid number. + }; + var isNumber = function isNumber(input) { + return Number(input) === Number(input); + }; + + // Add a class to an element. + var addClass = 'classList' in document.documentElement ? function(element, className) { + element.classList.add(className); + } : function(element, className) { + var oldClass = element.getAttribute('class') || ''; + element.setAttribute('class', oldClass + ' ' + className); + }; + + /** + * Creates a HyperList instance that virtually scrolls very large amounts of + * data effortlessly. + */ + + var HyperList = function() { + _createClass(HyperList, null, [{ + key: 'create', + value: function create(element, userProvidedConfig) { + return new HyperList(element, userProvidedConfig); + } + + /** + * Merge given css style on an element + * @param {DOMElement} element + * @param {Object} style + */ + + }, { + key: 'mergeStyle', + value: function mergeStyle(element, style) { + for (var i in style) { + if (element.style[i] !== style[i]) { + element.style[i] = style[i]; + } + } + } + }, { + key: 'getMaxBrowserHeight', + value: function getMaxBrowserHeight() { + // Create two elements, the wrapper is `1px` tall and is transparent and + // positioned at the top of the page. Inside that is an element that gets + // set to 1 billion pixels. Then reads the max height the browser can + // calculate. + var wrapper = document.createElement('div'); + var fixture = document.createElement('div'); + + // As said above, these values get set to put the fixture elements into the + // right visual state. + HyperList.mergeStyle(wrapper, { + position: 'absolute', + height: '1px', + opacity: 0 + }); + HyperList.mergeStyle(fixture, { + height: '1e7px' + }); + + // Add the fixture into the wrapper element. + wrapper.appendChild(fixture); + + // Apply to the page, the values won't kick in unless this is attached. + document.body.appendChild(wrapper); + + // Get the maximum element height in pixels. + var maxElementHeight = fixture.offsetHeight; + + // Remove the element immediately after reading the value. + document.body.removeChild(wrapper); + + return maxElementHeight; + } + }]); + + function HyperList(element, userProvidedConfig) { + var _this = this; + + _classCallCheck(this, HyperList); + + this._config = {}; + this._lastRepaint = null; + this._maxElementHeight = HyperList.getMaxBrowserHeight(); + + this.refresh(element, userProvidedConfig); + + var config = this._config; + + // Create internal render loop. + var render = function render() { + var scrollTop = _this._getScrollPosition(); + var lastRepaint = _this._lastRepaint; + + _this._renderAnimationFrame = window.requestAnimationFrame(render); + + if (scrollTop === lastRepaint) { + return; + } + + var diff = lastRepaint ? scrollTop - lastRepaint : 0; + if (!lastRepaint || diff < 0 || diff > _this._averageHeight) { + var rendered = _this._renderChunk(); + + _this._lastRepaint = scrollTop; + + if (rendered !== false && typeof config.afterRender === 'function') { + config.afterRender(); + } + } + }; + + render(); + } + + _createClass(HyperList, [{ + key: 'destroy', + value: function destroy() { + window.cancelAnimationFrame(this._renderAnimationFrame); + } + }, { + key: 'refresh', + value: function refresh(element, userProvidedConfig) { + var _scrollerStyle; + + Object.assign(this._config, defaultConfig, userProvidedConfig); + + if (!element || element.nodeType !== 1) { + throw new Error('HyperList requires a valid DOM Node container'); + } + + this._element = element; + + var config = this._config; + + var scroller = this._scroller || config.scroller || document.createElement(config.scrollerTagName || 'tr'); + + // Default configuration option `useFragment` to `true`. + if (typeof config.useFragment !== 'boolean') { + this._config.useFragment = true; + } + + if (!config.generate) { + throw new Error('Missing required `generate` function'); + } + + if (!isNumber(config.total)) { + throw new Error('Invalid required `total` value, expected number'); + } + + if (!Array.isArray(config.itemHeight) && !isNumber(config.itemHeight)) { + throw new Error('\n Invalid required `itemHeight` value, expected number or array\n '.trim()); + } else if (isNumber(config.itemHeight)) { + this._itemHeights = Array(config.total).fill(config.itemHeight); + } else { + this._itemHeights = config.itemHeight; + } + + // Width and height should be coerced to string representations. Either in + // `%` or `px`. + Object.keys(defaultConfig).filter(function(prop) { + return prop in config; + }).forEach(function(prop) { + var value = config[prop]; + var isValueNumber = isNumber(value); + + if (value && typeof value !== 'string' && typeof value !== 'number') { + var msg = 'Invalid optional `' + prop + '`, expected string or number'; + throw new Error(msg); + } else if (isValueNumber) { + config[prop] = value + 'px'; + } + }); + + var isHoriz = Boolean(config.horizontal); + var value = config[isHoriz ? 'width' : 'height']; + + if (value) { + var isValueNumber = isNumber(value); + var isValuePercent = isValueNumber ? false : value.slice(-1) === '%'; + // Compute the containerHeight as number + var numberValue = isValueNumber ? value : parseInt(value.replace(/px|%/, ''), 10); + var innerSize = window[isHoriz ? 'innerWidth' : 'innerHeight']; + + if (isValuePercent) { + this._containerSize = innerSize * numberValue / 100; + } else { + this._containerSize = isNumber(value) ? value : numberValue; + } + } + + var scrollContainer = config.scrollContainer; + var scrollerHeight = config.itemHeight * config.total; + var maxElementHeight = this._maxElementHeight; + + if (scrollerHeight > maxElementHeight) { + console.warn(['HyperList: The maximum element height', maxElementHeight + 'px has', 'been exceeded; please reduce your item height.'].join(' ')); + } + + // Decorate the container element with styles that will match + // the user supplied configuration. + var elementStyle = { + width: '' + config.width, + height: scrollContainer ? scrollerHeight + 'px' : '' + config.height, + overflow: scrollContainer ? 'none' : 'auto', + position: 'relative' + }; + + HyperList.mergeStyle(element, elementStyle); + + if (scrollContainer) { + HyperList.mergeStyle(config.scrollContainer, { + overflow: 'auto' + }); + } + + var scrollerStyle = (_scrollerStyle = { + opacity: '0', + position: 'absolute' + }, _defineProperty(_scrollerStyle, isHoriz ? 'height' : 'width', '1px'), _defineProperty(_scrollerStyle, isHoriz ? 'width' : 'height', scrollerHeight + 'px'), _scrollerStyle); + + HyperList.mergeStyle(scroller, scrollerStyle); + + // Only append the scroller element once. + if (!this._scroller) { + element.appendChild(scroller); + } + + var padding = this._computeScrollPadding(); + this._scrollPaddingBottom = padding.bottom; + this._scrollPaddingTop = padding.top; + + // Set the scroller instance. + this._scroller = scroller; + this._scrollHeight = this._computeScrollHeight(); + + // Reuse the item positions if refreshed, otherwise set to empty array. + this._itemPositions = this._itemPositions || Array(config.total).fill(0); + + // Each index in the array should represent the position in the DOM. + this._computePositions(0); + + // Render after refreshing. Force render if we're calling refresh manually. + this._renderChunk(this._lastRepaint !== null); + + if (typeof config.afterRender === 'function') { + config.afterRender(); + } + } + }, { + key: '_getRow', + value: function _getRow(i) { + var config = this._config; + var item = config.generate(i); + var height = item.height; + + console.log(item); + + if (height !== undefined && isNumber(height)) { + item = item.element; + + // The height isn't the same as predicted, compute positions again + if (height !== this._itemHeights[i]) { + this._itemHeights[i] = height; + this._computePositions(i); + this._scrollHeight = this._computeScrollHeight(i); + } + } else { + height = this._itemHeights[i]; + } + + console.log(item); + + if (!item || item.nodeType !== 1) { + throw new Error('Generator did not return a DOM Node for index: ' + i); + } + + addClass(item, config.rowClassName || 'vrow'); + + var top = this._itemPositions[i] + this._scrollPaddingTop; + + HyperList.mergeStyle(item, _defineProperty({ + position: 'absolute' + }, config.horizontal ? 'left' : 'top', top + 'px')); + + return item; + } + }, { + key: '_getScrollPosition', + value: function _getScrollPosition() { + var config = this._config; + + if (typeof config.overrideScrollPosition === 'function') { + return config.overrideScrollPosition(); + } + + return this._element[config.horizontal ? 'scrollLeft' : 'scrollTop']; + } + }, { + key: '_renderChunk', + value: function _renderChunk(force) { + var config = this._config; + var element = this._element; + var scrollTop = this._getScrollPosition(); + var total = config.total; + + var from = config.reverse ? this._getReverseFrom(scrollTop) : this._getFrom(scrollTop) - 1; + + if (from < 0 || from - this._screenItemsLen < 0) { + from = 0; + } + + if (!force && this._lastFrom === from) { + return false; + } + + this._lastFrom = from; + + var to = from + this._cachedItemsLen; + + if (to > total || to + this._cachedItemsLen > total) { + to = total; + } + + // Append all the new rows in a document fragment that we will later append + // to the parent node + var fragment = config.useFragment ? document.createDocumentFragment() : [] + // Sometimes you'll pass fake elements to this tool and Fragments require + // real elements. + + + // The element that forces the container to scroll. + ; + var scroller = this._scroller; + + // Keep the scroller in the list of children. + fragment[config.useFragment ? 'appendChild' : 'push'](scroller); + + for (var i = from; i < to; i++) { + var row = this._getRow(i); + + fragment[config.useFragment ? 'appendChild' : 'push'](row); + } + + if (config.applyPatch) { + return config.applyPatch(element, fragment); + } + + element.innerHTML = ''; + element.appendChild(fragment); + } + }, { + key: '_computePositions', + value: function _computePositions() { + var from = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var config = this._config; + var total = config.total; + var reverse = config.reverse; + + if (from < 1 && !reverse) { + from = 1; + } + + for (var i = from; i < total; i++) { + if (reverse) { + if (i === 0) { + this._itemPositions[0] = this._scrollHeight - this._itemHeights[0]; + } else { + this._itemPositions[i] = this._itemPositions[i - 1] - this._itemHeights[i]; + } + } else { + this._itemPositions[i] = this._itemHeights[i - 1] + this._itemPositions[i - 1]; + } + } + } + }, { + key: '_computeScrollHeight', + value: function _computeScrollHeight() { + var _HyperList$mergeStyle2, + _this2 = this; + + var config = this._config; + var isHoriz = Boolean(config.horizontal); + var total = config.total; + var scrollHeight = this._itemHeights.reduce(function(a, b) { + return a + b; + }, 0) + this._scrollPaddingBottom + this._scrollPaddingTop; + + HyperList.mergeStyle(this._scroller, (_HyperList$mergeStyle2 = { + opacity: 0, + position: 'absolute', + top: '0px' + }, _defineProperty(_HyperList$mergeStyle2, isHoriz ? 'height' : 'width', '1px'), _defineProperty(_HyperList$mergeStyle2, isHoriz ? 'width' : 'height', scrollHeight + 'px'), _HyperList$mergeStyle2)); + + // Calculate the height median + var sortedItemHeights = this._itemHeights.slice(0).sort(function(a, b) { + return a - b; + }); + var middle = Math.floor(total / 2); + var averageHeight = total % 2 === 0 ? (sortedItemHeights[middle] + sortedItemHeights[middle - 1]) / 2 : sortedItemHeights[middle]; + + var clientProp = isHoriz ? 'clientWidth' : 'clientHeight'; + var element = config.scrollContainer ? config.scrollContainer : this._element; + var containerHeight = element[clientProp] ? element[clientProp] : this._containerSize; + this._screenItemsLen = Math.ceil(containerHeight / averageHeight); + this._containerSize = containerHeight; + + // Cache 3 times the number of items that fit in the container viewport. + this._cachedItemsLen = Math.max(this._cachedItemsLen || 0, this._screenItemsLen * 3); + this._averageHeight = averageHeight; + + if (config.reverse) { + window.requestAnimationFrame(function() { + if (isHoriz) { + _this2._element.scrollLeft = scrollHeight; + } else { + _this2._element.scrollTop = scrollHeight; + } + }); + } + + return scrollHeight; + } + }, { + key: '_computeScrollPadding', + value: function _computeScrollPadding() { + var config = this._config; + var isHoriz = Boolean(config.horizontal); + var isReverse = config.reverse; + var styles = window.getComputedStyle(this._element); + + var padding = function padding(location) { + var cssValue = styles.getPropertyValue('padding-' + location); + return parseInt(cssValue, 10) || 0; + }; + + if (isHoriz && isReverse) { + return { + bottom: padding('left'), + top: padding('right') + }; + } else if (isHoriz) { + return { + bottom: padding('right'), + top: padding('left') + }; + } else if (isReverse) { + return { + bottom: padding('top'), + top: padding('bottom') + }; + } else { + return { + bottom: padding('bottom'), + top: padding('top') + }; + } + } + }, { + key: '_getFrom', + value: function _getFrom(scrollTop) { + var i = 0; + + while (this._itemPositions[i] < scrollTop) { + i++; + } + + return i; + } + }, { + key: '_getReverseFrom', + value: function _getReverseFrom(scrollTop) { + var i = this._config.total - 1; + + while (i > 0 && this._itemPositions[i] < scrollTop + this._containerSize) { + i--; + } + + return i; + } + }]); + + return HyperList; + }(); + + exports.default = HyperList; + module.exports = exports['default']; + + }, {}] + }, {}, [1])(1) +}); diff --git a/public/js/reader.js b/public/js/reader.js index 932ce34..e86122d 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -1,64 +1,62 @@ -$(function() { - function bind() { - var controller = new ScrollMagic.Controller(); +$(() => { + getPages(); - // replace history on scroll - $('img').each(function(idx) { - var scene = new ScrollMagic.Scene({ - triggerElement: $(this).get(), - triggerHook: 'onEnter', - reverse: true - }) - .addTo(controller) - .on('enter', function(event) { - current = $(event.target.triggerElement()).attr('id'); - replaceHistory(current); - }) - .on('leave', function(event) { - var prev = $(event.target.triggerElement()).prev(); - current = $(prev).attr('id'); - replaceHistory(current); - }); - }); + $('#page-select').change(() => { + const p = parseInt($('#page-select').val()); + toPage(p); + }); +}); - // poor man's infinite scroll - var scene = new ScrollMagic.Scene({ - triggerElement: $('.next-url').get(), - triggerHook: 'onEnter', - offset: -500 - }) - .addTo(controller) - .on('enter', function() { - var nextURL = $('.next-url').attr('href'); - $('.next-url').remove(); - if (!nextURL) { - console.log('No .next-url found. Reached end of page'); - var lastURL = $('img').last().attr('id'); - // load the reader URL for the last page to update reading progrss to 100% - $.get(lastURL); - $('#next-btn').removeAttr('hidden'); - return; - } - $('#hidden').load(encodeURI(nextURL) + ' .uk-container', function(res, status, xhr) { - if (status === 'error') console.log(xhr.statusText); - if (status === 'success') { - console.log(nextURL + ' loaded'); - // new page loaded to #hidden, we now append it - $('.uk-section > .uk-container').append($('#hidden .uk-container').children()); - $('#hidden').empty(); - bind(); - } - }); +const getPages = () => { + $.get(`${base_url}api/dimensions/${tid}/${eid}`) + .then(data => { + if (!data.success && data.error) + throw new Error(resp.error); + const dimensions = data.dimensions; + + const items = dimensions.map((d, i) => { + return { + id: i + 1, + url: `${base_url}api/page/${tid}/${eid}/${i+1}`, + width: d.width, + height: d.height + }; }); - } - bind(); -}); -$('#page-select').change(function() { - jumpTo(parseInt($('#page-select').val())); -}); + setProp('items', items); + setProp('loading', false); -function showControl(idx) { + waitForPage(items.length, () => { + toPage(page); + setupScroller(); + }); + }) + .catch(e => { + const errMsg = `Failed to get the page dimensions. ${e}`; + console.error(e); + setProp('alertClass', 'uk-alert-danger'); + setProp('msg', errMsg); + }) +}; + +const toPage = (idx) => { + $(`#${idx}`).get(0).scrollIntoView(true); + UIkit.modal($('#modal-sections')).hide(); +}; + +const waitForPage = (idx, cb) => { + if ($(`#${idx}`).length > 0) return cb(); + setTimeout(() => { + waitForPage(idx, cb) + }, 100); +}; + +const setProp = (key, prop) => { + $('#root').get(0).__x.$data[key] = prop; +}; + +const showControl = (event) => { + const idx = parseInt($(event.currentTarget).attr('id')); const pageCount = $('#page-select > option').length; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; $('#progress-label').text(progressText); @@ -66,19 +64,43 @@ function showControl(idx) { UIkit.modal($('#modal-sections')).show(); } -function jumpTo(page) { - var ary = window.location.pathname.split('/'); - ary[ary.length - 1] = page; - ary.shift(); // remove leading `/` - ary.unshift(window.location.origin); - window.location.replace(ary.join('/')); -} - -function replaceHistory(url) { - history.replaceState(null, "", url); - console.log('reading ' + url); -} - -function redirect(url) { +const redirect = (url) => { window.location.replace(url); } + +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); +} + +const setupScroller = () => { + $('#root img').each((idx, el) => { + $(el).on('inview', (event, inView) => { + if (inView) { + const current = $(event.currentTarget).attr('id'); + replaceHistory(current); + } + }); + }); +}; + +let lastSavedPage = page; +const saveProgress = (idx) => { + if (Math.abs(idx - lastSavedPage) < 5) return; + lastSavedPage = idx; + + const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`; + $.post(url) + .then(data => { + if (data.error) throw new Error(data.error); + }) + .catch(e => { + console.error(e); + alert('danger', e); + }); +}; diff --git a/src/library/entry.cr b/src/library/entry.cr index 1f06e70..9dfe791 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -101,8 +101,8 @@ class Entry img end - def page_aspect_ratios - ratios = [] of Float64 + def page_dimensions + sizes = [] of Hash(String, Int32) ArchiveFile.open @zip_path do |file| file.entries .select { |e| @@ -116,14 +116,17 @@ class Entry begin data = file.read_entry(e).not_nil! size = ImageSize.get data - ratios << size.height / size.width + sizes << { + "width" => size.width, + "height" => size.height, + } rescue Logger.warn "Failed to read page #{i} of entry #{@id}" - ratios << 1_f64 + sizes << {"width" => 1000_i32, "height" => 1000_i32} end end end - ratios + sizes end def next_entry(username) diff --git a/src/routes/api.cr b/src/routes/api.cr index b8bd5db..a131a97 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -333,7 +333,7 @@ class APIRouter < Router end end - get "/api/ratios/:tid/:eid" do |env| + get "/api/dimensions/:tid/:eid" do |env| begin tid = env.params.url["tid"] eid = env.params.url["eid"] @@ -343,10 +343,10 @@ class APIRouter < Router entry = title.get_entry eid raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil? - ratios = entry.page_aspect_ratios + sizes = entry.page_dimensions send_json env, { - "success" => true, - "ratios" => ratios, + "success" => true, + "dimensions" => sizes, }.to_json rescue e send_json env, { diff --git a/src/routes/reader.cr b/src/routes/reader.cr index 50d6bcb..aa8bf48 100644 --- a/src/routes/reader.cr +++ b/src/routes/reader.cr @@ -13,10 +13,6 @@ class ReaderRouter < Router # load progress page = entry.load_progress username - # we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll - # library perloads a few pages in advance, and the user - # might not have actually read them - page = [page - 2 * IMGS_PER_PAGE, 1].max # start from page 1 if the user has finished reading the entry page = 1 if entry.finished? username @@ -32,29 +28,17 @@ class ReaderRouter < Router begin base_url = Config.current.base_url + username = get_username env + title = (@context.library.get_title env.params.url["title"]).not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil! page = env.params.url["page"].to_i raise "" if page > entry.pages || page <= 0 - # save progress - username = get_username env - entry.save_progress username, page - - pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) - urls = pages.map { |idx| - "#{base_url}api/page/#{title.id}/#{entry.id}/#{idx}" - } - reader_urls = pages.map { |idx| - "#{base_url}reader/#{title.id}/#{entry.id}/#{idx}" - } - next_page = page + IMGS_PER_PAGE - next_url = next_entry_url = nil exit_url = "#{base_url}book/#{title.id}" + + next_entry_url = nil next_entry = entry.next_entry username - unless next_page > entry.pages - next_url = "#{base_url}reader/#{title.id}/#{entry.id}/#{next_page}" - end unless next_entry.nil? next_entry_url = "#{base_url}reader/#{title.id}/#{next_entry.id}" end diff --git a/src/views/list.html.ecr b/src/views/list.html.ecr new file mode 100644 index 0000000..a2f4e7e --- /dev/null +++ b/src/views/list.html.ecr @@ -0,0 +1,41 @@ + + + + <%= render_component "head" %> + +
+