mirror of https://github.com/freeCodeCamp/devdocs
parent
6cc430ffc4
commit
e4fbca722b
@ -1,283 +1,352 @@
|
||||
@app =
|
||||
_$: $
|
||||
_$$: $$
|
||||
_page: page
|
||||
collections: {}
|
||||
models: {}
|
||||
templates: {}
|
||||
views: {}
|
||||
|
||||
init: ->
|
||||
try @initErrorTracking() catch
|
||||
return unless @browserCheck()
|
||||
|
||||
@el = $('._app')
|
||||
@localStorage = new LocalStorageStore
|
||||
@serviceWorker = new app.ServiceWorker if app.ServiceWorker.isEnabled()
|
||||
@settings = new app.Settings
|
||||
@db = new app.DB()
|
||||
|
||||
@settings.initLayout()
|
||||
|
||||
@docs = new app.collections.Docs
|
||||
@disabledDocs = new app.collections.Docs
|
||||
@entries = new app.collections.Entries
|
||||
|
||||
@router = new app.Router
|
||||
@shortcuts = new app.Shortcuts
|
||||
@document = new app.views.Document
|
||||
@mobile = new app.views.Mobile if @isMobile()
|
||||
|
||||
if document.body.hasAttribute('data-doc')
|
||||
@DOC = JSON.parse(document.body.getAttribute('data-doc'))
|
||||
@bootOne()
|
||||
else if @DOCS
|
||||
@bootAll()
|
||||
else
|
||||
@onBootError()
|
||||
return
|
||||
|
||||
browserCheck: ->
|
||||
return true if @isSupportedBrowser()
|
||||
document.body.innerHTML = app.templates.unsupportedBrowser
|
||||
@hideLoadingScreen()
|
||||
false
|
||||
|
||||
initErrorTracking: ->
|
||||
# Show a warning message and don't track errors when the app is loaded
|
||||
# from a domain other than our own, because things are likely to break.
|
||||
# (e.g. cross-domain requests)
|
||||
if @isInvalidLocation()
|
||||
new app.views.Notif 'InvalidLocation'
|
||||
else
|
||||
if @config.sentry_dsn
|
||||
Raven.config @config.sentry_dsn,
|
||||
release: @config.release
|
||||
whitelistUrls: [/devdocs/]
|
||||
includePaths: [/devdocs/]
|
||||
ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/]
|
||||
tags:
|
||||
mode: if @isSingleDoc() then 'single' else 'full'
|
||||
iframe: (window.top isnt window).toString()
|
||||
electron: (!!window.process?.versions?.electron).toString()
|
||||
shouldSendCallback: =>
|
||||
try
|
||||
if @isInjectionError()
|
||||
@onInjectionError()
|
||||
return false
|
||||
if @isAndroidWebview()
|
||||
return false
|
||||
true
|
||||
dataCallback: (data) ->
|
||||
try
|
||||
$.extend(data.user ||= {}, app.settings.dump())
|
||||
data.user.docs = data.user.docs.split('/') if data.user.docs
|
||||
data.user.lastIDBTransaction = app.lastIDBTransaction if app.lastIDBTransaction
|
||||
data.tags.scriptCount = document.scripts.length
|
||||
data
|
||||
.install()
|
||||
@previousErrorHandler = onerror
|
||||
window.onerror = @onWindowError.bind(@)
|
||||
CookiesStore.onBlocked = @onCookieBlocked
|
||||
return
|
||||
|
||||
bootOne: ->
|
||||
@doc = new app.models.Doc @DOC
|
||||
@docs.reset [@doc]
|
||||
@doc.load @start.bind(@), @onBootError.bind(@), readCache: true
|
||||
new app.views.Notice 'singleDoc', @doc
|
||||
delete @DOC
|
||||
return
|
||||
|
||||
bootAll: ->
|
||||
docs = @settings.getDocs()
|
||||
for doc in @DOCS
|
||||
(if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc)
|
||||
@migrateDocs()
|
||||
@docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true
|
||||
delete @DOCS
|
||||
return
|
||||
|
||||
start: ->
|
||||
@entries.add doc.toEntry() for doc in @docs.all()
|
||||
@entries.add doc.toEntry() for doc in @disabledDocs.all()
|
||||
@initDoc(doc) for doc in @docs.all()
|
||||
@trigger 'ready'
|
||||
@router.start()
|
||||
@hideLoadingScreen()
|
||||
setTimeout =>
|
||||
@welcomeBack() unless @doc
|
||||
@removeEvent 'ready bootError'
|
||||
, 50
|
||||
return
|
||||
|
||||
initDoc: (doc) ->
|
||||
doc.entries.add type.toEntry() for type in doc.types.all()
|
||||
@entries.add doc.entries.all()
|
||||
return
|
||||
|
||||
migrateDocs: ->
|
||||
for slug in @settings.getDocs() when not @docs.findBy('slug', slug)
|
||||
needsSaving = true
|
||||
doc = @disabledDocs.findBy('slug', 'webpack') if slug == 'webpack~2'
|
||||
doc = @disabledDocs.findBy('slug', 'angular') if slug == 'angular~4_typescript'
|
||||
doc = @disabledDocs.findBy('slug', 'angular~2') if slug == 'angular~2_typescript'
|
||||
doc ||= @disabledDocs.findBy('slug_without_version', slug)
|
||||
if doc
|
||||
@disabledDocs.remove(doc)
|
||||
@docs.add(doc)
|
||||
|
||||
@saveDocs() if needsSaving
|
||||
return
|
||||
|
||||
enableDoc: (doc, _onSuccess, onError) ->
|
||||
return if @docs.contains(doc)
|
||||
|
||||
onSuccess = =>
|
||||
return if @docs.contains(doc)
|
||||
@disabledDocs.remove(doc)
|
||||
@docs.add(doc)
|
||||
@docs.sort()
|
||||
@initDoc(doc)
|
||||
@saveDocs()
|
||||
if app.settings.get('autoInstall')
|
||||
doc.install(_onSuccess, onError)
|
||||
else
|
||||
_onSuccess()
|
||||
return
|
||||
|
||||
doc.load onSuccess, onError, writeCache: true
|
||||
return
|
||||
|
||||
saveDocs: ->
|
||||
@settings.setDocs(doc.slug for doc in @docs.all())
|
||||
@db.migrate()
|
||||
@serviceWorker?.updateInBackground()
|
||||
|
||||
welcomeBack: ->
|
||||
visitCount = @settings.get('count')
|
||||
@settings.set 'count', ++visitCount
|
||||
new app.views.Notif 'Share', autoHide: null if visitCount is 5
|
||||
new app.views.News()
|
||||
new app.views.Updates()
|
||||
@updateChecker = new app.UpdateChecker()
|
||||
|
||||
reboot: ->
|
||||
if location.pathname isnt '/' and location.pathname isnt '/settings'
|
||||
window.location = "/##{location.pathname}"
|
||||
else
|
||||
window.location = '/'
|
||||
return
|
||||
|
||||
reload: ->
|
||||
@docs.clearCache()
|
||||
@disabledDocs.clearCache()
|
||||
if @serviceWorker then @serviceWorker.reload() else @reboot()
|
||||
return
|
||||
|
||||
reset: ->
|
||||
@localStorage.reset()
|
||||
@settings.reset()
|
||||
@db?.reset()
|
||||
@serviceWorker?.update()
|
||||
window.location = '/'
|
||||
return
|
||||
|
||||
showTip: (tip) ->
|
||||
return if @isSingleDoc()
|
||||
tips = @settings.getTips()
|
||||
if tips.indexOf(tip) is -1
|
||||
tips.push(tip)
|
||||
@settings.setTips(tips)
|
||||
new app.views.Tip(tip)
|
||||
return
|
||||
|
||||
hideLoadingScreen: ->
|
||||
document.body.classList.add '_overlay-scrollbars' if $.overlayScrollbarsEnabled()
|
||||
document.documentElement.classList.remove '_booting'
|
||||
return
|
||||
|
||||
indexHost: ->
|
||||
# Can't load the index files from the host/CDN when service worker is
|
||||
# enabled because it doesn't support caching URLs that use CORS.
|
||||
@config[if @serviceWorker and @settings.hasDocs() then 'index_path' else 'docs_origin']
|
||||
|
||||
onBootError: (args...) ->
|
||||
@trigger 'bootError'
|
||||
@hideLoadingScreen()
|
||||
return
|
||||
|
||||
onQuotaExceeded: ->
|
||||
return if @quotaExceeded
|
||||
@quotaExceeded = true
|
||||
new app.views.Notif 'QuotaExceeded', autoHide: null
|
||||
return
|
||||
|
||||
onCookieBlocked: (key, value, actual) ->
|
||||
return if @cookieBlocked
|
||||
@cookieBlocked = true
|
||||
new app.views.Notif 'CookieBlocked', autoHide: null
|
||||
Raven.captureMessage "CookieBlocked/#{key}", level: 'warning', extra: {value, actual}
|
||||
return
|
||||
|
||||
onWindowError: (args...) ->
|
||||
return if @cookieBlocked
|
||||
if @isInjectionError args...
|
||||
@onInjectionError()
|
||||
else if @isAppError args...
|
||||
@previousErrorHandler? args...
|
||||
@hideLoadingScreen()
|
||||
@errorNotif or= new app.views.Notif 'Error'
|
||||
@errorNotif.show()
|
||||
return
|
||||
|
||||
onInjectionError: ->
|
||||
unless @injectionError
|
||||
@injectionError = true
|
||||
alert """
|
||||
JavaScript code has been injected in the page which prevents DevDocs from running correctly.
|
||||
Please check your browser extensions/addons. """
|
||||
Raven.captureMessage 'injection error', level: 'info'
|
||||
return
|
||||
|
||||
isInjectionError: ->
|
||||
# Some browser extensions expect the entire web to use jQuery.
|
||||
# I gave up trying to fight back.
|
||||
window.$ isnt app._$ or window.$$ isnt app._$$ or window.page isnt app._page or typeof $.empty isnt 'function' or typeof page.show isnt 'function'
|
||||
|
||||
isAppError: (error, file) ->
|
||||
# Ignore errors from external scripts.
|
||||
file and file.indexOf('devdocs') isnt -1 and file.indexOf('.js') is file.length - 3
|
||||
|
||||
isSupportedBrowser: ->
|
||||
try
|
||||
features =
|
||||
bind: !!Function::bind
|
||||
pushState: !!history.pushState
|
||||
matchMedia: !!window.matchMedia
|
||||
insertAdjacentHTML: !!document.body.insertAdjacentHTML
|
||||
defaultPrevented: document.createEvent('CustomEvent').defaultPrevented is false
|
||||
cssVariables: !!CSS?.supports?('(--t: 0)')
|
||||
|
||||
for key, value of features when not value
|
||||
Raven.captureMessage "unsupported/#{key}", level: 'info'
|
||||
return false
|
||||
|
||||
true
|
||||
catch error
|
||||
Raven.captureMessage 'unsupported/exception', level: 'info', extra: { error: error }
|
||||
false
|
||||
|
||||
isSingleDoc: ->
|
||||
document.body.hasAttribute('data-doc')
|
||||
|
||||
isMobile: ->
|
||||
@_isMobile ?= app.views.Mobile.detect()
|
||||
|
||||
isAndroidWebview: ->
|
||||
@_isAndroidWebview ?= app.views.Mobile.detectAndroidWebview()
|
||||
|
||||
isInvalidLocation: ->
|
||||
@config.env is 'production' and location.host.indexOf(app.config.production_host) isnt 0
|
||||
|
||||
$.extend app, Events
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
this.app = {
|
||||
_$: $,
|
||||
_$$: $$,
|
||||
_page: page,
|
||||
collections: {},
|
||||
models: {},
|
||||
templates: {},
|
||||
views: {},
|
||||
|
||||
init() {
|
||||
try { this.initErrorTracking(); } catch (error) {}
|
||||
if (!this.browserCheck()) { return; }
|
||||
|
||||
this.el = $('._app');
|
||||
this.localStorage = new LocalStorageStore;
|
||||
if (app.ServiceWorker.isEnabled()) { this.serviceWorker = new app.ServiceWorker; }
|
||||
this.settings = new app.Settings;
|
||||
this.db = new app.DB();
|
||||
|
||||
this.settings.initLayout();
|
||||
|
||||
this.docs = new app.collections.Docs;
|
||||
this.disabledDocs = new app.collections.Docs;
|
||||
this.entries = new app.collections.Entries;
|
||||
|
||||
this.router = new app.Router;
|
||||
this.shortcuts = new app.Shortcuts;
|
||||
this.document = new app.views.Document;
|
||||
if (this.isMobile()) { this.mobile = new app.views.Mobile; }
|
||||
|
||||
if (document.body.hasAttribute('data-doc')) {
|
||||
this.DOC = JSON.parse(document.body.getAttribute('data-doc'));
|
||||
this.bootOne();
|
||||
} else if (this.DOCS) {
|
||||
this.bootAll();
|
||||
} else {
|
||||
this.onBootError();
|
||||
}
|
||||
},
|
||||
|
||||
browserCheck() {
|
||||
if (this.isSupportedBrowser()) { return true; }
|
||||
document.body.innerHTML = app.templates.unsupportedBrowser;
|
||||
this.hideLoadingScreen();
|
||||
return false;
|
||||
},
|
||||
|
||||
initErrorTracking() {
|
||||
// Show a warning message and don't track errors when the app is loaded
|
||||
// from a domain other than our own, because things are likely to break.
|
||||
// (e.g. cross-domain requests)
|
||||
if (this.isInvalidLocation()) {
|
||||
new app.views.Notif('InvalidLocation');
|
||||
} else {
|
||||
if (this.config.sentry_dsn) {
|
||||
Raven.config(this.config.sentry_dsn, {
|
||||
release: this.config.release,
|
||||
whitelistUrls: [/devdocs/],
|
||||
includePaths: [/devdocs/],
|
||||
ignoreErrors: [/NPObject/, /NS_ERROR/, /^null$/, /EvalError/],
|
||||
tags: {
|
||||
mode: this.isSingleDoc() ? 'single' : 'full',
|
||||
iframe: (window.top !== window).toString(),
|
||||
electron: (!!__guard__(window.process != null ? window.process.versions : undefined, x => x.electron)).toString()
|
||||
},
|
||||
shouldSendCallback: () => {
|
||||
try {
|
||||
if (this.isInjectionError()) {
|
||||
this.onInjectionError();
|
||||
return false;
|
||||
}
|
||||
if (this.isAndroidWebview()) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {}
|
||||
return true;
|
||||
},
|
||||
dataCallback(data) {
|
||||
try {
|
||||
$.extend(data.user || (data.user = {}), app.settings.dump());
|
||||
if (data.user.docs) { data.user.docs = data.user.docs.split('/'); }
|
||||
if (app.lastIDBTransaction) { data.user.lastIDBTransaction = app.lastIDBTransaction; }
|
||||
data.tags.scriptCount = document.scripts.length;
|
||||
} catch (error) {}
|
||||
return data;
|
||||
}
|
||||
}).install();
|
||||
}
|
||||
this.previousErrorHandler = onerror;
|
||||
window.onerror = this.onWindowError.bind(this);
|
||||
CookiesStore.onBlocked = this.onCookieBlocked;
|
||||
}
|
||||
},
|
||||
|
||||
bootOne() {
|
||||
this.doc = new app.models.Doc(this.DOC);
|
||||
this.docs.reset([this.doc]);
|
||||
this.doc.load(this.start.bind(this), this.onBootError.bind(this), {readCache: true});
|
||||
new app.views.Notice('singleDoc', this.doc);
|
||||
delete this.DOC;
|
||||
},
|
||||
|
||||
bootAll() {
|
||||
const docs = this.settings.getDocs();
|
||||
for (var doc of Array.from(this.DOCS)) {
|
||||
(docs.indexOf(doc.slug) >= 0 ? this.docs : this.disabledDocs).add(doc);
|
||||
}
|
||||
this.migrateDocs();
|
||||
this.docs.load(this.start.bind(this), this.onBootError.bind(this), {readCache: true, writeCache: true});
|
||||
delete this.DOCS;
|
||||
},
|
||||
|
||||
start() {
|
||||
let doc;
|
||||
for (doc of Array.from(this.docs.all())) { this.entries.add(doc.toEntry()); }
|
||||
for (doc of Array.from(this.disabledDocs.all())) { this.entries.add(doc.toEntry()); }
|
||||
for (doc of Array.from(this.docs.all())) { this.initDoc(doc); }
|
||||
this.trigger('ready');
|
||||
this.router.start();
|
||||
this.hideLoadingScreen();
|
||||
setTimeout(() => {
|
||||
if (!this.doc) { this.welcomeBack(); }
|
||||
return this.removeEvent('ready bootError');
|
||||
}
|
||||
, 50);
|
||||
},
|
||||
|
||||
initDoc(doc) {
|
||||
for (var type of Array.from(doc.types.all())) { doc.entries.add(type.toEntry()); }
|
||||
this.entries.add(doc.entries.all());
|
||||
},
|
||||
|
||||
migrateDocs() {
|
||||
let needsSaving;
|
||||
for (var slug of Array.from(this.settings.getDocs())) {
|
||||
if (!this.docs.findBy('slug', slug)) {var doc;
|
||||
|
||||
needsSaving = true;
|
||||
if (slug === 'webpack~2') { doc = this.disabledDocs.findBy('slug', 'webpack'); }
|
||||
if (slug === 'angular~4_typescript') { doc = this.disabledDocs.findBy('slug', 'angular'); }
|
||||
if (slug === 'angular~2_typescript') { doc = this.disabledDocs.findBy('slug', 'angular~2'); }
|
||||
if (!doc) { doc = this.disabledDocs.findBy('slug_without_version', slug); }
|
||||
if (doc) {
|
||||
this.disabledDocs.remove(doc);
|
||||
this.docs.add(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSaving) { this.saveDocs(); }
|
||||
},
|
||||
|
||||
enableDoc(doc, _onSuccess, onError) {
|
||||
if (this.docs.contains(doc)) { return; }
|
||||
|
||||
const onSuccess = () => {
|
||||
if (this.docs.contains(doc)) { return; }
|
||||
this.disabledDocs.remove(doc);
|
||||
this.docs.add(doc);
|
||||
this.docs.sort();
|
||||
this.initDoc(doc);
|
||||
this.saveDocs();
|
||||
if (app.settings.get('autoInstall')) {
|
||||
doc.install(_onSuccess, onError);
|
||||
} else {
|
||||
_onSuccess();
|
||||
}
|
||||
};
|
||||
|
||||
doc.load(onSuccess, onError, {writeCache: true});
|
||||
},
|
||||
|
||||
saveDocs() {
|
||||
this.settings.setDocs(Array.from(this.docs.all()).map((doc) => doc.slug));
|
||||
this.db.migrate();
|
||||
return (this.serviceWorker != null ? this.serviceWorker.updateInBackground() : undefined);
|
||||
},
|
||||
|
||||
welcomeBack() {
|
||||
let visitCount = this.settings.get('count');
|
||||
this.settings.set('count', ++visitCount);
|
||||
if (visitCount === 5) { new app.views.Notif('Share', {autoHide: null}); }
|
||||
new app.views.News();
|
||||
new app.views.Updates();
|
||||
return this.updateChecker = new app.UpdateChecker();
|
||||
},
|
||||
|
||||
reboot() {
|
||||
if ((location.pathname !== '/') && (location.pathname !== '/settings')) {
|
||||
window.location = `/#${location.pathname}`;
|
||||
} else {
|
||||
window.location = '/';
|
||||
}
|
||||
},
|
||||
|
||||
reload() {
|
||||
this.docs.clearCache();
|
||||
this.disabledDocs.clearCache();
|
||||
if (this.serviceWorker) { this.serviceWorker.reload(); } else { this.reboot(); }
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.localStorage.reset();
|
||||
this.settings.reset();
|
||||
if (this.db != null) {
|
||||
this.db.reset();
|
||||
}
|
||||
if (this.serviceWorker != null) {
|
||||
this.serviceWorker.update();
|
||||
}
|
||||
window.location = '/';
|
||||
},
|
||||
|
||||
showTip(tip) {
|
||||
if (this.isSingleDoc()) { return; }
|
||||
const tips = this.settings.getTips();
|
||||
if (tips.indexOf(tip) === -1) {
|
||||
tips.push(tip);
|
||||
this.settings.setTips(tips);
|
||||
new app.views.Tip(tip);
|
||||
}
|
||||
},
|
||||
|
||||
hideLoadingScreen() {
|
||||
if ($.overlayScrollbarsEnabled()) { document.body.classList.add('_overlay-scrollbars'); }
|
||||
document.documentElement.classList.remove('_booting');
|
||||
},
|
||||
|
||||
indexHost() {
|
||||
// Can't load the index files from the host/CDN when service worker is
|
||||
// enabled because it doesn't support caching URLs that use CORS.
|
||||
return this.config[this.serviceWorker && this.settings.hasDocs() ? 'index_path' : 'docs_origin'];
|
||||
},
|
||||
|
||||
onBootError(...args) {
|
||||
this.trigger('bootError');
|
||||
this.hideLoadingScreen();
|
||||
},
|
||||
|
||||
onQuotaExceeded() {
|
||||
if (this.quotaExceeded) { return; }
|
||||
this.quotaExceeded = true;
|
||||
new app.views.Notif('QuotaExceeded', {autoHide: null});
|
||||
},
|
||||
|
||||
onCookieBlocked(key, value, actual) {
|
||||
if (this.cookieBlocked) { return; }
|
||||
this.cookieBlocked = true;
|
||||
new app.views.Notif('CookieBlocked', {autoHide: null});
|
||||
Raven.captureMessage(`CookieBlocked/${key}`, {level: 'warning', extra: {value, actual}});
|
||||
},
|
||||
|
||||
onWindowError(...args) {
|
||||
if (this.cookieBlocked) { return; }
|
||||
if (this.isInjectionError(...Array.from(args || []))) {
|
||||
this.onInjectionError();
|
||||
} else if (this.isAppError(...Array.from(args || []))) {
|
||||
if (typeof this.previousErrorHandler === 'function') {
|
||||
this.previousErrorHandler(...Array.from(args || []));
|
||||
}
|
||||
this.hideLoadingScreen();
|
||||
if (!this.errorNotif) { this.errorNotif = new app.views.Notif('Error'); }
|
||||
this.errorNotif.show();
|
||||
}
|
||||
},
|
||||
|
||||
onInjectionError() {
|
||||
if (!this.injectionError) {
|
||||
this.injectionError = true;
|
||||
alert(`\
|
||||
JavaScript code has been injected in the page which prevents DevDocs from running correctly.
|
||||
Please check your browser extensions/addons. `
|
||||
);
|
||||
Raven.captureMessage('injection error', {level: 'info'});
|
||||
}
|
||||
},
|
||||
|
||||
isInjectionError() {
|
||||
// Some browser extensions expect the entire web to use jQuery.
|
||||
// I gave up trying to fight back.
|
||||
return (window.$ !== app._$) || (window.$$ !== app._$$) || (window.page !== app._page) || (typeof $.empty !== 'function') || (typeof page.show !== 'function');
|
||||
},
|
||||
|
||||
isAppError(error, file) {
|
||||
// Ignore errors from external scripts.
|
||||
return file && (file.indexOf('devdocs') !== -1) && (file.indexOf('.js') === (file.length - 3));
|
||||
},
|
||||
|
||||
isSupportedBrowser() {
|
||||
try {
|
||||
const features = {
|
||||
bind: !!Function.prototype.bind,
|
||||
pushState: !!history.pushState,
|
||||
matchMedia: !!window.matchMedia,
|
||||
insertAdjacentHTML: !!document.body.insertAdjacentHTML,
|
||||
defaultPrevented: document.createEvent('CustomEvent').defaultPrevented === false,
|
||||
cssVariables: !!__guardMethod__(CSS, 'supports', o => o.supports('(--t: 0)'))
|
||||
};
|
||||
|
||||
for (var key in features) {
|
||||
var value = features[key];
|
||||
if (!value) {
|
||||
Raven.captureMessage(`unsupported/${key}`, {level: 'info'});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Raven.captureMessage('unsupported/exception', {level: 'info', extra: { error }});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isSingleDoc() {
|
||||
return document.body.hasAttribute('data-doc');
|
||||
},
|
||||
|
||||
isMobile() {
|
||||
return this._isMobile != null ? this._isMobile : (this._isMobile = app.views.Mobile.detect());
|
||||
},
|
||||
|
||||
isAndroidWebview() {
|
||||
return this._isAndroidWebview != null ? this._isAndroidWebview : (this._isAndroidWebview = app.views.Mobile.detectAndroidWebview());
|
||||
},
|
||||
|
||||
isInvalidLocation() {
|
||||
return (this.config.env === 'production') && (location.host.indexOf(app.config.production_host) !== 0);
|
||||
}
|
||||
};
|
||||
|
||||
$.extend(app, Events);
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
||||
function __guardMethod__(obj, methodName, transform) {
|
||||
if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
|
||||
return transform(obj, methodName);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -1,382 +1,486 @@
|
||||
class app.DB
|
||||
NAME = 'docs'
|
||||
VERSION = 15
|
||||
|
||||
constructor: ->
|
||||
@versionMultipler = if $.isIE() then 1e5 else 1e9
|
||||
@useIndexedDB = @useIndexedDB()
|
||||
@callbacks = []
|
||||
|
||||
db: (fn) ->
|
||||
return fn() unless @useIndexedDB
|
||||
@callbacks.push(fn) if fn
|
||||
return if @open
|
||||
|
||||
try
|
||||
@open = true
|
||||
req = indexedDB.open(NAME, VERSION * @versionMultipler + @userVersion())
|
||||
req.onsuccess = @onOpenSuccess
|
||||
req.onerror = @onOpenError
|
||||
req.onupgradeneeded = @onUpgradeNeeded
|
||||
catch error
|
||||
@fail 'exception', error
|
||||
return
|
||||
|
||||
onOpenSuccess: (event) =>
|
||||
db = event.target.result
|
||||
|
||||
if db.objectStoreNames.length is 0
|
||||
try db.close()
|
||||
@open = false
|
||||
@fail 'empty'
|
||||
else if error = @buggyIDB(db)
|
||||
try db.close()
|
||||
@open = false
|
||||
@fail 'buggy', error
|
||||
else
|
||||
@runCallbacks(db)
|
||||
@open = false
|
||||
db.close()
|
||||
return
|
||||
|
||||
onOpenError: (event) =>
|
||||
event.preventDefault()
|
||||
@open = false
|
||||
error = event.target.error
|
||||
|
||||
switch error.name
|
||||
when 'QuotaExceededError'
|
||||
@onQuotaExceededError()
|
||||
when 'VersionError'
|
||||
@onVersionError()
|
||||
when 'InvalidStateError'
|
||||
@fail 'private_mode'
|
||||
else
|
||||
@fail 'cant_open', error
|
||||
return
|
||||
|
||||
fail: (reason, error) ->
|
||||
@cachedDocs = null
|
||||
@useIndexedDB = false
|
||||
@reason or= reason
|
||||
@error or= error
|
||||
console.error? 'IDB error', error if error
|
||||
@runCallbacks()
|
||||
if error and reason is 'cant_open'
|
||||
Raven.captureMessage "#{error.name}: #{error.message}", level: 'warning', fingerprint: [error.name]
|
||||
return
|
||||
|
||||
onQuotaExceededError: ->
|
||||
@reset()
|
||||
@db()
|
||||
app.onQuotaExceeded()
|
||||
Raven.captureMessage 'QuotaExceededError', level: 'warning'
|
||||
return
|
||||
|
||||
onVersionError: ->
|
||||
req = indexedDB.open(NAME)
|
||||
req.onsuccess = (event) =>
|
||||
@handleVersionMismatch event.target.result.version
|
||||
req.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
@fail 'cant_open', error
|
||||
return
|
||||
|
||||
handleVersionMismatch: (actualVersion) ->
|
||||
if Math.floor(actualVersion / @versionMultipler) isnt VERSION
|
||||
@fail 'version'
|
||||
else
|
||||
@setUserVersion actualVersion - VERSION * @versionMultipler
|
||||
@db()
|
||||
return
|
||||
|
||||
buggyIDB: (db) ->
|
||||
return if @checkedBuggyIDB
|
||||
@checkedBuggyIDB = true
|
||||
try
|
||||
@idbTransaction(db, stores: $.makeArray(db.objectStoreNames)[0..1], mode: 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937
|
||||
return
|
||||
catch error
|
||||
return error
|
||||
|
||||
runCallbacks: (db) ->
|
||||
fn(db) while fn = @callbacks.shift()
|
||||
return
|
||||
|
||||
onUpgradeNeeded: (event) ->
|
||||
return unless db = event.target.result
|
||||
|
||||
objectStoreNames = $.makeArray(db.objectStoreNames)
|
||||
|
||||
unless $.arrayDelete(objectStoreNames, 'docs')
|
||||
try db.createObjectStore('docs')
|
||||
|
||||
for doc in app.docs.all() when not $.arrayDelete(objectStoreNames, doc.slug)
|
||||
try db.createObjectStore(doc.slug)
|
||||
|
||||
for name in objectStoreNames
|
||||
try db.deleteObjectStore(name)
|
||||
return
|
||||
|
||||
store: (doc, data, onSuccess, onError, _retry = true) ->
|
||||
@db (db) =>
|
||||
unless db
|
||||
onError()
|
||||
return
|
||||
|
||||
txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false
|
||||
txn.oncomplete = =>
|
||||
@cachedDocs?[doc.slug] = doc.mtime
|
||||
onSuccess()
|
||||
return
|
||||
txn.onerror = (event) =>
|
||||
event.preventDefault()
|
||||
if txn.error?.name is 'NotFoundError' and _retry
|
||||
@migrate()
|
||||
setTimeout =>
|
||||
@store(doc, data, onSuccess, onError, false)
|
||||
, 0
|
||||
else
|
||||
onError(event)
|
||||
return
|
||||
|
||||
store = txn.objectStore(doc.slug)
|
||||
store.clear()
|
||||
store.add(content, path) for path, content of data
|
||||
|
||||
store = txn.objectStore('docs')
|
||||
store.put(doc.mtime, doc.slug)
|
||||
return
|
||||
return
|
||||
|
||||
unstore: (doc, onSuccess, onError, _retry = true) ->
|
||||
@db (db) =>
|
||||
unless db
|
||||
onError()
|
||||
return
|
||||
|
||||
txn = @idbTransaction db, stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false
|
||||
txn.oncomplete = =>
|
||||
delete @cachedDocs?[doc.slug]
|
||||
onSuccess()
|
||||
return
|
||||
txn.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
if txn.error?.name is 'NotFoundError' and _retry
|
||||
@migrate()
|
||||
setTimeout =>
|
||||
@unstore(doc, onSuccess, onError, false)
|
||||
, 0
|
||||
else
|
||||
onError(event)
|
||||
return
|
||||
|
||||
store = txn.objectStore('docs')
|
||||
store.delete(doc.slug)
|
||||
|
||||
store = txn.objectStore(doc.slug)
|
||||
store.clear()
|
||||
return
|
||||
return
|
||||
|
||||
version: (doc, fn) ->
|
||||
if (version = @cachedVersion(doc))?
|
||||
fn(version)
|
||||
return
|
||||
|
||||
@db (db) =>
|
||||
unless db
|
||||
fn(false)
|
||||
return
|
||||
|
||||
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
|
||||
store = txn.objectStore('docs')
|
||||
|
||||
req = store.get(doc.slug)
|
||||
req.onsuccess = ->
|
||||
fn(req.result)
|
||||
return
|
||||
req.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
fn(false)
|
||||
return
|
||||
return
|
||||
return
|
||||
|
||||
cachedVersion: (doc) ->
|
||||
return unless @cachedDocs
|
||||
@cachedDocs[doc.slug] or false
|
||||
|
||||
versions: (docs, fn) ->
|
||||
if versions = @cachedVersions(docs)
|
||||
fn(versions)
|
||||
return
|
||||
|
||||
@db (db) =>
|
||||
unless db
|
||||
fn(false)
|
||||
return
|
||||
|
||||
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
|
||||
txn.oncomplete = ->
|
||||
fn(result)
|
||||
return
|
||||
store = txn.objectStore('docs')
|
||||
result = {}
|
||||
|
||||
docs.forEach (doc) ->
|
||||
req = store.get(doc.slug)
|
||||
req.onsuccess = ->
|
||||
result[doc.slug] = req.result
|
||||
return
|
||||
req.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
result[doc.slug] = false
|
||||
return
|
||||
return
|
||||
return
|
||||
|
||||
cachedVersions: (docs) ->
|
||||
return unless @cachedDocs
|
||||
result = {}
|
||||
result[doc.slug] = @cachedVersion(doc) for doc in docs
|
||||
result
|
||||
|
||||
load: (entry, onSuccess, onError) ->
|
||||
if @shouldLoadWithIDB(entry)
|
||||
onError = @loadWithXHR.bind(@, entry, onSuccess, onError)
|
||||
@loadWithIDB entry, onSuccess, onError
|
||||
else
|
||||
@loadWithXHR entry, onSuccess, onError
|
||||
|
||||
loadWithXHR: (entry, onSuccess, onError) ->
|
||||
ajax
|
||||
url: entry.fileUrl()
|
||||
dataType: 'html'
|
||||
success: onSuccess
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let NAME = undefined;
|
||||
let VERSION = undefined;
|
||||
const Cls = (app.DB = class DB {
|
||||
static initClass() {
|
||||
NAME = 'docs';
|
||||
VERSION = 15;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.onOpenSuccess = this.onOpenSuccess.bind(this);
|
||||
this.onOpenError = this.onOpenError.bind(this);
|
||||
this.checkForCorruptedDocs = this.checkForCorruptedDocs.bind(this);
|
||||
this.deleteCorruptedDocs = this.deleteCorruptedDocs.bind(this);
|
||||
this.versionMultipler = $.isIE() ? 1e5 : 1e9;
|
||||
this.useIndexedDB = this.useIndexedDB();
|
||||
this.callbacks = [];
|
||||
}
|
||||
|
||||
db(fn) {
|
||||
if (!this.useIndexedDB) { return fn(); }
|
||||
if (fn) { this.callbacks.push(fn); }
|
||||
if (this.open) { return; }
|
||||
|
||||
try {
|
||||
this.open = true;
|
||||
const req = indexedDB.open(NAME, (VERSION * this.versionMultipler) + this.userVersion());
|
||||
req.onsuccess = this.onOpenSuccess;
|
||||
req.onerror = this.onOpenError;
|
||||
req.onupgradeneeded = this.onUpgradeNeeded;
|
||||
} catch (error) {
|
||||
this.fail('exception', error);
|
||||
}
|
||||
}
|
||||
|
||||
onOpenSuccess(event) {
|
||||
let error;
|
||||
const db = event.target.result;
|
||||
|
||||
if (db.objectStoreNames.length === 0) {
|
||||
try { db.close(); } catch (error1) {}
|
||||
this.open = false;
|
||||
this.fail('empty');
|
||||
} else if (error = this.buggyIDB(db)) {
|
||||
try { db.close(); } catch (error2) {}
|
||||
this.open = false;
|
||||
this.fail('buggy', error);
|
||||
} else {
|
||||
this.runCallbacks(db);
|
||||
this.open = false;
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
onOpenError(event) {
|
||||
event.preventDefault();
|
||||
this.open = false;
|
||||
const {
|
||||
error
|
||||
} = event.target;
|
||||
|
||||
switch (error.name) {
|
||||
case 'QuotaExceededError':
|
||||
this.onQuotaExceededError();
|
||||
break;
|
||||
case 'VersionError':
|
||||
this.onVersionError();
|
||||
break;
|
||||
case 'InvalidStateError':
|
||||
this.fail('private_mode');
|
||||
break;
|
||||
default:
|
||||
this.fail('cant_open', error);
|
||||
}
|
||||
}
|
||||
|
||||
fail(reason, error) {
|
||||
this.cachedDocs = null;
|
||||
this.useIndexedDB = false;
|
||||
if (!this.reason) { this.reason = reason; }
|
||||
if (!this.error) { this.error = error; }
|
||||
if (error) { if (typeof console.error === 'function') {
|
||||
console.error('IDB error', error);
|
||||
} }
|
||||
this.runCallbacks();
|
||||
if (error && (reason === 'cant_open')) {
|
||||
Raven.captureMessage(`${error.name}: ${error.message}`, {level: 'warning', fingerprint: [error.name]});
|
||||
}
|
||||
}
|
||||
|
||||
onQuotaExceededError() {
|
||||
this.reset();
|
||||
this.db();
|
||||
app.onQuotaExceeded();
|
||||
Raven.captureMessage('QuotaExceededError', {level: 'warning'});
|
||||
}
|
||||
|
||||
onVersionError() {
|
||||
const req = indexedDB.open(NAME);
|
||||
req.onsuccess = event => {
|
||||
return this.handleVersionMismatch(event.target.result.version);
|
||||
};
|
||||
req.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
return this.fail('cant_open', error);
|
||||
};
|
||||
}
|
||||
|
||||
handleVersionMismatch(actualVersion) {
|
||||
if (Math.floor(actualVersion / this.versionMultipler) !== VERSION) {
|
||||
this.fail('version');
|
||||
} else {
|
||||
this.setUserVersion(actualVersion - (VERSION * this.versionMultipler));
|
||||
this.db();
|
||||
}
|
||||
}
|
||||
|
||||
buggyIDB(db) {
|
||||
if (this.checkedBuggyIDB) { return; }
|
||||
this.checkedBuggyIDB = true;
|
||||
try {
|
||||
this.idbTransaction(db, {stores: $.makeArray(db.objectStoreNames).slice(0, 2), mode: 'readwrite'}).abort(); // https://bugs.webkit.org/show_bug.cgi?id=136937
|
||||
return;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
runCallbacks(db) {
|
||||
let fn;
|
||||
while ((fn = this.callbacks.shift())) { fn(db); }
|
||||
}
|
||||
|
||||
onUpgradeNeeded(event) {
|
||||
let db;
|
||||
if (!(db = event.target.result)) { return; }
|
||||
|
||||
const objectStoreNames = $.makeArray(db.objectStoreNames);
|
||||
|
||||
if (!$.arrayDelete(objectStoreNames, 'docs')) {
|
||||
try { db.createObjectStore('docs'); } catch (error) {}
|
||||
}
|
||||
|
||||
for (var doc of Array.from(app.docs.all())) {
|
||||
if (!$.arrayDelete(objectStoreNames, doc.slug)) {
|
||||
try { db.createObjectStore(doc.slug); } catch (error1) {}
|
||||
}
|
||||
}
|
||||
|
||||
for (var name of Array.from(objectStoreNames)) {
|
||||
try { db.deleteObjectStore(name); } catch (error2) {}
|
||||
}
|
||||
}
|
||||
|
||||
store(doc, data, onSuccess, onError, _retry) {
|
||||
if (_retry == null) { _retry = true; }
|
||||
this.db(db => {
|
||||
if (!db) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false});
|
||||
txn.oncomplete = () => {
|
||||
if (this.cachedDocs != null) {
|
||||
this.cachedDocs[doc.slug] = doc.mtime;
|
||||
}
|
||||
onSuccess();
|
||||
};
|
||||
txn.onerror = event => {
|
||||
event.preventDefault();
|
||||
if (((txn.error != null ? txn.error.name : undefined) === 'NotFoundError') && _retry) {
|
||||
this.migrate();
|
||||
setTimeout(() => {
|
||||
return this.store(doc, data, onSuccess, onError, false);
|
||||
}
|
||||
, 0);
|
||||
} else {
|
||||
onError(event);
|
||||
}
|
||||
};
|
||||
|
||||
let store = txn.objectStore(doc.slug);
|
||||
store.clear();
|
||||
for (var path in data) { var content = data[path]; store.add(content, path); }
|
||||
|
||||
store = txn.objectStore('docs');
|
||||
store.put(doc.mtime, doc.slug);
|
||||
});
|
||||
}
|
||||
|
||||
unstore(doc, onSuccess, onError, _retry) {
|
||||
if (_retry == null) { _retry = true; }
|
||||
this.db(db => {
|
||||
if (!db) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: ['docs', doc.slug], mode: 'readwrite', ignoreError: false});
|
||||
txn.oncomplete = () => {
|
||||
if (this.cachedDocs != null) {
|
||||
delete this.cachedDocs[doc.slug];
|
||||
}
|
||||
onSuccess();
|
||||
};
|
||||
txn.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
if (((txn.error != null ? txn.error.name : undefined) === 'NotFoundError') && _retry) {
|
||||
this.migrate();
|
||||
setTimeout(() => {
|
||||
return this.unstore(doc, onSuccess, onError, false);
|
||||
}
|
||||
, 0);
|
||||
} else {
|
||||
onError(event);
|
||||
}
|
||||
};
|
||||
|
||||
let store = txn.objectStore('docs');
|
||||
store.delete(doc.slug);
|
||||
|
||||
store = txn.objectStore(doc.slug);
|
||||
store.clear();
|
||||
});
|
||||
}
|
||||
|
||||
version(doc, fn) {
|
||||
let version;
|
||||
if ((version = this.cachedVersion(doc)) != null) {
|
||||
fn(version);
|
||||
return;
|
||||
}
|
||||
|
||||
this.db(db => {
|
||||
if (!db) {
|
||||
fn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: ['docs'], mode: 'readonly'});
|
||||
const store = txn.objectStore('docs');
|
||||
|
||||
const req = store.get(doc.slug);
|
||||
req.onsuccess = function() {
|
||||
fn(req.result);
|
||||
};
|
||||
req.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
fn(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
cachedVersion(doc) {
|
||||
if (!this.cachedDocs) { return; }
|
||||
return this.cachedDocs[doc.slug] || false;
|
||||
}
|
||||
|
||||
versions(docs, fn) {
|
||||
let versions;
|
||||
if (versions = this.cachedVersions(docs)) {
|
||||
fn(versions);
|
||||
return;
|
||||
}
|
||||
|
||||
return this.db(db => {
|
||||
if (!db) {
|
||||
fn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: ['docs'], mode: 'readonly'});
|
||||
txn.oncomplete = function() {
|
||||
fn(result);
|
||||
};
|
||||
const store = txn.objectStore('docs');
|
||||
var result = {};
|
||||
|
||||
docs.forEach(function(doc) {
|
||||
const req = store.get(doc.slug);
|
||||
req.onsuccess = function() {
|
||||
result[doc.slug] = req.result;
|
||||
};
|
||||
req.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
result[doc.slug] = false;
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cachedVersions(docs) {
|
||||
if (!this.cachedDocs) { return; }
|
||||
const result = {};
|
||||
for (var doc of Array.from(docs)) { result[doc.slug] = this.cachedVersion(doc); }
|
||||
return result;
|
||||
}
|
||||
|
||||
load(entry, onSuccess, onError) {
|
||||
if (this.shouldLoadWithIDB(entry)) {
|
||||
onError = this.loadWithXHR.bind(this, entry, onSuccess, onError);
|
||||
return this.loadWithIDB(entry, onSuccess, onError);
|
||||
} else {
|
||||
return this.loadWithXHR(entry, onSuccess, onError);
|
||||
}
|
||||
}
|
||||
|
||||
loadWithXHR(entry, onSuccess, onError) {
|
||||
return ajax({
|
||||
url: entry.fileUrl(),
|
||||
dataType: 'html',
|
||||
success: onSuccess,
|
||||
error: onError
|
||||
|
||||
loadWithIDB: (entry, onSuccess, onError) ->
|
||||
@db (db) =>
|
||||
unless db
|
||||
onError()
|
||||
return
|
||||
|
||||
unless db.objectStoreNames.contains(entry.doc.slug)
|
||||
onError()
|
||||
@loadDocsCache(db)
|
||||
return
|
||||
|
||||
txn = @idbTransaction db, stores: [entry.doc.slug], mode: 'readonly'
|
||||
store = txn.objectStore(entry.doc.slug)
|
||||
|
||||
req = store.get(entry.dbPath())
|
||||
req.onsuccess = ->
|
||||
if req.result then onSuccess(req.result) else onError()
|
||||
return
|
||||
req.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
onError()
|
||||
return
|
||||
@loadDocsCache(db)
|
||||
return
|
||||
|
||||
loadDocsCache: (db) ->
|
||||
return if @cachedDocs
|
||||
@cachedDocs = {}
|
||||
|
||||
txn = @idbTransaction db, stores: ['docs'], mode: 'readonly'
|
||||
txn.oncomplete = =>
|
||||
setTimeout(@checkForCorruptedDocs, 50)
|
||||
return
|
||||
|
||||
req = txn.objectStore('docs').openCursor()
|
||||
req.onsuccess = (event) =>
|
||||
return unless cursor = event.target.result
|
||||
@cachedDocs[cursor.key] = cursor.value
|
||||
cursor.continue()
|
||||
return
|
||||
req.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
return
|
||||
return
|
||||
|
||||
checkForCorruptedDocs: =>
|
||||
@db (db) =>
|
||||
@corruptedDocs = []
|
||||
docs = (key for key, value of @cachedDocs when value)
|
||||
return if docs.length is 0
|
||||
|
||||
for slug in docs when not app.docs.findBy('slug', slug)
|
||||
@corruptedDocs.push(slug)
|
||||
|
||||
for slug in @corruptedDocs
|
||||
$.arrayDelete(docs, slug)
|
||||
|
||||
if docs.length is 0
|
||||
setTimeout(@deleteCorruptedDocs, 0)
|
||||
return
|
||||
|
||||
txn = @idbTransaction(db, stores: docs, mode: 'readonly', ignoreError: false)
|
||||
txn.oncomplete = =>
|
||||
setTimeout(@deleteCorruptedDocs, 0) if @corruptedDocs.length > 0
|
||||
return
|
||||
|
||||
for doc in docs
|
||||
txn.objectStore(doc).get('index').onsuccess = (event) =>
|
||||
@corruptedDocs.push(event.target.source.name) unless event.target.result
|
||||
return
|
||||
return
|
||||
return
|
||||
|
||||
deleteCorruptedDocs: =>
|
||||
@db (db) =>
|
||||
txn = @idbTransaction(db, stores: ['docs'], mode: 'readwrite', ignoreError: false)
|
||||
store = txn.objectStore('docs')
|
||||
while doc = @corruptedDocs.pop()
|
||||
@cachedDocs[doc] = false
|
||||
store.delete(doc)
|
||||
return
|
||||
Raven.captureMessage 'corruptedDocs', level: 'info', extra: { docs: @corruptedDocs.join(',') }
|
||||
return
|
||||
|
||||
shouldLoadWithIDB: (entry) ->
|
||||
@useIndexedDB and (not @cachedDocs or @cachedDocs[entry.doc.slug])
|
||||
|
||||
idbTransaction: (db, options) ->
|
||||
app.lastIDBTransaction = [options.stores, options.mode]
|
||||
txn = db.transaction(options.stores, options.mode)
|
||||
unless options.ignoreError is false
|
||||
txn.onerror = (event) ->
|
||||
event.preventDefault()
|
||||
return
|
||||
unless options.ignoreAbort is false
|
||||
txn.onabort = (event) ->
|
||||
event.preventDefault()
|
||||
return
|
||||
txn
|
||||
|
||||
reset: ->
|
||||
try indexedDB?.deleteDatabase(NAME) catch
|
||||
return
|
||||
|
||||
useIndexedDB: ->
|
||||
try
|
||||
if !app.isSingleDoc() and window.indexedDB
|
||||
true
|
||||
else
|
||||
@reason = 'not_supported'
|
||||
false
|
||||
catch
|
||||
false
|
||||
|
||||
migrate: ->
|
||||
app.settings.set('schema', @userVersion() + 1)
|
||||
return
|
||||
|
||||
setUserVersion: (version) ->
|
||||
app.settings.set('schema', version)
|
||||
return
|
||||
|
||||
userVersion: ->
|
||||
app.settings.get('schema')
|
||||
});
|
||||
}
|
||||
|
||||
loadWithIDB(entry, onSuccess, onError) {
|
||||
return this.db(db => {
|
||||
if (!db) {
|
||||
onError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(entry.doc.slug)) {
|
||||
onError();
|
||||
this.loadDocsCache(db);
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: [entry.doc.slug], mode: 'readonly'});
|
||||
const store = txn.objectStore(entry.doc.slug);
|
||||
|
||||
const req = store.get(entry.dbPath());
|
||||
req.onsuccess = function() {
|
||||
if (req.result) { onSuccess(req.result); } else { onError(); }
|
||||
};
|
||||
req.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
onError();
|
||||
};
|
||||
this.loadDocsCache(db);
|
||||
});
|
||||
}
|
||||
|
||||
loadDocsCache(db) {
|
||||
if (this.cachedDocs) { return; }
|
||||
this.cachedDocs = {};
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: ['docs'], mode: 'readonly'});
|
||||
txn.oncomplete = () => {
|
||||
setTimeout(this.checkForCorruptedDocs, 50);
|
||||
};
|
||||
|
||||
const req = txn.objectStore('docs').openCursor();
|
||||
req.onsuccess = event => {
|
||||
let cursor;
|
||||
if (!(cursor = event.target.result)) { return; }
|
||||
this.cachedDocs[cursor.key] = cursor.value;
|
||||
cursor.continue();
|
||||
};
|
||||
req.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
|
||||
checkForCorruptedDocs() {
|
||||
this.db(db => {
|
||||
let slug;
|
||||
this.corruptedDocs = [];
|
||||
const docs = ((() => {
|
||||
const result = [];
|
||||
for (var key in this.cachedDocs) {
|
||||
var value = this.cachedDocs[key];
|
||||
if (value) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})());
|
||||
if (docs.length === 0) { return; }
|
||||
|
||||
for (slug of Array.from(docs)) {
|
||||
if (!app.docs.findBy('slug', slug)) {
|
||||
this.corruptedDocs.push(slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (slug of Array.from(this.corruptedDocs)) {
|
||||
$.arrayDelete(docs, slug);
|
||||
}
|
||||
|
||||
if (docs.length === 0) {
|
||||
setTimeout(this.deleteCorruptedDocs, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const txn = this.idbTransaction(db, {stores: docs, mode: 'readonly', ignoreError: false});
|
||||
txn.oncomplete = () => {
|
||||
if (this.corruptedDocs.length > 0) { setTimeout(this.deleteCorruptedDocs, 0); }
|
||||
};
|
||||
|
||||
for (var doc of Array.from(docs)) {
|
||||
txn.objectStore(doc).get('index').onsuccess = event => {
|
||||
if (!event.target.result) { this.corruptedDocs.push(event.target.source.name); }
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteCorruptedDocs() {
|
||||
this.db(db => {
|
||||
let doc;
|
||||
const txn = this.idbTransaction(db, {stores: ['docs'], mode: 'readwrite', ignoreError: false});
|
||||
const store = txn.objectStore('docs');
|
||||
while ((doc = this.corruptedDocs.pop())) {
|
||||
this.cachedDocs[doc] = false;
|
||||
store.delete(doc);
|
||||
}
|
||||
});
|
||||
Raven.captureMessage('corruptedDocs', {level: 'info', extra: { docs: this.corruptedDocs.join(',') }});
|
||||
}
|
||||
|
||||
shouldLoadWithIDB(entry) {
|
||||
return this.useIndexedDB && (!this.cachedDocs || this.cachedDocs[entry.doc.slug]);
|
||||
}
|
||||
|
||||
idbTransaction(db, options) {
|
||||
app.lastIDBTransaction = [options.stores, options.mode];
|
||||
const txn = db.transaction(options.stores, options.mode);
|
||||
if (options.ignoreError !== false) {
|
||||
txn.onerror = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
if (options.ignoreAbort !== false) {
|
||||
txn.onabort = function(event) {
|
||||
event.preventDefault();
|
||||
};
|
||||
}
|
||||
return txn;
|
||||
}
|
||||
|
||||
reset() {
|
||||
try { if (typeof indexedDB !== 'undefined' && indexedDB !== null) {
|
||||
indexedDB.deleteDatabase(NAME);
|
||||
} } catch (error) {}
|
||||
}
|
||||
|
||||
useIndexedDB() {
|
||||
try {
|
||||
if (!app.isSingleDoc() && window.indexedDB) {
|
||||
return true;
|
||||
} else {
|
||||
this.reason = 'not_supported';
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
migrate() {
|
||||
app.settings.set('schema', this.userVersion() + 1);
|
||||
}
|
||||
|
||||
setUserVersion(version) {
|
||||
app.settings.set('schema', version);
|
||||
}
|
||||
|
||||
userVersion() {
|
||||
return app.settings.get('schema');
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,154 +1,199 @@
|
||||
class app.Router
|
||||
$.extend @prototype, Events
|
||||
|
||||
@routes: [
|
||||
['*', 'before' ]
|
||||
['/', 'root' ]
|
||||
['/settings', 'settings' ]
|
||||
['/offline', 'offline' ]
|
||||
['/about', 'about' ]
|
||||
['/news', 'news' ]
|
||||
['/help', 'help' ]
|
||||
['/:doc-:type/', 'type' ]
|
||||
['/:doc/', 'doc' ]
|
||||
['/:doc/:path(*)', 'entry' ]
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.Router = class Router {
|
||||
static initClass() {
|
||||
$.extend(this.prototype, Events);
|
||||
|
||||
this.routes = [
|
||||
['*', 'before' ],
|
||||
['/', 'root' ],
|
||||
['/settings', 'settings' ],
|
||||
['/offline', 'offline' ],
|
||||
['/about', 'about' ],
|
||||
['/news', 'news' ],
|
||||
['/help', 'help' ],
|
||||
['/:doc-:type/', 'type' ],
|
||||
['/:doc/', 'doc' ],
|
||||
['/:doc/:path(*)', 'entry' ],
|
||||
['*', 'notFound' ]
|
||||
]
|
||||
|
||||
constructor: ->
|
||||
for [path, method] in @constructor.routes
|
||||
page path, @[method].bind(@)
|
||||
@setInitialPath()
|
||||
|
||||
start: ->
|
||||
page.start()
|
||||
return
|
||||
|
||||
show: (path) ->
|
||||
page.show(path)
|
||||
return
|
||||
|
||||
triggerRoute: (name) ->
|
||||
@trigger name, @context
|
||||
@trigger 'after', name, @context
|
||||
return
|
||||
|
||||
before: (context, next) ->
|
||||
previousContext = @context
|
||||
@context = context
|
||||
@trigger 'before', context
|
||||
|
||||
if res = next()
|
||||
@context = previousContext
|
||||
return res
|
||||
else
|
||||
return
|
||||
|
||||
doc: (context, next) ->
|
||||
if doc = app.docs.findBySlug(context.params.doc) or app.disabledDocs.findBySlug(context.params.doc)
|
||||
context.doc = doc
|
||||
context.entry = doc.toEntry()
|
||||
@triggerRoute 'entry'
|
||||
return
|
||||
else
|
||||
return next()
|
||||
|
||||
type: (context, next) ->
|
||||
doc = app.docs.findBySlug(context.params.doc)
|
||||
|
||||
if type = doc?.types.findBy 'slug', context.params.type
|
||||
context.doc = doc
|
||||
context.type = type
|
||||
@triggerRoute 'type'
|
||||
return
|
||||
else
|
||||
return next()
|
||||
|
||||
entry: (context, next) ->
|
||||
doc = app.docs.findBySlug(context.params.doc)
|
||||
return next() unless doc
|
||||
path = context.params.path
|
||||
hash = context.hash
|
||||
|
||||
if entry = doc.findEntryByPathAndHash(path, hash)
|
||||
context.doc = doc
|
||||
context.entry = entry
|
||||
@triggerRoute 'entry'
|
||||
return
|
||||
else if path.slice(-6) is '/index'
|
||||
path = path.substr(0, path.length - 6)
|
||||
return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash)
|
||||
else
|
||||
path = "#{path}/index"
|
||||
return entry.fullPath() if entry = doc.findEntryByPathAndHash(path, hash)
|
||||
|
||||
return next()
|
||||
|
||||
root: ->
|
||||
return '/' if app.isSingleDoc()
|
||||
@triggerRoute 'root'
|
||||
return
|
||||
|
||||
settings: (context) ->
|
||||
return "/#/#{context.path}" if app.isSingleDoc()
|
||||
@triggerRoute 'settings'
|
||||
return
|
||||
|
||||
offline: (context)->
|
||||
return "/#/#{context.path}" if app.isSingleDoc()
|
||||
@triggerRoute 'offline'
|
||||
return
|
||||
|
||||
about: (context) ->
|
||||
return "/#/#{context.path}" if app.isSingleDoc()
|
||||
context.page = 'about'
|
||||
@triggerRoute 'page'
|
||||
return
|
||||
|
||||
news: (context) ->
|
||||
return "/#/#{context.path}" if app.isSingleDoc()
|
||||
context.page = 'news'
|
||||
@triggerRoute 'page'
|
||||
return
|
||||
|
||||
help: (context) ->
|
||||
return "/#/#{context.path}" if app.isSingleDoc()
|
||||
context.page = 'help'
|
||||
@triggerRoute 'page'
|
||||
return
|
||||
|
||||
notFound: (context) ->
|
||||
@triggerRoute 'notFound'
|
||||
return
|
||||
|
||||
isIndex: ->
|
||||
@context?.path is '/' or (app.isSingleDoc() and @context?.entry?.isIndex())
|
||||
|
||||
isSettings: ->
|
||||
@context?.path is '/settings'
|
||||
|
||||
setInitialPath: ->
|
||||
# Remove superfluous forward slashes at the beginning of the path
|
||||
if (path = location.pathname.replace /^\/{2,}/g, '/') isnt location.pathname
|
||||
page.replace path + location.search + location.hash, null, true
|
||||
|
||||
if location.pathname is '/'
|
||||
if path = @getInitialPathFromHash()
|
||||
page.replace path + location.search, null, true
|
||||
else if path = @getInitialPathFromCookie()
|
||||
page.replace path + location.search + location.hash, null, true
|
||||
return
|
||||
|
||||
getInitialPathFromHash: ->
|
||||
try
|
||||
(new RegExp "#/(.+)").exec(decodeURIComponent location.hash)?[1]
|
||||
catch
|
||||
|
||||
getInitialPathFromCookie: ->
|
||||
if path = Cookies.get('initial_path')
|
||||
Cookies.expire('initial_path')
|
||||
];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
for (var [path, method] of Array.from(this.constructor.routes)) {
|
||||
page(path, this[method].bind(this));
|
||||
}
|
||||
this.setInitialPath();
|
||||
}
|
||||
|
||||
start() {
|
||||
page.start();
|
||||
}
|
||||
|
||||
show(path) {
|
||||
page.show(path);
|
||||
}
|
||||
|
||||
triggerRoute(name) {
|
||||
this.trigger(name, this.context);
|
||||
this.trigger('after', name, this.context);
|
||||
}
|
||||
|
||||
before(context, next) {
|
||||
let res;
|
||||
const previousContext = this.context;
|
||||
this.context = context;
|
||||
this.trigger('before', context);
|
||||
|
||||
if (res = next()) {
|
||||
this.context = previousContext;
|
||||
return res;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
doc(context, next) {
|
||||
let doc;
|
||||
if (doc = app.docs.findBySlug(context.params.doc) || app.disabledDocs.findBySlug(context.params.doc)) {
|
||||
context.doc = doc;
|
||||
context.entry = doc.toEntry();
|
||||
this.triggerRoute('entry');
|
||||
return;
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
type(context, next) {
|
||||
let type;
|
||||
const doc = app.docs.findBySlug(context.params.doc);
|
||||
|
||||
if (type = doc != null ? doc.types.findBy('slug', context.params.type) : undefined) {
|
||||
context.doc = doc;
|
||||
context.type = type;
|
||||
this.triggerRoute('type');
|
||||
return;
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
entry(context, next) {
|
||||
let entry;
|
||||
const doc = app.docs.findBySlug(context.params.doc);
|
||||
if (!doc) { return next(); }
|
||||
let {
|
||||
path
|
||||
|
||||
replaceHash: (hash) ->
|
||||
page.replace location.pathname + location.search + (hash or ''), null, true
|
||||
return
|
||||
} = context.params;
|
||||
const {
|
||||
hash
|
||||
} = context;
|
||||
|
||||
if (entry = doc.findEntryByPathAndHash(path, hash)) {
|
||||
context.doc = doc;
|
||||
context.entry = entry;
|
||||
this.triggerRoute('entry');
|
||||
return;
|
||||
} else if (path.slice(-6) === '/index') {
|
||||
path = path.substr(0, path.length - 6);
|
||||
if (entry = doc.findEntryByPathAndHash(path, hash)) { return entry.fullPath(); }
|
||||
} else {
|
||||
path = `${path}/index`;
|
||||
if (entry = doc.findEntryByPathAndHash(path, hash)) { return entry.fullPath(); }
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
root() {
|
||||
if (app.isSingleDoc()) { return '/'; }
|
||||
this.triggerRoute('root');
|
||||
}
|
||||
|
||||
settings(context) {
|
||||
if (app.isSingleDoc()) { return `/#/${context.path}`; }
|
||||
this.triggerRoute('settings');
|
||||
}
|
||||
|
||||
offline(context){
|
||||
if (app.isSingleDoc()) { return `/#/${context.path}`; }
|
||||
this.triggerRoute('offline');
|
||||
}
|
||||
|
||||
about(context) {
|
||||
if (app.isSingleDoc()) { return `/#/${context.path}`; }
|
||||
context.page = 'about';
|
||||
this.triggerRoute('page');
|
||||
}
|
||||
|
||||
news(context) {
|
||||
if (app.isSingleDoc()) { return `/#/${context.path}`; }
|
||||
context.page = 'news';
|
||||
this.triggerRoute('page');
|
||||
}
|
||||
|
||||
help(context) {
|
||||
if (app.isSingleDoc()) { return `/#/${context.path}`; }
|
||||
context.page = 'help';
|
||||
this.triggerRoute('page');
|
||||
}
|
||||
|
||||
notFound(context) {
|
||||
this.triggerRoute('notFound');
|
||||
}
|
||||
|
||||
isIndex() {
|
||||
return ((this.context != null ? this.context.path : undefined) === '/') || (app.isSingleDoc() && __guard__(this.context != null ? this.context.entry : undefined, x => x.isIndex()));
|
||||
}
|
||||
|
||||
isSettings() {
|
||||
return (this.context != null ? this.context.path : undefined) === '/settings';
|
||||
}
|
||||
|
||||
setInitialPath() {
|
||||
// Remove superfluous forward slashes at the beginning of the path
|
||||
let path;
|
||||
if ((path = location.pathname.replace(/^\/{2,}/g, '/')) !== location.pathname) {
|
||||
page.replace(path + location.search + location.hash, null, true);
|
||||
}
|
||||
|
||||
if (location.pathname === '/') {
|
||||
if (path = this.getInitialPathFromHash()) {
|
||||
page.replace(path + location.search, null, true);
|
||||
} else if (path = this.getInitialPathFromCookie()) {
|
||||
page.replace(path + location.search + location.hash, null, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getInitialPathFromHash() {
|
||||
try {
|
||||
return __guard__((new RegExp("#/(.+)")).exec(decodeURIComponent(location.hash)), x => x[1]);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
getInitialPathFromCookie() {
|
||||
let path;
|
||||
if (path = Cookies.get('initial_path')) {
|
||||
Cookies.expire('initial_path');
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
replaceHash(hash) {
|
||||
page.replace(location.pathname + location.search + (hash || ''), null, true);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,292 +1,373 @@
|
||||
#
|
||||
# Match functions
|
||||
#
|
||||
|
||||
SEPARATOR = '.'
|
||||
|
||||
query =
|
||||
queryLength =
|
||||
value =
|
||||
valueLength =
|
||||
matcher = # current match function
|
||||
fuzzyRegexp = # query fuzzy regexp
|
||||
index = # position of the query in the string being matched
|
||||
lastIndex = # last position of the query in the string being matched
|
||||
match = # regexp match data
|
||||
matchIndex =
|
||||
matchLength =
|
||||
score = # score for the current match
|
||||
separators = # counter
|
||||
i = null # cursor
|
||||
|
||||
`function exactMatch() {`
|
||||
index = value.indexOf(query)
|
||||
return unless index >= 0
|
||||
|
||||
lastIndex = value.lastIndexOf(query)
|
||||
|
||||
if index isnt lastIndex
|
||||
return Math.max(scoreExactMatch(), ((index = lastIndex) and scoreExactMatch()) or 0)
|
||||
else
|
||||
return scoreExactMatch()
|
||||
`}`
|
||||
|
||||
`function scoreExactMatch() {`
|
||||
# Remove one point for each unmatched character.
|
||||
score = 100 - (valueLength - queryLength)
|
||||
|
||||
if index > 0
|
||||
# If the character preceding the query is a dot, assign the same score
|
||||
# as if the query was found at the beginning of the string, minus one.
|
||||
if value.charAt(index - 1) is SEPARATOR
|
||||
score += index - 1
|
||||
# Don't match a single-character query unless it's found at the beginning
|
||||
# of the string or is preceded by a dot.
|
||||
else if queryLength is 1
|
||||
return
|
||||
# (1) Remove one point for each unmatched character up to the nearest
|
||||
# preceding dot or the beginning of the string.
|
||||
# (2) Remove one point for each unmatched character following the query.
|
||||
else
|
||||
i = index - 2
|
||||
i-- while i >= 0 and value.charAt(i) isnt SEPARATOR
|
||||
score -= (index - i) + # (1)
|
||||
(valueLength - queryLength - index) # (2)
|
||||
|
||||
# Remove one point for each dot preceding the query, except for the one
|
||||
# immediately before the query.
|
||||
separators = 0
|
||||
i = index - 2
|
||||
while i >= 0
|
||||
separators++ if value.charAt(i) is SEPARATOR
|
||||
i--
|
||||
score -= separators
|
||||
|
||||
# Remove five points for each dot following the query.
|
||||
separators = 0
|
||||
i = valueLength - queryLength - index - 1
|
||||
while i >= 0
|
||||
separators++ if value.charAt(index + queryLength + i) is SEPARATOR
|
||||
i--
|
||||
score -= separators * 5
|
||||
|
||||
return Math.max 1, score
|
||||
`}`
|
||||
|
||||
`function fuzzyMatch() {`
|
||||
return if valueLength <= queryLength or value.indexOf(query) >= 0
|
||||
return unless match = fuzzyRegexp.exec(value)
|
||||
matchIndex = match.index
|
||||
matchLength = match[0].length
|
||||
score = scoreFuzzyMatch()
|
||||
if match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1))
|
||||
matchIndex = i + match.index
|
||||
matchLength = match[0].length
|
||||
return Math.max(score, scoreFuzzyMatch())
|
||||
else
|
||||
return score
|
||||
`}`
|
||||
|
||||
`function scoreFuzzyMatch() {`
|
||||
# When the match is at the beginning of the string or preceded by a dot.
|
||||
if matchIndex is 0 or value.charAt(matchIndex - 1) is SEPARATOR
|
||||
return Math.max 66, 100 - matchLength
|
||||
# When the match is at the end of the string.
|
||||
else if matchIndex + matchLength is valueLength
|
||||
return Math.max 33, 67 - matchLength
|
||||
# When the match is in the middle of the string.
|
||||
else
|
||||
return Math.max 1, 34 - matchLength
|
||||
`}`
|
||||
|
||||
#
|
||||
# Searchers
|
||||
#
|
||||
|
||||
class app.Searcher
|
||||
$.extend @prototype, Events
|
||||
|
||||
CHUNK_SIZE = 20000
|
||||
|
||||
DEFAULTS =
|
||||
max_results: app.config.max_results
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS104: Avoid inline assignments
|
||||
* DS202: Simplify dynamic range loops
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS209: Avoid top-level return
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//
|
||||
// Match functions
|
||||
//
|
||||
|
||||
let fuzzyRegexp, i, index, lastIndex, match, matcher, matchIndex, matchLength, queryLength, score, separators, value, valueLength;
|
||||
const SEPARATOR = '.';
|
||||
|
||||
let query =
|
||||
(queryLength =
|
||||
(value =
|
||||
(valueLength =
|
||||
(matcher = // current match function
|
||||
(fuzzyRegexp = // query fuzzy regexp
|
||||
(index = // position of the query in the string being matched
|
||||
(lastIndex = // last position of the query in the string being matched
|
||||
(match = // regexp match data
|
||||
(matchIndex =
|
||||
(matchLength =
|
||||
(score = // score for the current match
|
||||
(separators = // counter
|
||||
(i = null))))))))))))); // cursor
|
||||
|
||||
function exactMatch() {
|
||||
index = value.indexOf(query);
|
||||
if (!(index >= 0)) { return; }
|
||||
|
||||
lastIndex = value.lastIndexOf(query);
|
||||
|
||||
if (index !== lastIndex) {
|
||||
return Math.max(scoreExactMatch(), ((index = lastIndex) && scoreExactMatch()) || 0);
|
||||
} else {
|
||||
return scoreExactMatch();
|
||||
}
|
||||
}
|
||||
|
||||
function scoreExactMatch() {
|
||||
// Remove one point for each unmatched character.
|
||||
score = 100 - (valueLength - queryLength);
|
||||
|
||||
if (index > 0) {
|
||||
// If the character preceding the query is a dot, assign the same score
|
||||
// as if the query was found at the beginning of the string, minus one.
|
||||
if (value.charAt(index - 1) === SEPARATOR) {
|
||||
score += index - 1;
|
||||
// Don't match a single-character query unless it's found at the beginning
|
||||
// of the string or is preceded by a dot.
|
||||
} else if (queryLength === 1) {
|
||||
return;
|
||||
// (1) Remove one point for each unmatched character up to the nearest
|
||||
// preceding dot or the beginning of the string.
|
||||
// (2) Remove one point for each unmatched character following the query.
|
||||
} else {
|
||||
i = index - 2;
|
||||
while ((i >= 0) && (value.charAt(i) !== SEPARATOR)) { i--; }
|
||||
score -= (index - i) + // (1)
|
||||
(valueLength - queryLength - index); // (2)
|
||||
}
|
||||
|
||||
// Remove one point for each dot preceding the query, except for the one
|
||||
// immediately before the query.
|
||||
separators = 0;
|
||||
i = index - 2;
|
||||
while (i >= 0) {
|
||||
if (value.charAt(i) === SEPARATOR) { separators++; }
|
||||
i--;
|
||||
}
|
||||
score -= separators;
|
||||
}
|
||||
|
||||
// Remove five points for each dot following the query.
|
||||
separators = 0;
|
||||
i = valueLength - queryLength - index - 1;
|
||||
while (i >= 0) {
|
||||
if (value.charAt(index + queryLength + i) === SEPARATOR) { separators++; }
|
||||
i--;
|
||||
}
|
||||
score -= separators * 5;
|
||||
|
||||
return Math.max(1, score);
|
||||
}
|
||||
|
||||
function fuzzyMatch() {
|
||||
if ((valueLength <= queryLength) || (value.indexOf(query) >= 0)) { return; }
|
||||
if (!(match = fuzzyRegexp.exec(value))) { return; }
|
||||
matchIndex = match.index;
|
||||
matchLength = match[0].length;
|
||||
score = scoreFuzzyMatch();
|
||||
if (match = fuzzyRegexp.exec(value.slice(i = value.lastIndexOf(SEPARATOR) + 1))) {
|
||||
matchIndex = i + match.index;
|
||||
matchLength = match[0].length;
|
||||
return Math.max(score, scoreFuzzyMatch());
|
||||
} else {
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
function scoreFuzzyMatch() {
|
||||
// When the match is at the beginning of the string or preceded by a dot.
|
||||
if ((matchIndex === 0) || (value.charAt(matchIndex - 1) === SEPARATOR)) {
|
||||
return Math.max(66, 100 - matchLength);
|
||||
// When the match is at the end of the string.
|
||||
} else if ((matchIndex + matchLength) === valueLength) {
|
||||
return Math.max(33, 67 - matchLength);
|
||||
// When the match is in the middle of the string.
|
||||
} else {
|
||||
return Math.max(1, 34 - matchLength);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Searchers
|
||||
//
|
||||
|
||||
(function() {
|
||||
let CHUNK_SIZE = undefined;
|
||||
let DEFAULTS = undefined;
|
||||
let SEPARATORS_REGEXP = undefined;
|
||||
let EOS_SEPARATORS_REGEXP = undefined;
|
||||
let INFO_PARANTHESES_REGEXP = undefined;
|
||||
let EMPTY_PARANTHESES_REGEXP = undefined;
|
||||
let EVENT_REGEXP = undefined;
|
||||
let DOT_REGEXP = undefined;
|
||||
let WHITESPACE_REGEXP = undefined;
|
||||
let EMPTY_STRING = undefined;
|
||||
let ELLIPSIS = undefined;
|
||||
let STRING = undefined;
|
||||
const Cls = (app.Searcher = class Searcher {
|
||||
static initClass() {
|
||||
$.extend(this.prototype, Events);
|
||||
|
||||
CHUNK_SIZE = 20000;
|
||||
|
||||
DEFAULTS = {
|
||||
max_results: app.config.max_results,
|
||||
fuzzy_min_length: 3
|
||||
|
||||
SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g
|
||||
EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/
|
||||
INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/
|
||||
EMPTY_PARANTHESES_REGEXP = /\(\)/
|
||||
EVENT_REGEXP = /\ event$/
|
||||
DOT_REGEXP = /\.+/g
|
||||
WHITESPACE_REGEXP = /\s/g
|
||||
|
||||
EMPTY_STRING = ''
|
||||
ELLIPSIS = '...'
|
||||
STRING = 'string'
|
||||
|
||||
@normalizeString: (string) ->
|
||||
string
|
||||
};
|
||||
|
||||
SEPARATORS_REGEXP = /#|::|:-|->|\$(?=\w)|\-(?=\w)|\:(?=\w)|\ [\/\-&]\ |:\ |\ /g;
|
||||
EOS_SEPARATORS_REGEXP = /(\w)[\-:]$/;
|
||||
INFO_PARANTHESES_REGEXP = /\ \(\w+?\)$/;
|
||||
EMPTY_PARANTHESES_REGEXP = /\(\)/;
|
||||
EVENT_REGEXP = /\ event$/;
|
||||
DOT_REGEXP = /\.+/g;
|
||||
WHITESPACE_REGEXP = /\s/g;
|
||||
|
||||
EMPTY_STRING = '';
|
||||
ELLIPSIS = '...';
|
||||
STRING = 'string';
|
||||
}
|
||||
|
||||
static normalizeString(string) {
|
||||
return string
|
||||
.toLowerCase()
|
||||
.replace ELLIPSIS, EMPTY_STRING
|
||||
.replace EVENT_REGEXP, EMPTY_STRING
|
||||
.replace INFO_PARANTHESES_REGEXP, EMPTY_STRING
|
||||
.replace SEPARATORS_REGEXP, SEPARATOR
|
||||
.replace DOT_REGEXP, SEPARATOR
|
||||
.replace EMPTY_PARANTHESES_REGEXP, EMPTY_STRING
|
||||
.replace WHITESPACE_REGEXP, EMPTY_STRING
|
||||
|
||||
@normalizeQuery: (string) ->
|
||||
string = @normalizeString(string)
|
||||
string.replace EOS_SEPARATORS_REGEXP, '$1.'
|
||||
|
||||
constructor: (options = {}) ->
|
||||
@options = $.extend {}, DEFAULTS, options
|
||||
|
||||
find: (data, attr, q) ->
|
||||
@kill()
|
||||
|
||||
@data = data
|
||||
@attr = attr
|
||||
@query = q
|
||||
@setup()
|
||||
|
||||
if @isValid() then @match() else @end()
|
||||
return
|
||||
|
||||
setup: ->
|
||||
query = @query = @constructor.normalizeQuery(@query)
|
||||
queryLength = query.length
|
||||
@dataLength = @data.length
|
||||
@matchers = [exactMatch]
|
||||
@totalResults = 0
|
||||
@setupFuzzy()
|
||||
return
|
||||
|
||||
setupFuzzy: ->
|
||||
if queryLength >= @options.fuzzy_min_length
|
||||
fuzzyRegexp = @queryToFuzzyRegexp(query)
|
||||
@matchers.push(fuzzyMatch)
|
||||
else
|
||||
fuzzyRegexp = null
|
||||
return
|
||||
|
||||
isValid: ->
|
||||
queryLength > 0 and query isnt SEPARATOR
|
||||
|
||||
end: ->
|
||||
@triggerResults [] unless @totalResults
|
||||
@trigger 'end'
|
||||
@free()
|
||||
return
|
||||
|
||||
kill: ->
|
||||
if @timeout
|
||||
clearTimeout @timeout
|
||||
@free()
|
||||
return
|
||||
|
||||
free: ->
|
||||
@data = @attr = @dataLength = @matchers = @matcher = @query =
|
||||
@totalResults = @scoreMap = @cursor = @timeout = null
|
||||
return
|
||||
|
||||
match: =>
|
||||
if not @foundEnough() and @matcher = @matchers.shift()
|
||||
@setupMatcher()
|
||||
@matchChunks()
|
||||
else
|
||||
@end()
|
||||
return
|
||||
|
||||
setupMatcher: ->
|
||||
@cursor = 0
|
||||
@scoreMap = new Array(101)
|
||||
return
|
||||
|
||||
matchChunks: =>
|
||||
@matchChunk()
|
||||
|
||||
if @cursor is @dataLength or @scoredEnough()
|
||||
@delay @match
|
||||
@sendResults()
|
||||
else
|
||||
@delay @matchChunks
|
||||
return
|
||||
|
||||
matchChunk: ->
|
||||
matcher = @matcher
|
||||
for [0...@chunkSize()]
|
||||
value = @data[@cursor][@attr]
|
||||
if value.split # string
|
||||
valueLength = value.length
|
||||
@addResult(@data[@cursor], score) if score = matcher()
|
||||
else # array
|
||||
score = 0
|
||||
for value in @data[@cursor][@attr]
|
||||
valueLength = value.length
|
||||
score = Math.max(score, matcher() || 0)
|
||||
@addResult(@data[@cursor], score) if score > 0
|
||||
@cursor++
|
||||
return
|
||||
|
||||
chunkSize: ->
|
||||
if @cursor + CHUNK_SIZE > @dataLength
|
||||
@dataLength % CHUNK_SIZE
|
||||
else
|
||||
CHUNK_SIZE
|
||||
|
||||
scoredEnough: ->
|
||||
@scoreMap[100]?.length >= @options.max_results
|
||||
|
||||
foundEnough: ->
|
||||
@totalResults >= @options.max_results
|
||||
|
||||
addResult: (object, score) ->
|
||||
(@scoreMap[Math.round(score)] or= []).push(object)
|
||||
@totalResults++
|
||||
return
|
||||
|
||||
getResults: ->
|
||||
results = []
|
||||
for objects in @scoreMap by -1 when objects
|
||||
results.push.apply results, objects
|
||||
results[0...@options.max_results]
|
||||
|
||||
sendResults: ->
|
||||
results = @getResults()
|
||||
@triggerResults results if results.length
|
||||
return
|
||||
|
||||
triggerResults: (results) ->
|
||||
@trigger 'results', results
|
||||
return
|
||||
|
||||
delay: (fn) ->
|
||||
@timeout = setTimeout(fn, 1)
|
||||
|
||||
queryToFuzzyRegexp: (string) ->
|
||||
chars = string.split ''
|
||||
chars[i] = $.escapeRegexp(char) for char, i in chars
|
||||
new RegExp chars.join('.*?') # abc -> /a.*?b.*?c.*?/
|
||||
|
||||
class app.SynchronousSearcher extends app.Searcher
|
||||
match: =>
|
||||
if @matcher
|
||||
@allResults or= []
|
||||
@allResults.push.apply @allResults, @getResults()
|
||||
super
|
||||
|
||||
free: ->
|
||||
@allResults = null
|
||||
super
|
||||
|
||||
end: ->
|
||||
@sendResults true
|
||||
super
|
||||
|
||||
sendResults: (end) ->
|
||||
if end and @allResults?.length
|
||||
@triggerResults @allResults
|
||||
|
||||
delay: (fn) ->
|
||||
fn()
|
||||
.replace(ELLIPSIS, EMPTY_STRING)
|
||||
.replace(EVENT_REGEXP, EMPTY_STRING)
|
||||
.replace(INFO_PARANTHESES_REGEXP, EMPTY_STRING)
|
||||
.replace(SEPARATORS_REGEXP, SEPARATOR)
|
||||
.replace(DOT_REGEXP, SEPARATOR)
|
||||
.replace(EMPTY_PARANTHESES_REGEXP, EMPTY_STRING)
|
||||
.replace(WHITESPACE_REGEXP, EMPTY_STRING);
|
||||
}
|
||||
|
||||
static normalizeQuery(string) {
|
||||
string = this.normalizeString(string);
|
||||
return string.replace(EOS_SEPARATORS_REGEXP, '$1.');
|
||||
}
|
||||
|
||||
constructor(options) {
|
||||
this.match = this.match.bind(this);
|
||||
this.matchChunks = this.matchChunks.bind(this);
|
||||
if (options == null) { options = {}; }
|
||||
this.options = $.extend({}, DEFAULTS, options);
|
||||
}
|
||||
|
||||
find(data, attr, q) {
|
||||
this.kill();
|
||||
|
||||
this.data = data;
|
||||
this.attr = attr;
|
||||
this.query = q;
|
||||
this.setup();
|
||||
|
||||
if (this.isValid()) { this.match(); } else { this.end(); }
|
||||
}
|
||||
|
||||
setup() {
|
||||
query = (this.query = this.constructor.normalizeQuery(this.query));
|
||||
queryLength = query.length;
|
||||
this.dataLength = this.data.length;
|
||||
this.matchers = [exactMatch];
|
||||
this.totalResults = 0;
|
||||
this.setupFuzzy();
|
||||
}
|
||||
|
||||
setupFuzzy() {
|
||||
if (queryLength >= this.options.fuzzy_min_length) {
|
||||
fuzzyRegexp = this.queryToFuzzyRegexp(query);
|
||||
this.matchers.push(fuzzyMatch);
|
||||
} else {
|
||||
fuzzyRegexp = null;
|
||||
}
|
||||
}
|
||||
|
||||
isValid() {
|
||||
return (queryLength > 0) && (query !== SEPARATOR);
|
||||
}
|
||||
|
||||
end() {
|
||||
if (!this.totalResults) { this.triggerResults([]); }
|
||||
this.trigger('end');
|
||||
this.free();
|
||||
}
|
||||
|
||||
kill() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.free();
|
||||
}
|
||||
}
|
||||
|
||||
free() {
|
||||
this.data = (this.attr = (this.dataLength = (this.matchers = (this.matcher = (this.query =
|
||||
(this.totalResults = (this.scoreMap = (this.cursor = (this.timeout = null)))))))));
|
||||
}
|
||||
|
||||
match() {
|
||||
if (!this.foundEnough() && (this.matcher = this.matchers.shift())) {
|
||||
this.setupMatcher();
|
||||
this.matchChunks();
|
||||
} else {
|
||||
this.end();
|
||||
}
|
||||
}
|
||||
|
||||
setupMatcher() {
|
||||
this.cursor = 0;
|
||||
this.scoreMap = new Array(101);
|
||||
}
|
||||
|
||||
matchChunks() {
|
||||
this.matchChunk();
|
||||
|
||||
if ((this.cursor === this.dataLength) || this.scoredEnough()) {
|
||||
this.delay(this.match);
|
||||
this.sendResults();
|
||||
} else {
|
||||
this.delay(this.matchChunks);
|
||||
}
|
||||
}
|
||||
|
||||
matchChunk() {
|
||||
({
|
||||
matcher
|
||||
} = this);
|
||||
for (let j = 0, end = this.chunkSize(), asc = 0 <= end; asc ? j < end : j > end; asc ? j++ : j--) {
|
||||
value = this.data[this.cursor][this.attr];
|
||||
if (value.split) { // string
|
||||
valueLength = value.length;
|
||||
if (score = matcher()) { this.addResult(this.data[this.cursor], score); }
|
||||
} else { // array
|
||||
score = 0;
|
||||
for (value of Array.from(this.data[this.cursor][this.attr])) {
|
||||
valueLength = value.length;
|
||||
score = Math.max(score, matcher() || 0);
|
||||
}
|
||||
if (score > 0) { this.addResult(this.data[this.cursor], score); }
|
||||
}
|
||||
this.cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
chunkSize() {
|
||||
if ((this.cursor + CHUNK_SIZE) > this.dataLength) {
|
||||
return this.dataLength % CHUNK_SIZE;
|
||||
} else {
|
||||
return CHUNK_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
scoredEnough() {
|
||||
return (this.scoreMap[100] != null ? this.scoreMap[100].length : undefined) >= this.options.max_results;
|
||||
}
|
||||
|
||||
foundEnough() {
|
||||
return this.totalResults >= this.options.max_results;
|
||||
}
|
||||
|
||||
addResult(object, score) {
|
||||
let name;
|
||||
(this.scoreMap[name = Math.round(score)] || (this.scoreMap[name] = [])).push(object);
|
||||
this.totalResults++;
|
||||
}
|
||||
|
||||
getResults() {
|
||||
const results = [];
|
||||
for (let j = this.scoreMap.length - 1; j >= 0; j--) {
|
||||
var objects = this.scoreMap[j];
|
||||
if (objects) {
|
||||
results.push.apply(results, objects);
|
||||
}
|
||||
}
|
||||
return results.slice(0, this.options.max_results);
|
||||
}
|
||||
|
||||
sendResults() {
|
||||
const results = this.getResults();
|
||||
if (results.length) { this.triggerResults(results); }
|
||||
}
|
||||
|
||||
triggerResults(results) {
|
||||
this.trigger('results', results);
|
||||
}
|
||||
|
||||
delay(fn) {
|
||||
return this.timeout = setTimeout(fn, 1);
|
||||
}
|
||||
|
||||
queryToFuzzyRegexp(string) {
|
||||
const chars = string.split('');
|
||||
for (i = 0; i < chars.length; i++) { var char = chars[i]; chars[i] = $.escapeRegexp(char); }
|
||||
return new RegExp(chars.join('.*?'));
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls; // abc -> /a.*?b.*?c.*?/
|
||||
})();
|
||||
|
||||
app.SynchronousSearcher = class SynchronousSearcher extends app.Searcher {
|
||||
constructor(...args) {
|
||||
this.match = this.match.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
match() {
|
||||
if (this.matcher) {
|
||||
if (!this.allResults) { this.allResults = []; }
|
||||
this.allResults.push.apply(this.allResults, this.getResults());
|
||||
}
|
||||
return super.match(...arguments);
|
||||
}
|
||||
|
||||
free() {
|
||||
this.allResults = null;
|
||||
return super.free(...arguments);
|
||||
}
|
||||
|
||||
end() {
|
||||
this.sendResults(true);
|
||||
return super.end(...arguments);
|
||||
}
|
||||
|
||||
sendResults(end) {
|
||||
if (end && (this.allResults != null ? this.allResults.length : undefined)) {
|
||||
return this.triggerResults(this.allResults);
|
||||
}
|
||||
}
|
||||
|
||||
delay(fn) {
|
||||
return fn();
|
||||
}
|
||||
};
|
||||
|
@ -1,49 +1,66 @@
|
||||
class app.ServiceWorker
|
||||
$.extend @prototype, Events
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.ServiceWorker = class ServiceWorker {
|
||||
static initClass() {
|
||||
$.extend(this.prototype, Events);
|
||||
}
|
||||
|
||||
@isEnabled: ->
|
||||
!!navigator.serviceWorker and app.config.service_worker_enabled
|
||||
static isEnabled() {
|
||||
return !!navigator.serviceWorker && app.config.service_worker_enabled;
|
||||
}
|
||||
|
||||
constructor: ->
|
||||
@registration = null
|
||||
@notifyUpdate = true
|
||||
constructor() {
|
||||
this.onUpdateFound = this.onUpdateFound.bind(this);
|
||||
this.onStateChange = this.onStateChange.bind(this);
|
||||
this.registration = null;
|
||||
this.notifyUpdate = true;
|
||||
|
||||
navigator.serviceWorker.register(app.config.service_worker_path, {scope: '/'})
|
||||
.then(
|
||||
(registration) => @updateRegistration(registration),
|
||||
(error) -> console.error('Could not register service worker:', error)
|
||||
)
|
||||
|
||||
update: ->
|
||||
return unless @registration
|
||||
@notifyUpdate = true
|
||||
return @registration.update().catch(->)
|
||||
|
||||
updateInBackground: ->
|
||||
return unless @registration
|
||||
@notifyUpdate = false
|
||||
return @registration.update().catch(->)
|
||||
|
||||
reload: ->
|
||||
return @updateInBackground().then(() -> app.reboot())
|
||||
|
||||
updateRegistration: (registration) ->
|
||||
@registration = registration
|
||||
$.on @registration, 'updatefound', @onUpdateFound
|
||||
return
|
||||
|
||||
onUpdateFound: =>
|
||||
$.off @installingRegistration, 'statechange', @onStateChange() if @installingRegistration
|
||||
@installingRegistration = @registration.installing
|
||||
$.on @installingRegistration, 'statechange', @onStateChange
|
||||
return
|
||||
|
||||
onStateChange: =>
|
||||
if @installingRegistration and @installingRegistration.state == 'installed' and navigator.serviceWorker.controller
|
||||
@installingRegistration = null
|
||||
@onUpdateReady()
|
||||
return
|
||||
|
||||
onUpdateReady: ->
|
||||
@trigger 'updateready' if @notifyUpdate
|
||||
return
|
||||
registration => this.updateRegistration(registration),
|
||||
error => console.error('Could not register service worker:', error));
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.registration) { return; }
|
||||
this.notifyUpdate = true;
|
||||
return this.registration.update().catch(function() {});
|
||||
}
|
||||
|
||||
updateInBackground() {
|
||||
if (!this.registration) { return; }
|
||||
this.notifyUpdate = false;
|
||||
return this.registration.update().catch(function() {});
|
||||
}
|
||||
|
||||
reload() {
|
||||
return this.updateInBackground().then(() => app.reboot());
|
||||
}
|
||||
|
||||
updateRegistration(registration) {
|
||||
this.registration = registration;
|
||||
$.on(this.registration, 'updatefound', this.onUpdateFound);
|
||||
}
|
||||
|
||||
onUpdateFound() {
|
||||
if (this.installingRegistration) { $.off(this.installingRegistration, 'statechange', this.onStateChange()); }
|
||||
this.installingRegistration = this.registration.installing;
|
||||
$.on(this.installingRegistration, 'statechange', this.onStateChange);
|
||||
}
|
||||
|
||||
onStateChange() {
|
||||
if (this.installingRegistration && (this.installingRegistration.state === 'installed') && navigator.serviceWorker.controller) {
|
||||
this.installingRegistration = null;
|
||||
this.onUpdateReady();
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateReady() {
|
||||
if (this.notifyUpdate) { this.trigger('updateready'); }
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,170 +1,219 @@
|
||||
class app.Settings
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS104: Avoid inline assignments
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let PREFERENCE_KEYS = undefined;
|
||||
let INTERNAL_KEYS = undefined;
|
||||
const Cls = (app.Settings = class Settings {
|
||||
static initClass() {
|
||||
PREFERENCE_KEYS = [
|
||||
'hideDisabled'
|
||||
'hideIntro'
|
||||
'manualUpdate'
|
||||
'fastScroll'
|
||||
'arrowScroll'
|
||||
'analyticsConsent'
|
||||
'docs'
|
||||
'dark' # legacy
|
||||
'theme'
|
||||
'layout'
|
||||
'size'
|
||||
'tips'
|
||||
'noAutofocus'
|
||||
'autoInstall'
|
||||
'spaceScroll'
|
||||
'hideDisabled',
|
||||
'hideIntro',
|
||||
'manualUpdate',
|
||||
'fastScroll',
|
||||
'arrowScroll',
|
||||
'analyticsConsent',
|
||||
'docs',
|
||||
'dark', // legacy
|
||||
'theme',
|
||||
'layout',
|
||||
'size',
|
||||
'tips',
|
||||
'noAutofocus',
|
||||
'autoInstall',
|
||||
'spaceScroll',
|
||||
'spaceTimeout'
|
||||
]
|
||||
];
|
||||
|
||||
INTERNAL_KEYS = [
|
||||
'count'
|
||||
'schema'
|
||||
'version'
|
||||
'count',
|
||||
'schema',
|
||||
'version',
|
||||
'news'
|
||||
]
|
||||
];
|
||||
|
||||
LAYOUTS: [
|
||||
'_max-width'
|
||||
'_sidebar-hidden'
|
||||
'_native-scrollbars'
|
||||
this.prototype.LAYOUTS = [
|
||||
'_max-width',
|
||||
'_sidebar-hidden',
|
||||
'_native-scrollbars',
|
||||
'_text-justify-hyphenate'
|
||||
]
|
||||
|
||||
@defaults:
|
||||
count: 0
|
||||
hideDisabled: false
|
||||
hideIntro: false
|
||||
news: 0
|
||||
manualUpdate: false
|
||||
schema: 1
|
||||
analyticsConsent: false
|
||||
theme: 'auto'
|
||||
spaceScroll: 1
|
||||
];
|
||||
|
||||
this.defaults = {
|
||||
count: 0,
|
||||
hideDisabled: false,
|
||||
hideIntro: false,
|
||||
news: 0,
|
||||
manualUpdate: false,
|
||||
schema: 1,
|
||||
analyticsConsent: false,
|
||||
theme: 'auto',
|
||||
spaceScroll: 1,
|
||||
spaceTimeout: 0.5
|
||||
|
||||
constructor: ->
|
||||
@store = new CookiesStore
|
||||
@cache = {}
|
||||
@autoSupported = window.matchMedia('(prefers-color-scheme)').media != 'not all'
|
||||
if @autoSupported
|
||||
@darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
@darkModeQuery.addListener => @setTheme(@get('theme'))
|
||||
|
||||
|
||||
get: (key) ->
|
||||
return @cache[key] if @cache.hasOwnProperty(key)
|
||||
@cache[key] = @store.get(key) ? @constructor.defaults[key]
|
||||
if key == 'theme' and @cache[key] == 'auto' and !@darkModeQuery
|
||||
@cache[key] = 'default'
|
||||
else
|
||||
@cache[key]
|
||||
|
||||
set: (key, value) ->
|
||||
@store.set(key, value)
|
||||
delete @cache[key]
|
||||
@setTheme(value) if key == 'theme'
|
||||
return
|
||||
|
||||
del: (key) ->
|
||||
@store.del(key)
|
||||
delete @cache[key]
|
||||
return
|
||||
|
||||
hasDocs: ->
|
||||
try !!@store.get('docs')
|
||||
|
||||
getDocs: ->
|
||||
@store.get('docs')?.split('/') or app.config.default_docs
|
||||
|
||||
setDocs: (docs) ->
|
||||
@set 'docs', docs.join('/')
|
||||
return
|
||||
|
||||
getTips: ->
|
||||
@store.get('tips')?.split('/') or []
|
||||
|
||||
setTips: (tips) ->
|
||||
@set 'tips', tips.join('/')
|
||||
return
|
||||
|
||||
setLayout: (name, enable) ->
|
||||
@toggleLayout(name, enable)
|
||||
|
||||
layout = (@store.get('layout') || '').split(' ')
|
||||
$.arrayDelete(layout, '')
|
||||
|
||||
if enable
|
||||
layout.push(name) if layout.indexOf(name) is -1
|
||||
else
|
||||
$.arrayDelete(layout, name)
|
||||
|
||||
if layout.length > 0
|
||||
@set 'layout', layout.join(' ')
|
||||
else
|
||||
@del 'layout'
|
||||
return
|
||||
|
||||
hasLayout: (name) ->
|
||||
layout = (@store.get('layout') || '').split(' ')
|
||||
layout.indexOf(name) isnt -1
|
||||
|
||||
setSize: (value) ->
|
||||
@set 'size', value
|
||||
return
|
||||
|
||||
dump: ->
|
||||
@store.dump()
|
||||
|
||||
export: ->
|
||||
data = @dump()
|
||||
delete data[key] for key in INTERNAL_KEYS
|
||||
data
|
||||
|
||||
import: (data) ->
|
||||
for key, value of @export()
|
||||
@del key unless data.hasOwnProperty(key)
|
||||
for key, value of data
|
||||
@set key, value if PREFERENCE_KEYS.indexOf(key) isnt -1
|
||||
return
|
||||
|
||||
reset: ->
|
||||
@store.reset()
|
||||
@cache = {}
|
||||
return
|
||||
|
||||
initLayout: ->
|
||||
if @get('dark') is 1
|
||||
@set('theme', 'dark')
|
||||
@del 'dark'
|
||||
@setTheme(@get('theme'))
|
||||
@toggleLayout(layout, @hasLayout(layout)) for layout in @LAYOUTS
|
||||
@initSidebarWidth()
|
||||
return
|
||||
|
||||
setTheme: (theme) ->
|
||||
if theme is 'auto'
|
||||
theme = if @darkModeQuery.matches then 'dark' else 'default'
|
||||
classList = document.documentElement.classList
|
||||
classList.remove('_theme-default', '_theme-dark')
|
||||
classList.add('_theme-' + theme)
|
||||
@updateColorMeta()
|
||||
return
|
||||
|
||||
updateColorMeta: ->
|
||||
color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim()
|
||||
$('meta[name=theme-color]').setAttribute('content', color)
|
||||
return
|
||||
|
||||
toggleLayout: (layout, enable) ->
|
||||
classList = document.body.classList
|
||||
# sidebar is always shown for settings; its state is updated in app.views.Settings
|
||||
classList.toggle(layout, enable) unless layout is '_sidebar-hidden' and app.router?.isSettings
|
||||
classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled())
|
||||
return
|
||||
|
||||
initSidebarWidth: ->
|
||||
size = @get('size')
|
||||
document.documentElement.style.setProperty('--sidebarWidth', size + 'px') if size
|
||||
return
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.store = new CookiesStore;
|
||||
this.cache = {};
|
||||
this.autoSupported = window.matchMedia('(prefers-color-scheme)').media !== 'not all';
|
||||
if (this.autoSupported) {
|
||||
this.darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.darkModeQuery.addListener(() => this.setTheme(this.get('theme')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get(key) {
|
||||
let left;
|
||||
if (this.cache.hasOwnProperty(key)) { return this.cache[key]; }
|
||||
this.cache[key] = (left = this.store.get(key)) != null ? left : this.constructor.defaults[key];
|
||||
if ((key === 'theme') && (this.cache[key] === 'auto') && !this.darkModeQuery) {
|
||||
return this.cache[key] = 'default';
|
||||
} else {
|
||||
return this.cache[key];
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.store.set(key, value);
|
||||
delete this.cache[key];
|
||||
if (key === 'theme') { this.setTheme(value); }
|
||||
}
|
||||
|
||||
del(key) {
|
||||
this.store.del(key);
|
||||
delete this.cache[key];
|
||||
}
|
||||
|
||||
hasDocs() {
|
||||
try { return !!this.store.get('docs'); } catch (error) {}
|
||||
}
|
||||
|
||||
getDocs() {
|
||||
return __guard__(this.store.get('docs'), x => x.split('/')) || app.config.default_docs;
|
||||
}
|
||||
|
||||
setDocs(docs) {
|
||||
this.set('docs', docs.join('/'));
|
||||
}
|
||||
|
||||
getTips() {
|
||||
return __guard__(this.store.get('tips'), x => x.split('/')) || [];
|
||||
}
|
||||
|
||||
setTips(tips) {
|
||||
this.set('tips', tips.join('/'));
|
||||
}
|
||||
|
||||
setLayout(name, enable) {
|
||||
this.toggleLayout(name, enable);
|
||||
|
||||
const layout = (this.store.get('layout') || '').split(' ');
|
||||
$.arrayDelete(layout, '');
|
||||
|
||||
if (enable) {
|
||||
if (layout.indexOf(name) === -1) { layout.push(name); }
|
||||
} else {
|
||||
$.arrayDelete(layout, name);
|
||||
}
|
||||
|
||||
if (layout.length > 0) {
|
||||
this.set('layout', layout.join(' '));
|
||||
} else {
|
||||
this.del('layout');
|
||||
}
|
||||
}
|
||||
|
||||
hasLayout(name) {
|
||||
const layout = (this.store.get('layout') || '').split(' ');
|
||||
return layout.indexOf(name) !== -1;
|
||||
}
|
||||
|
||||
setSize(value) {
|
||||
this.set('size', value);
|
||||
}
|
||||
|
||||
dump() {
|
||||
return this.store.dump();
|
||||
}
|
||||
|
||||
export() {
|
||||
const data = this.dump();
|
||||
for (var key of Array.from(INTERNAL_KEYS)) { delete data[key]; }
|
||||
return data;
|
||||
}
|
||||
|
||||
import(data) {
|
||||
let key, value;
|
||||
const object = this.export();
|
||||
for (key in object) {
|
||||
value = object[key];
|
||||
if (!data.hasOwnProperty(key)) { this.del(key); }
|
||||
}
|
||||
for (key in data) {
|
||||
value = data[key];
|
||||
if (PREFERENCE_KEYS.indexOf(key) !== -1) { this.set(key, value); }
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.store.reset();
|
||||
this.cache = {};
|
||||
}
|
||||
|
||||
initLayout() {
|
||||
if (this.get('dark') === 1) {
|
||||
this.set('theme', 'dark');
|
||||
this.del('dark');
|
||||
}
|
||||
this.setTheme(this.get('theme'));
|
||||
for (var layout of Array.from(this.LAYOUTS)) { this.toggleLayout(layout, this.hasLayout(layout)); }
|
||||
this.initSidebarWidth();
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
if (theme === 'auto') {
|
||||
theme = this.darkModeQuery.matches ? 'dark' : 'default';
|
||||
}
|
||||
const {
|
||||
classList
|
||||
} = document.documentElement;
|
||||
classList.remove('_theme-default', '_theme-dark');
|
||||
classList.add('_theme-' + theme);
|
||||
this.updateColorMeta();
|
||||
}
|
||||
|
||||
updateColorMeta() {
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--headerBackground').trim();
|
||||
$('meta[name=theme-color]').setAttribute('content', color);
|
||||
}
|
||||
|
||||
toggleLayout(layout, enable) {
|
||||
const {
|
||||
classList
|
||||
} = document.body;
|
||||
// sidebar is always shown for settings; its state is updated in app.views.Settings
|
||||
if ((layout !== '_sidebar-hidden') || !(app.router != null ? app.router.isSettings : undefined)) { classList.toggle(layout, enable); }
|
||||
classList.toggle('_overlay-scrollbars', $.overlayScrollbarsEnabled());
|
||||
}
|
||||
|
||||
initSidebarWidth() {
|
||||
const size = this.get('size');
|
||||
if (size) { document.documentElement.style.setProperty('--sidebarWidth', size + 'px'); }
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,193 +1,259 @@
|
||||
class app.Shortcuts
|
||||
$.extend @prototype, Events
|
||||
|
||||
constructor: ->
|
||||
@isMac = $.isMac()
|
||||
@start()
|
||||
|
||||
start: ->
|
||||
$.on document, 'keydown', @onKeydown
|
||||
$.on document, 'keypress', @onKeypress
|
||||
return
|
||||
|
||||
stop: ->
|
||||
$.off document, 'keydown', @onKeydown
|
||||
$.off document, 'keypress', @onKeypress
|
||||
return
|
||||
|
||||
swapArrowKeysBehavior: ->
|
||||
app.settings.get('arrowScroll')
|
||||
|
||||
spaceScroll: ->
|
||||
app.settings.get('spaceScroll')
|
||||
|
||||
showTip: ->
|
||||
app.showTip('KeyNav')
|
||||
@showTip = null
|
||||
|
||||
spaceTimeout: ->
|
||||
app.settings.get('spaceTimeout')
|
||||
|
||||
onKeydown: (event) =>
|
||||
return if @buggyEvent(event)
|
||||
result = if event.ctrlKey or event.metaKey
|
||||
@handleKeydownSuperEvent event unless event.altKey or event.shiftKey
|
||||
else if event.shiftKey
|
||||
@handleKeydownShiftEvent event unless event.altKey
|
||||
else if event.altKey
|
||||
@handleKeydownAltEvent event
|
||||
else
|
||||
@handleKeydownEvent event
|
||||
|
||||
event.preventDefault() if result is false
|
||||
return
|
||||
|
||||
onKeypress: (event) =>
|
||||
return if @buggyEvent(event) or (event.charCode == 63 and document.activeElement.tagName == 'INPUT')
|
||||
unless event.ctrlKey or event.metaKey
|
||||
result = @handleKeypressEvent event
|
||||
event.preventDefault() if result is false
|
||||
return
|
||||
|
||||
handleKeydownEvent: (event, _force) ->
|
||||
return @handleKeydownAltEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
|
||||
|
||||
if not event.target.form and (48 <= event.which <= 57 or 65 <= event.which <= 90)
|
||||
@trigger 'typing'
|
||||
return
|
||||
|
||||
switch event.which
|
||||
when 8
|
||||
@trigger 'typing' unless event.target.form
|
||||
when 13
|
||||
@trigger 'enter'
|
||||
when 27
|
||||
@trigger 'escape'
|
||||
false
|
||||
when 32
|
||||
if event.target.type is 'search' and @spaceScroll() and (not @lastKeypress or @lastKeypress < Date.now() - (@spaceTimeout() * 1000))
|
||||
@trigger 'pageDown'
|
||||
false
|
||||
when 33
|
||||
@trigger 'pageUp'
|
||||
when 34
|
||||
@trigger 'pageDown'
|
||||
when 35
|
||||
@trigger 'pageBottom' unless event.target.form
|
||||
when 36
|
||||
@trigger 'pageTop' unless event.target.form
|
||||
when 37
|
||||
@trigger 'left' unless event.target.value
|
||||
when 38
|
||||
@trigger 'up'
|
||||
@showTip?()
|
||||
false
|
||||
when 39
|
||||
@trigger 'right' unless event.target.value
|
||||
when 40
|
||||
@trigger 'down'
|
||||
@showTip?()
|
||||
false
|
||||
when 191
|
||||
unless event.target.form
|
||||
@trigger 'typing'
|
||||
false
|
||||
|
||||
handleKeydownSuperEvent: (event) ->
|
||||
switch event.which
|
||||
when 13
|
||||
@trigger 'superEnter'
|
||||
when 37
|
||||
if @isMac
|
||||
@trigger 'superLeft'
|
||||
false
|
||||
when 38
|
||||
@trigger 'pageTop'
|
||||
false
|
||||
when 39
|
||||
if @isMac
|
||||
@trigger 'superRight'
|
||||
false
|
||||
when 40
|
||||
@trigger 'pageBottom'
|
||||
false
|
||||
when 188
|
||||
@trigger 'preferences'
|
||||
false
|
||||
|
||||
handleKeydownShiftEvent: (event, _force) ->
|
||||
return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
|
||||
|
||||
if not event.target.form and 65 <= event.which <= 90
|
||||
@trigger 'typing'
|
||||
return
|
||||
|
||||
switch event.which
|
||||
when 32
|
||||
@trigger 'pageUp'
|
||||
false
|
||||
when 38
|
||||
unless getSelection()?.toString()
|
||||
@trigger 'altUp'
|
||||
false
|
||||
when 40
|
||||
unless getSelection()?.toString()
|
||||
@trigger 'altDown'
|
||||
false
|
||||
|
||||
handleKeydownAltEvent: (event, _force) ->
|
||||
return @handleKeydownEvent(event, true) if not _force and event.which in [37, 38, 39, 40] and @swapArrowKeysBehavior()
|
||||
|
||||
switch event.which
|
||||
when 9
|
||||
@trigger 'altRight', event
|
||||
when 37
|
||||
unless @isMac
|
||||
@trigger 'superLeft'
|
||||
false
|
||||
when 38
|
||||
@trigger 'altUp'
|
||||
false
|
||||
when 39
|
||||
unless @isMac
|
||||
@trigger 'superRight'
|
||||
false
|
||||
when 40
|
||||
@trigger 'altDown'
|
||||
false
|
||||
when 67
|
||||
@trigger 'altC'
|
||||
false
|
||||
when 68
|
||||
@trigger 'altD'
|
||||
false
|
||||
when 70
|
||||
@trigger 'altF', event
|
||||
when 71
|
||||
@trigger 'altG'
|
||||
false
|
||||
when 79
|
||||
@trigger 'altO'
|
||||
false
|
||||
when 82
|
||||
@trigger 'altR'
|
||||
false
|
||||
when 83
|
||||
@trigger 'altS'
|
||||
false
|
||||
|
||||
handleKeypressEvent: (event) ->
|
||||
if event.which is 63 and not event.target.value
|
||||
@trigger 'help'
|
||||
false
|
||||
else
|
||||
@lastKeypress = Date.now()
|
||||
|
||||
buggyEvent: (event) ->
|
||||
try
|
||||
event.target
|
||||
event.ctrlKey
|
||||
event.which
|
||||
return false
|
||||
catch
|
||||
return true
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.Shortcuts = class Shortcuts {
|
||||
static initClass() {
|
||||
$.extend(this.prototype, Events);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.onKeydown = this.onKeydown.bind(this);
|
||||
this.onKeypress = this.onKeypress.bind(this);
|
||||
this.isMac = $.isMac();
|
||||
this.start();
|
||||
}
|
||||
|
||||
start() {
|
||||
$.on(document, 'keydown', this.onKeydown);
|
||||
$.on(document, 'keypress', this.onKeypress);
|
||||
}
|
||||
|
||||
stop() {
|
||||
$.off(document, 'keydown', this.onKeydown);
|
||||
$.off(document, 'keypress', this.onKeypress);
|
||||
}
|
||||
|
||||
swapArrowKeysBehavior() {
|
||||
return app.settings.get('arrowScroll');
|
||||
}
|
||||
|
||||
spaceScroll() {
|
||||
return app.settings.get('spaceScroll');
|
||||
}
|
||||
|
||||
showTip() {
|
||||
app.showTip('KeyNav');
|
||||
return this.showTip = null;
|
||||
}
|
||||
|
||||
spaceTimeout() {
|
||||
return app.settings.get('spaceTimeout');
|
||||
}
|
||||
|
||||
onKeydown(event) {
|
||||
if (this.buggyEvent(event)) { return; }
|
||||
const result = (() => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (!event.altKey && !event.shiftKey) { return this.handleKeydownSuperEvent(event); }
|
||||
} else if (event.shiftKey) {
|
||||
if (!event.altKey) { return this.handleKeydownShiftEvent(event); }
|
||||
} else if (event.altKey) {
|
||||
return this.handleKeydownAltEvent(event);
|
||||
} else {
|
||||
return this.handleKeydownEvent(event);
|
||||
}
|
||||
})();
|
||||
|
||||
if (result === false) { event.preventDefault(); }
|
||||
}
|
||||
|
||||
onKeypress(event) {
|
||||
if (this.buggyEvent(event) || ((event.charCode === 63) && (document.activeElement.tagName === 'INPUT'))) { return; }
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
const result = this.handleKeypressEvent(event);
|
||||
if (result === false) { event.preventDefault(); }
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydownEvent(event, _force) {
|
||||
if (!_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior()) { return this.handleKeydownAltEvent(event, true); }
|
||||
|
||||
if (!event.target.form && ((48 <= event.which && event.which <= 57) || (65 <= event.which && event.which <= 90))) {
|
||||
this.trigger('typing');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 8:
|
||||
if (!event.target.form) { return this.trigger('typing'); }
|
||||
break;
|
||||
case 13:
|
||||
return this.trigger('enter');
|
||||
case 27:
|
||||
this.trigger('escape');
|
||||
return false;
|
||||
case 32:
|
||||
if ((event.target.type === 'search') && this.spaceScroll() && (!this.lastKeypress || (this.lastKeypress < (Date.now() - (this.spaceTimeout() * 1000))))) {
|
||||
this.trigger('pageDown');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 33:
|
||||
return this.trigger('pageUp');
|
||||
case 34:
|
||||
return this.trigger('pageDown');
|
||||
case 35:
|
||||
if (!event.target.form) { return this.trigger('pageBottom'); }
|
||||
break;
|
||||
case 36:
|
||||
if (!event.target.form) { return this.trigger('pageTop'); }
|
||||
break;
|
||||
case 37:
|
||||
if (!event.target.value) { return this.trigger('left'); }
|
||||
break;
|
||||
case 38:
|
||||
this.trigger('up');
|
||||
if (typeof this.showTip === 'function') {
|
||||
this.showTip();
|
||||
}
|
||||
return false;
|
||||
case 39:
|
||||
if (!event.target.value) { return this.trigger('right'); }
|
||||
break;
|
||||
case 40:
|
||||
this.trigger('down');
|
||||
if (typeof this.showTip === 'function') {
|
||||
this.showTip();
|
||||
}
|
||||
return false;
|
||||
case 191:
|
||||
if (!event.target.form) {
|
||||
this.trigger('typing');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydownSuperEvent(event) {
|
||||
switch (event.which) {
|
||||
case 13:
|
||||
return this.trigger('superEnter');
|
||||
case 37:
|
||||
if (this.isMac) {
|
||||
this.trigger('superLeft');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 38:
|
||||
this.trigger('pageTop');
|
||||
return false;
|
||||
case 39:
|
||||
if (this.isMac) {
|
||||
this.trigger('superRight');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 40:
|
||||
this.trigger('pageBottom');
|
||||
return false;
|
||||
case 188:
|
||||
this.trigger('preferences');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydownShiftEvent(event, _force) {
|
||||
if (!_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior()) { return this.handleKeydownEvent(event, true); }
|
||||
|
||||
if (!event.target.form && (65 <= event.which && event.which <= 90)) {
|
||||
this.trigger('typing');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 32:
|
||||
this.trigger('pageUp');
|
||||
return false;
|
||||
case 38:
|
||||
if (!__guard__(getSelection(), x => x.toString())) {
|
||||
this.trigger('altUp');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 40:
|
||||
if (!__guard__(getSelection(), x1 => x1.toString())) {
|
||||
this.trigger('altDown');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydownAltEvent(event, _force) {
|
||||
if (!_force && [37, 38, 39, 40].includes(event.which) && this.swapArrowKeysBehavior()) { return this.handleKeydownEvent(event, true); }
|
||||
|
||||
switch (event.which) {
|
||||
case 9:
|
||||
return this.trigger('altRight', event);
|
||||
case 37:
|
||||
if (!this.isMac) {
|
||||
this.trigger('superLeft');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 38:
|
||||
this.trigger('altUp');
|
||||
return false;
|
||||
case 39:
|
||||
if (!this.isMac) {
|
||||
this.trigger('superRight');
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 40:
|
||||
this.trigger('altDown');
|
||||
return false;
|
||||
case 67:
|
||||
this.trigger('altC');
|
||||
return false;
|
||||
case 68:
|
||||
this.trigger('altD');
|
||||
return false;
|
||||
case 70:
|
||||
return this.trigger('altF', event);
|
||||
case 71:
|
||||
this.trigger('altG');
|
||||
return false;
|
||||
case 79:
|
||||
this.trigger('altO');
|
||||
return false;
|
||||
case 82:
|
||||
this.trigger('altR');
|
||||
return false;
|
||||
case 83:
|
||||
this.trigger('altS');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
handleKeypressEvent(event) {
|
||||
if ((event.which === 63) && !event.target.value) {
|
||||
this.trigger('help');
|
||||
return false;
|
||||
} else {
|
||||
return this.lastKeypress = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
buggyEvent(event) {
|
||||
try {
|
||||
event.target;
|
||||
event.ctrlKey;
|
||||
event.which;
|
||||
return false;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,39 +1,54 @@
|
||||
class app.UpdateChecker
|
||||
constructor: ->
|
||||
@lastCheck = Date.now()
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.UpdateChecker = class UpdateChecker {
|
||||
constructor() {
|
||||
this.checkDocs = this.checkDocs.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.lastCheck = Date.now();
|
||||
|
||||
$.on window, 'focus', @onFocus
|
||||
app.serviceWorker?.on 'updateready', @onUpdateReady
|
||||
$.on(window, 'focus', this.onFocus);
|
||||
if (app.serviceWorker != null) {
|
||||
app.serviceWorker.on('updateready', this.onUpdateReady);
|
||||
}
|
||||
|
||||
setTimeout @checkDocs, 0
|
||||
setTimeout(this.checkDocs, 0);
|
||||
}
|
||||
|
||||
check: ->
|
||||
if app.serviceWorker
|
||||
app.serviceWorker.update()
|
||||
else
|
||||
ajax
|
||||
url: $('script[src*="application"]').getAttribute('src')
|
||||
dataType: 'application/javascript'
|
||||
error: (_, xhr) => @onUpdateReady() if xhr.status is 404
|
||||
return
|
||||
check() {
|
||||
if (app.serviceWorker) {
|
||||
app.serviceWorker.update();
|
||||
} else {
|
||||
ajax({
|
||||
url: $('script[src*="application"]').getAttribute('src'),
|
||||
dataType: 'application/javascript',
|
||||
error: (_, xhr) => { if (xhr.status === 404) { return this.onUpdateReady(); } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onUpdateReady: ->
|
||||
new app.views.Notif 'UpdateReady', autoHide: null
|
||||
return
|
||||
onUpdateReady() {
|
||||
new app.views.Notif('UpdateReady', {autoHide: null});
|
||||
}
|
||||
|
||||
checkDocs: =>
|
||||
unless app.settings.get('manualUpdate')
|
||||
app.docs.updateInBackground()
|
||||
else
|
||||
app.docs.checkForUpdates (i) => @onDocsUpdateReady() if i > 0
|
||||
return
|
||||
checkDocs() {
|
||||
if (!app.settings.get('manualUpdate')) {
|
||||
app.docs.updateInBackground();
|
||||
} else {
|
||||
app.docs.checkForUpdates(i => { if (i > 0) { return this.onDocsUpdateReady(); } });
|
||||
}
|
||||
}
|
||||
|
||||
onDocsUpdateReady: ->
|
||||
new app.views.Notif 'UpdateDocs', autoHide: null
|
||||
return
|
||||
onDocsUpdateReady() {
|
||||
new app.views.Notif('UpdateDocs', {autoHide: null});
|
||||
}
|
||||
|
||||
onFocus: =>
|
||||
if Date.now() - @lastCheck > 21600e3
|
||||
@lastCheck = Date.now()
|
||||
@check()
|
||||
return
|
||||
onFocus() {
|
||||
if ((Date.now() - this.lastCheck) > 21600e3) {
|
||||
this.lastCheck = Date.now();
|
||||
this.check();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,31 +1,38 @@
|
||||
#= require_tree ./vendor
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require_tree ./vendor
|
||||
|
||||
#= require lib/license
|
||||
#= require_tree ./lib
|
||||
//= require lib/license
|
||||
//= require_tree ./lib
|
||||
|
||||
#= require app/app
|
||||
#= require app/config
|
||||
#= require_tree ./app
|
||||
//= require app/app
|
||||
//= require app/config
|
||||
//= require_tree ./app
|
||||
|
||||
#= require collections/collection
|
||||
#= require_tree ./collections
|
||||
//= require collections/collection
|
||||
//= require_tree ./collections
|
||||
|
||||
#= require models/model
|
||||
#= require_tree ./models
|
||||
//= require models/model
|
||||
//= require_tree ./models
|
||||
|
||||
#= require views/view
|
||||
#= require_tree ./views
|
||||
//= require views/view
|
||||
//= require_tree ./views
|
||||
|
||||
#= require_tree ./templates
|
||||
//= require_tree ./templates
|
||||
|
||||
#= require tracking
|
||||
//= require tracking
|
||||
|
||||
init = ->
|
||||
document.removeEventListener 'DOMContentLoaded', init, false
|
||||
var init = function() {
|
||||
document.removeEventListener('DOMContentLoaded', init, false);
|
||||
|
||||
if document.body
|
||||
app.init()
|
||||
else
|
||||
setTimeout(init, 42)
|
||||
if (document.body) {
|
||||
return app.init();
|
||||
} else {
|
||||
return setTimeout(init, 42);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener 'DOMContentLoaded', init, false
|
||||
document.addEventListener('DOMContentLoaded', init, false);
|
||||
|
@ -1,55 +1,75 @@
|
||||
class app.Collection
|
||||
constructor: (objects = []) ->
|
||||
@reset objects
|
||||
|
||||
model: ->
|
||||
app.models[@constructor.model]
|
||||
|
||||
reset: (objects = []) ->
|
||||
@models = []
|
||||
@add object for object in objects
|
||||
return
|
||||
|
||||
add: (object) ->
|
||||
if object instanceof app.Model
|
||||
@models.push object
|
||||
else if object instanceof Array
|
||||
@add obj for obj in object
|
||||
else if object instanceof app.Collection
|
||||
@models.push object.all()...
|
||||
else
|
||||
@models.push new (@model())(object)
|
||||
return
|
||||
|
||||
remove: (model) ->
|
||||
@models.splice @models.indexOf(model), 1
|
||||
return
|
||||
|
||||
size: ->
|
||||
@models.length
|
||||
|
||||
isEmpty: ->
|
||||
@models.length is 0
|
||||
|
||||
each: (fn) ->
|
||||
fn(model) for model in @models
|
||||
return
|
||||
|
||||
all: ->
|
||||
@models
|
||||
|
||||
contains: (model) ->
|
||||
@models.indexOf(model) >= 0
|
||||
|
||||
findBy: (attr, value) ->
|
||||
for model in @models
|
||||
return model if model[attr] is value
|
||||
return
|
||||
|
||||
findAllBy: (attr, value) ->
|
||||
model for model in @models when model[attr] is value
|
||||
|
||||
countAllBy: (attr, value) ->
|
||||
i = 0
|
||||
i += 1 for model in @models when model[attr] is value
|
||||
i
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.Collection = class Collection {
|
||||
constructor(objects) {
|
||||
if (objects == null) { objects = []; }
|
||||
this.reset(objects);
|
||||
}
|
||||
|
||||
model() {
|
||||
return app.models[this.constructor.model];
|
||||
}
|
||||
|
||||
reset(objects) {
|
||||
if (objects == null) { objects = []; }
|
||||
this.models = [];
|
||||
for (var object of Array.from(objects)) { this.add(object); }
|
||||
}
|
||||
|
||||
add(object) {
|
||||
if (object instanceof app.Model) {
|
||||
this.models.push(object);
|
||||
} else if (object instanceof Array) {
|
||||
for (var obj of Array.from(object)) { this.add(obj); }
|
||||
} else if (object instanceof app.Collection) {
|
||||
this.models.push(...Array.from(object.all() || []));
|
||||
} else {
|
||||
this.models.push(new (this.model())(object));
|
||||
}
|
||||
}
|
||||
|
||||
remove(model) {
|
||||
this.models.splice(this.models.indexOf(model), 1);
|
||||
}
|
||||
|
||||
size() {
|
||||
return this.models.length;
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.models.length === 0;
|
||||
}
|
||||
|
||||
each(fn) {
|
||||
for (var model of Array.from(this.models)) { fn(model); }
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
contains(model) {
|
||||
return this.models.indexOf(model) >= 0;
|
||||
}
|
||||
|
||||
findBy(attr, value) {
|
||||
for (var model of Array.from(this.models)) {
|
||||
if (model[attr] === value) { return model; }
|
||||
}
|
||||
}
|
||||
|
||||
findAllBy(attr, value) {
|
||||
return Array.from(this.models).filter((model) => model[attr] === value);
|
||||
}
|
||||
|
||||
countAllBy(attr, value) {
|
||||
let i = 0;
|
||||
for (var model of Array.from(this.models)) { if (model[attr] === value) { i += 1; } }
|
||||
return i;
|
||||
}
|
||||
};
|
||||
|
@ -1,85 +1,117 @@
|
||||
class app.collections.Docs extends app.Collection
|
||||
@model: 'Doc'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS202: Simplify dynamic range loops
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let NORMALIZE_VERSION_RGX = undefined;
|
||||
let NORMALIZE_VERSION_SUB = undefined;
|
||||
let CONCURRENCY = undefined;
|
||||
const Cls = (app.collections.Docs = class Docs extends app.Collection {
|
||||
static initClass() {
|
||||
this.model = 'Doc';
|
||||
|
||||
findBySlug: (slug) ->
|
||||
@findBy('slug', slug) or @findBy('slug_without_version', slug)
|
||||
NORMALIZE_VERSION_RGX = /\.(\d)$/;
|
||||
NORMALIZE_VERSION_SUB = '.0$1';
|
||||
|
||||
NORMALIZE_VERSION_RGX = /\.(\d)$/
|
||||
NORMALIZE_VERSION_SUB = '.0$1'
|
||||
sort: ->
|
||||
@models.sort (a, b) ->
|
||||
if a.name is b.name
|
||||
if not a.version or a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB)
|
||||
-1
|
||||
else
|
||||
1
|
||||
else if a.name.toLowerCase() > b.name.toLowerCase()
|
||||
1
|
||||
else
|
||||
-1
|
||||
// Load models concurrently.
|
||||
// It's not pretty but I didn't want to import a promise library only for this.
|
||||
CONCURRENCY = 3;
|
||||
}
|
||||
|
||||
# Load models concurrently.
|
||||
# It's not pretty but I didn't want to import a promise library only for this.
|
||||
CONCURRENCY = 3
|
||||
load: (onComplete, onError, options) ->
|
||||
i = 0
|
||||
findBySlug(slug) {
|
||||
return this.findBy('slug', slug) || this.findBy('slug_without_version', slug);
|
||||
}
|
||||
sort() {
|
||||
return this.models.sort(function(a, b) {
|
||||
if (a.name === b.name) {
|
||||
if (!a.version || (a.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB) > b.version.replace(NORMALIZE_VERSION_RGX, NORMALIZE_VERSION_SUB))) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else if (a.name.toLowerCase() > b.name.toLowerCase()) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
load(onComplete, onError, options) {
|
||||
let i = 0;
|
||||
|
||||
next = =>
|
||||
if i < @models.length
|
||||
@models[i].load(next, fail, options)
|
||||
else if i is @models.length + CONCURRENCY - 1
|
||||
onComplete()
|
||||
i++
|
||||
return
|
||||
var next = () => {
|
||||
if (i < this.models.length) {
|
||||
this.models[i].load(next, fail, options);
|
||||
} else if (i === ((this.models.length + CONCURRENCY) - 1)) {
|
||||
onComplete();
|
||||
}
|
||||
i++;
|
||||
};
|
||||
|
||||
fail = (args...) ->
|
||||
if onError
|
||||
onError(args...)
|
||||
onError = null
|
||||
next()
|
||||
return
|
||||
var fail = function(...args) {
|
||||
if (onError) {
|
||||
onError(...Array.from(args || []));
|
||||
onError = null;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
next() for [0...CONCURRENCY]
|
||||
return
|
||||
for (let j = 0, end = CONCURRENCY, asc = 0 <= end; asc ? j < end : j > end; asc ? j++ : j--) { next(); }
|
||||
}
|
||||
|
||||
clearCache: ->
|
||||
doc.clearCache() for doc in @models
|
||||
return
|
||||
clearCache() {
|
||||
for (var doc of Array.from(this.models)) { doc.clearCache(); }
|
||||
}
|
||||
|
||||
uninstall: (callback) ->
|
||||
i = 0
|
||||
next = =>
|
||||
if i < @models.length
|
||||
@models[i++].uninstall(next, next)
|
||||
else
|
||||
callback()
|
||||
return
|
||||
next()
|
||||
return
|
||||
uninstall(callback) {
|
||||
let i = 0;
|
||||
var next = () => {
|
||||
if (i < this.models.length) {
|
||||
this.models[i++].uninstall(next, next);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
getInstallStatuses: (callback) ->
|
||||
app.db.versions @models, (statuses) ->
|
||||
if statuses
|
||||
for key, value of statuses
|
||||
statuses[key] = installed: !!value, mtime: value
|
||||
callback(statuses)
|
||||
return
|
||||
return
|
||||
getInstallStatuses(callback) {
|
||||
app.db.versions(this.models, function(statuses) {
|
||||
if (statuses) {
|
||||
for (var key in statuses) {
|
||||
var value = statuses[key];
|
||||
statuses[key] = {installed: !!value, mtime: value};
|
||||
}
|
||||
}
|
||||
callback(statuses);
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates: (callback) ->
|
||||
@getInstallStatuses (statuses) =>
|
||||
i = 0
|
||||
if statuses
|
||||
i += 1 for slug, status of statuses when @findBy('slug', slug).isOutdated(status)
|
||||
callback(i)
|
||||
return
|
||||
return
|
||||
checkForUpdates(callback) {
|
||||
this.getInstallStatuses(statuses => {
|
||||
let i = 0;
|
||||
if (statuses) {
|
||||
for (var slug in statuses) { var status = statuses[slug]; if (this.findBy('slug', slug).isOutdated(status)) { i += 1; } }
|
||||
}
|
||||
callback(i);
|
||||
});
|
||||
}
|
||||
|
||||
updateInBackground: ->
|
||||
@getInstallStatuses (statuses) =>
|
||||
return unless statuses
|
||||
for slug, status of statuses
|
||||
doc = @findBy 'slug', slug
|
||||
doc.install($.noop, $.noop) if doc.isOutdated(status)
|
||||
return
|
||||
return
|
||||
updateInBackground() {
|
||||
this.getInstallStatuses(statuses => {
|
||||
if (!statuses) { return; }
|
||||
for (var slug in statuses) {
|
||||
var status = statuses[slug];
|
||||
var doc = this.findBy('slug', slug);
|
||||
if (doc.isOutdated(status)) { doc.install($.noop, $.noop); }
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,2 +1,11 @@
|
||||
class app.collections.Entries extends app.Collection
|
||||
@model: 'Entry'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.collections.Entries = class Entries extends app.Collection {
|
||||
static initClass() {
|
||||
this.model = 'Entry';
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,19 +1,41 @@
|
||||
class app.collections.Types extends app.Collection
|
||||
@model: 'Type'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS104: Avoid inline assignments
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let GUIDES_RGX = undefined;
|
||||
let APPENDIX_RGX = undefined;
|
||||
const Cls = (app.collections.Types = class Types extends app.Collection {
|
||||
static initClass() {
|
||||
this.model = 'Type';
|
||||
|
||||
groups: ->
|
||||
result = []
|
||||
for type in @models
|
||||
(result[@_groupFor(type)] ||= []).push(type)
|
||||
result.filter (e) -> e.length > 0
|
||||
GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i;
|
||||
APPENDIX_RGX = /appendix/i;
|
||||
}
|
||||
|
||||
GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i
|
||||
APPENDIX_RGX = /appendix/i
|
||||
groups() {
|
||||
const result = [];
|
||||
for (var type of Array.from(this.models)) {
|
||||
var name;
|
||||
(result[name = this._groupFor(type)] || (result[name] = [])).push(type);
|
||||
}
|
||||
return result.filter(e => e.length > 0);
|
||||
}
|
||||
|
||||
_groupFor: (type) ->
|
||||
if GUIDES_RGX.test(type.name)
|
||||
0
|
||||
else if APPENDIX_RGX.test(type.name)
|
||||
2
|
||||
else
|
||||
1
|
||||
_groupFor(type) {
|
||||
if (GUIDES_RGX.test(type.name)) {
|
||||
return 0;
|
||||
} else if (APPENDIX_RGX.test(type.name)) {
|
||||
return 2;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,85 +1,110 @@
|
||||
return unless console?.time and console.groupCollapsed
|
||||
|
||||
#
|
||||
# App
|
||||
#
|
||||
|
||||
_init = app.init
|
||||
app.init = ->
|
||||
console.time 'Init'
|
||||
_init.call(app)
|
||||
console.timeEnd 'Init'
|
||||
console.time 'Load'
|
||||
|
||||
_start = app.start
|
||||
app.start = ->
|
||||
console.timeEnd 'Load'
|
||||
console.time 'Start'
|
||||
_start.call(app, arguments...)
|
||||
console.timeEnd 'Start'
|
||||
|
||||
#
|
||||
# Searcher
|
||||
#
|
||||
|
||||
_super = app.Searcher
|
||||
_proto = app.Searcher.prototype
|
||||
|
||||
app.Searcher = ->
|
||||
_super.apply @, arguments
|
||||
|
||||
_setup = @setup.bind(@)
|
||||
@setup = ->
|
||||
console.groupCollapsed "Search: #{@query}"
|
||||
console.time 'Total'
|
||||
_setup()
|
||||
|
||||
_match = @match.bind(@)
|
||||
@match = =>
|
||||
console.timeEnd @matcher.name if @matcher
|
||||
_match()
|
||||
|
||||
_setupMatcher = @setupMatcher.bind(@)
|
||||
@setupMatcher = ->
|
||||
console.time @matcher.name
|
||||
_setupMatcher()
|
||||
|
||||
_end = @end.bind(@)
|
||||
@end = ->
|
||||
console.log "Results: #{@totalResults}"
|
||||
console.timeEnd 'Total'
|
||||
console.groupEnd()
|
||||
_end()
|
||||
|
||||
_kill = @kill.bind(@)
|
||||
@kill = ->
|
||||
if @timeout
|
||||
console.timeEnd @matcher.name if @matcher
|
||||
console.groupEnd()
|
||||
console.timeEnd 'Total'
|
||||
console.warn 'Killed'
|
||||
_kill()
|
||||
|
||||
return
|
||||
|
||||
$.extend(app.Searcher, _super)
|
||||
_proto.constructor = app.Searcher
|
||||
app.Searcher.prototype = _proto
|
||||
|
||||
#
|
||||
# View tree
|
||||
#
|
||||
|
||||
@viewTree = (view = app.document, level = 0, visited = []) ->
|
||||
return if visited.indexOf(view) >= 0
|
||||
visited.push(view)
|
||||
|
||||
console.log "%c #{Array(level + 1).join(' ')}#{view.constructor.name}: #{!!view.activated}",
|
||||
'color:' + (view.activated and 'green' or 'red')
|
||||
|
||||
for own key, value of view when key isnt 'view' and value
|
||||
if typeof value is 'object' and value.setupElement
|
||||
@viewTree(value, level + 1, visited)
|
||||
else if value.constructor.toString().match(/Object\(\)/)
|
||||
@viewTree(v, level + 1, visited) for own k, v of value when v and typeof v is 'object' and v.setupElement
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS203: Remove `|| {}` from converted for-own loops
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* DS209: Avoid top-level return
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
if (!(typeof console !== 'undefined' && console !== null ? console.time : undefined) || !console.groupCollapsed) { return; }
|
||||
|
||||
//
|
||||
// App
|
||||
//
|
||||
|
||||
const _init = app.init;
|
||||
app.init = function() {
|
||||
console.time('Init');
|
||||
_init.call(app);
|
||||
console.timeEnd('Init');
|
||||
return console.time('Load');
|
||||
};
|
||||
|
||||
const _start = app.start;
|
||||
app.start = function() {
|
||||
console.timeEnd('Load');
|
||||
console.time('Start');
|
||||
_start.call(app, ...arguments);
|
||||
return console.timeEnd('Start');
|
||||
};
|
||||
|
||||
//
|
||||
// Searcher
|
||||
//
|
||||
|
||||
const _super = app.Searcher;
|
||||
const _proto = app.Searcher.prototype;
|
||||
|
||||
app.Searcher = function() {
|
||||
_super.apply(this, arguments);
|
||||
|
||||
const _setup = this.setup.bind(this);
|
||||
this.setup = function() {
|
||||
console.groupCollapsed(`Search: ${this.query}`);
|
||||
console.time('Total');
|
||||
return _setup();
|
||||
};
|
||||
|
||||
const _match = this.match.bind(this);
|
||||
this.match = () => {
|
||||
if (this.matcher) { console.timeEnd(this.matcher.name); }
|
||||
return _match();
|
||||
};
|
||||
|
||||
const _setupMatcher = this.setupMatcher.bind(this);
|
||||
this.setupMatcher = function() {
|
||||
console.time(this.matcher.name);
|
||||
return _setupMatcher();
|
||||
};
|
||||
|
||||
const _end = this.end.bind(this);
|
||||
this.end = function() {
|
||||
console.log(`Results: ${this.totalResults}`);
|
||||
console.timeEnd('Total');
|
||||
console.groupEnd();
|
||||
return _end();
|
||||
};
|
||||
|
||||
const _kill = this.kill.bind(this);
|
||||
this.kill = function() {
|
||||
if (this.timeout) {
|
||||
if (this.matcher) { console.timeEnd(this.matcher.name); }
|
||||
console.groupEnd();
|
||||
console.timeEnd('Total');
|
||||
console.warn('Killed');
|
||||
}
|
||||
return _kill();
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
$.extend(app.Searcher, _super);
|
||||
_proto.constructor = app.Searcher;
|
||||
app.Searcher.prototype = _proto;
|
||||
|
||||
//
|
||||
// View tree
|
||||
//
|
||||
|
||||
this.viewTree = function(view, level, visited) {
|
||||
if (view == null) { view = app.document; }
|
||||
if (level == null) { level = 0; }
|
||||
if (visited == null) { visited = []; }
|
||||
if (visited.indexOf(view) >= 0) { return; }
|
||||
visited.push(view);
|
||||
|
||||
console.log(`%c ${Array(level + 1).join(' ')}${view.constructor.name}: ${!!view.activated}`,
|
||||
'color:' + ((view.activated && 'green') || 'red'));
|
||||
|
||||
for (var key of Object.keys(view || {})) {
|
||||
var value = view[key];
|
||||
if ((key !== 'view') && value) {
|
||||
if ((typeof value === 'object') && value.setupElement) {
|
||||
this.viewTree(value, level + 1, visited);
|
||||
} else if (value.constructor.toString().match(/Object\(\)/)) {
|
||||
for (var k of Object.keys(value || {})) { var v = value[k]; if (v && (typeof v === 'object') && v.setupElement) { this.viewTree(v, level + 1, visited); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,118 +1,154 @@
|
||||
MIME_TYPES =
|
||||
json: 'application/json'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const MIME_TYPES = {
|
||||
json: 'application/json',
|
||||
html: 'text/html'
|
||||
};
|
||||
|
||||
@ajax = (options) ->
|
||||
applyDefaults(options)
|
||||
serializeData(options)
|
||||
this.ajax = function(options) {
|
||||
applyDefaults(options);
|
||||
serializeData(options);
|
||||
|
||||
xhr = new XMLHttpRequest()
|
||||
xhr.open(options.type, options.url, options.async)
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(options.type, options.url, options.async);
|
||||
|
||||
applyCallbacks(xhr, options)
|
||||
applyHeaders(xhr, options)
|
||||
applyCallbacks(xhr, options);
|
||||
applyHeaders(xhr, options);
|
||||
|
||||
xhr.send(options.data)
|
||||
xhr.send(options.data);
|
||||
|
||||
if options.async
|
||||
abort: abort.bind(undefined, xhr)
|
||||
else
|
||||
parseResponse(xhr, options)
|
||||
if (options.async) {
|
||||
return {abort: abort.bind(undefined, xhr)};
|
||||
} else {
|
||||
return parseResponse(xhr, options);
|
||||
}
|
||||
};
|
||||
|
||||
ajax.defaults =
|
||||
async: true
|
||||
dataType: 'json'
|
||||
timeout: 30
|
||||
ajax.defaults = {
|
||||
async: true,
|
||||
dataType: 'json',
|
||||
timeout: 30,
|
||||
type: 'GET'
|
||||
# contentType
|
||||
# context
|
||||
# data
|
||||
# error
|
||||
# headers
|
||||
# progress
|
||||
# success
|
||||
# url
|
||||
|
||||
applyDefaults = (options) ->
|
||||
for key of ajax.defaults
|
||||
options[key] ?= ajax.defaults[key]
|
||||
return
|
||||
|
||||
serializeData = (options) ->
|
||||
return unless options.data
|
||||
|
||||
if options.type is 'GET'
|
||||
options.url += '?' + serializeParams(options.data)
|
||||
options.data = null
|
||||
else
|
||||
options.data = serializeParams(options.data)
|
||||
return
|
||||
|
||||
serializeParams = (params) ->
|
||||
("#{encodeURIComponent key}=#{encodeURIComponent value}" for key, value of params).join '&'
|
||||
|
||||
applyCallbacks = (xhr, options) ->
|
||||
return unless options.async
|
||||
|
||||
xhr.timer = setTimeout onTimeout.bind(undefined, xhr, options), options.timeout * 1000
|
||||
xhr.onprogress = options.progress if options.progress
|
||||
xhr.onreadystatechange = ->
|
||||
if xhr.readyState is 4
|
||||
clearTimeout(xhr.timer)
|
||||
onComplete(xhr, options)
|
||||
return
|
||||
return
|
||||
|
||||
applyHeaders = (xhr, options) ->
|
||||
options.headers or= {}
|
||||
|
||||
if options.contentType
|
||||
options.headers['Content-Type'] = options.contentType
|
||||
|
||||
if not options.headers['Content-Type'] and options.data and options.type isnt 'GET'
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
|
||||
if options.dataType
|
||||
options.headers['Accept'] = MIME_TYPES[options.dataType] or options.dataType
|
||||
|
||||
for key, value of options.headers
|
||||
xhr.setRequestHeader(key, value)
|
||||
return
|
||||
|
||||
onComplete = (xhr, options) ->
|
||||
if 200 <= xhr.status < 300
|
||||
if (response = parseResponse(xhr, options))?
|
||||
onSuccess response, xhr, options
|
||||
else
|
||||
onError 'invalid', xhr, options
|
||||
else
|
||||
onError 'error', xhr, options
|
||||
return
|
||||
|
||||
onSuccess = (response, xhr, options) ->
|
||||
options.success?.call options.context, response, xhr, options
|
||||
return
|
||||
|
||||
onError = (type, xhr, options) ->
|
||||
options.error?.call options.context, type, xhr, options
|
||||
return
|
||||
|
||||
onTimeout = (xhr, options) ->
|
||||
xhr.abort()
|
||||
onError 'timeout', xhr, options
|
||||
return
|
||||
|
||||
abort = (xhr) ->
|
||||
clearTimeout(xhr.timer)
|
||||
xhr.onreadystatechange = null
|
||||
xhr.abort()
|
||||
return
|
||||
|
||||
parseResponse = (xhr, options) ->
|
||||
if options.dataType is 'json'
|
||||
parseJSON(xhr.responseText)
|
||||
else
|
||||
xhr.responseText
|
||||
|
||||
parseJSON = (json) ->
|
||||
try JSON.parse(json) catch
|
||||
};
|
||||
// contentType
|
||||
// context
|
||||
// data
|
||||
// error
|
||||
// headers
|
||||
// progress
|
||||
// success
|
||||
// url
|
||||
|
||||
var applyDefaults = function(options) {
|
||||
for (var key in ajax.defaults) {
|
||||
if (options[key] == null) { options[key] = ajax.defaults[key]; }
|
||||
}
|
||||
};
|
||||
|
||||
var serializeData = function(options) {
|
||||
if (!options.data) { return; }
|
||||
|
||||
if (options.type === 'GET') {
|
||||
options.url += '?' + serializeParams(options.data);
|
||||
options.data = null;
|
||||
} else {
|
||||
options.data = serializeParams(options.data);
|
||||
}
|
||||
};
|
||||
|
||||
var serializeParams = params => ((() => {
|
||||
const result = [];
|
||||
for (var key in params) {
|
||||
var value = params[key];
|
||||
result.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
return result;
|
||||
})()).join('&');
|
||||
|
||||
var applyCallbacks = function(xhr, options) {
|
||||
if (!options.async) { return; }
|
||||
|
||||
xhr.timer = setTimeout(onTimeout.bind(undefined, xhr, options), options.timeout * 1000);
|
||||
if (options.progress) { xhr.onprogress = options.progress; }
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
clearTimeout(xhr.timer);
|
||||
onComplete(xhr, options);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var applyHeaders = function(xhr, options) {
|
||||
if (!options.headers) { options.headers = {}; }
|
||||
|
||||
if (options.contentType) {
|
||||
options.headers['Content-Type'] = options.contentType;
|
||||
}
|
||||
|
||||
if (!options.headers['Content-Type'] && options.data && (options.type !== 'GET')) {
|
||||
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
|
||||
if (options.dataType) {
|
||||
options.headers['Accept'] = MIME_TYPES[options.dataType] || options.dataType;
|
||||
}
|
||||
|
||||
for (var key in options.headers) {
|
||||
var value = options.headers[key];
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
var onComplete = function(xhr, options) {
|
||||
if (200 <= xhr.status && xhr.status < 300) {
|
||||
let response;
|
||||
if ((response = parseResponse(xhr, options)) != null) {
|
||||
onSuccess(response, xhr, options);
|
||||
} else {
|
||||
onError('invalid', xhr, options);
|
||||
}
|
||||
} else {
|
||||
onError('error', xhr, options);
|
||||
}
|
||||
};
|
||||
|
||||
var onSuccess = function(response, xhr, options) {
|
||||
if (options.success != null) {
|
||||
options.success.call(options.context, response, xhr, options);
|
||||
}
|
||||
};
|
||||
|
||||
var onError = function(type, xhr, options) {
|
||||
if (options.error != null) {
|
||||
options.error.call(options.context, type, xhr, options);
|
||||
}
|
||||
};
|
||||
|
||||
var onTimeout = function(xhr, options) {
|
||||
xhr.abort();
|
||||
onError('timeout', xhr, options);
|
||||
};
|
||||
|
||||
var abort = function(xhr) {
|
||||
clearTimeout(xhr.timer);
|
||||
xhr.onreadystatechange = null;
|
||||
xhr.abort();
|
||||
};
|
||||
|
||||
var parseResponse = function(xhr, options) {
|
||||
if (options.dataType === 'json') {
|
||||
return parseJSON(xhr.responseText);
|
||||
} else {
|
||||
return xhr.responseText;
|
||||
}
|
||||
};
|
||||
|
||||
var parseJSON = function(json) {
|
||||
try { return JSON.parse(json); } catch (error) {}
|
||||
};
|
||||
|
@ -1,42 +1,66 @@
|
||||
class @CookiesStore
|
||||
# Intentionally called CookiesStore instead of CookieStore
|
||||
# Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome
|
||||
# Related issue: https://github.com/freeCodeCamp/devdocs/issues/932
|
||||
|
||||
INT = /^\d+$/
|
||||
|
||||
@onBlocked: ->
|
||||
|
||||
get: (key) ->
|
||||
value = Cookies.get(key)
|
||||
value = parseInt(value, 10) if value? and INT.test(value)
|
||||
value
|
||||
|
||||
set: (key, value) ->
|
||||
if value == false
|
||||
@del(key)
|
||||
return
|
||||
|
||||
value = 1 if value == true
|
||||
value = parseInt(value, 10) if value and INT.test?(value)
|
||||
Cookies.set(key, '' + value, path: '/', expires: 1e8)
|
||||
@constructor.onBlocked(key, value, @get(key)) if @get(key) != value
|
||||
return
|
||||
|
||||
del: (key) ->
|
||||
Cookies.expire(key)
|
||||
return
|
||||
|
||||
reset: ->
|
||||
try
|
||||
for cookie in document.cookie.split(/;\s?/)
|
||||
Cookies.expire(cookie.split('=')[0])
|
||||
return
|
||||
catch
|
||||
|
||||
dump: ->
|
||||
result = {}
|
||||
for cookie in document.cookie.split(/;\s?/) when cookie[0] isnt '_'
|
||||
cookie = cookie.split('=')
|
||||
result[cookie[0]] = cookie[1]
|
||||
result
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let INT = undefined;
|
||||
const Cls = (this.CookiesStore = class CookiesStore {
|
||||
static initClass() {
|
||||
// Intentionally called CookiesStore instead of CookieStore
|
||||
// Calling it CookieStore causes issues when the Experimental Web Platform features flag is enabled in Chrome
|
||||
// Related issue: https://github.com/freeCodeCamp/devdocs/issues/932
|
||||
|
||||
INT = /^\d+$/;
|
||||
}
|
||||
|
||||
static onBlocked() {}
|
||||
|
||||
get(key) {
|
||||
let value = Cookies.get(key);
|
||||
if ((value != null) && INT.test(value)) { value = parseInt(value, 10); }
|
||||
return value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (value === false) {
|
||||
this.del(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === true) { value = 1; }
|
||||
if (value && (typeof INT.test === 'function' ? INT.test(value) : undefined)) { value = parseInt(value, 10); }
|
||||
Cookies.set(key, '' + value, {path: '/', expires: 1e8});
|
||||
if (this.get(key) !== value) { this.constructor.onBlocked(key, value, this.get(key)); }
|
||||
}
|
||||
|
||||
del(key) {
|
||||
Cookies.expire(key);
|
||||
}
|
||||
|
||||
reset() {
|
||||
try {
|
||||
for (var cookie of Array.from(document.cookie.split(/;\s?/))) {
|
||||
Cookies.expire(cookie.split('=')[0]);
|
||||
}
|
||||
return;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
dump() {
|
||||
const result = {};
|
||||
for (var cookie of Array.from(document.cookie.split(/;\s?/))) {
|
||||
if (cookie[0] !== '_') {
|
||||
cookie = cookie.split('=');
|
||||
result[cookie[0]] = cookie[1];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,28 +1,51 @@
|
||||
@Events =
|
||||
on: (event, callback) ->
|
||||
if event.indexOf(' ') >= 0
|
||||
@on name, callback for name in event.split(' ')
|
||||
else
|
||||
((@_callbacks ?= {})[event] ?= []).push callback
|
||||
@
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS104: Avoid inline assignments
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
this.Events = {
|
||||
on(event, callback) {
|
||||
if (event.indexOf(' ') >= 0) {
|
||||
for (var name of Array.from(event.split(' '))) { this.on(name, callback); }
|
||||
} else {
|
||||
let base;
|
||||
(((base = this._callbacks != null ? this._callbacks : (this._callbacks = {})))[event] != null ? base[event] : (base[event] = [])).push(callback);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
off: (event, callback) ->
|
||||
if event.indexOf(' ') >= 0
|
||||
@off name, callback for name in event.split(' ')
|
||||
else if (callbacks = @_callbacks?[event]) and (index = callbacks.indexOf callback) >= 0
|
||||
callbacks.splice index, 1
|
||||
delete @_callbacks[event] unless callbacks.length
|
||||
@
|
||||
off(event, callback) {
|
||||
let callbacks, index;
|
||||
if (event.indexOf(' ') >= 0) {
|
||||
for (var name of Array.from(event.split(' '))) { this.off(name, callback); }
|
||||
} else if ((callbacks = this._callbacks != null ? this._callbacks[event] : undefined) && ((index = callbacks.indexOf(callback)) >= 0)) {
|
||||
callbacks.splice(index, 1);
|
||||
if (!callbacks.length) { delete this._callbacks[event]; }
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
trigger: (event, args...) ->
|
||||
@eventInProgress = { name: event, args: args }
|
||||
if callbacks = @_callbacks?[event]
|
||||
callback? args... for callback in callbacks.slice(0)
|
||||
@eventInProgress = null
|
||||
@trigger 'all', event, args... unless event is 'all'
|
||||
@
|
||||
trigger(event, ...args) {
|
||||
let callbacks;
|
||||
this.eventInProgress = { name: event, args };
|
||||
if (callbacks = this._callbacks != null ? this._callbacks[event] : undefined) {
|
||||
for (var callback of Array.from(callbacks.slice(0))) { if (typeof callback === 'function') {
|
||||
callback(...Array.from(args || []));
|
||||
} }
|
||||
}
|
||||
this.eventInProgress = null;
|
||||
if (event !== 'all') { this.trigger('all', event, ...Array.from(args)); }
|
||||
return this;
|
||||
},
|
||||
|
||||
removeEvent: (event) ->
|
||||
if @_callbacks?
|
||||
delete @_callbacks[name] for name in event.split(' ')
|
||||
@
|
||||
removeEvent(event) {
|
||||
if (this._callbacks != null) {
|
||||
for (var name of Array.from(event.split(' '))) { delete this._callbacks[name]; }
|
||||
}
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
@ -1,76 +1,89 @@
|
||||
defaultUrl = null
|
||||
currentSlug = null
|
||||
|
||||
imageCache = {}
|
||||
urlCache = {}
|
||||
|
||||
withImage = (url, action) ->
|
||||
if imageCache[url]
|
||||
action(imageCache[url])
|
||||
else
|
||||
img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = url
|
||||
img.onload = () =>
|
||||
imageCache[url] = img
|
||||
action(img)
|
||||
|
||||
@setFaviconForDoc = (doc) ->
|
||||
return if currentSlug == doc.slug
|
||||
|
||||
favicon = $('link[rel="icon"]')
|
||||
|
||||
if defaultUrl == null
|
||||
defaultUrl = favicon.href
|
||||
|
||||
if urlCache[doc.slug]
|
||||
favicon.href = urlCache[doc.slug]
|
||||
currentSlug = doc.slug
|
||||
return
|
||||
|
||||
iconEl = $("._icon-#{doc.slug.split('~')[0]}")
|
||||
return if iconEl == null
|
||||
|
||||
styles = window.getComputedStyle(iconEl, ':before')
|
||||
|
||||
backgroundPositionX = styles['background-position-x']
|
||||
backgroundPositionY = styles['background-position-y']
|
||||
return if backgroundPositionX == undefined || backgroundPositionY == undefined
|
||||
|
||||
bgUrl = app.config.favicon_spritesheet
|
||||
sourceSize = 16
|
||||
sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2)))
|
||||
sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2)))
|
||||
|
||||
withImage(bgUrl, (docImg) ->
|
||||
withImage(defaultUrl, (defaultImg) ->
|
||||
size = defaultImg.width
|
||||
|
||||
canvas = document.createElement('canvas')
|
||||
ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx.drawImage(defaultImg, 0, 0)
|
||||
|
||||
docIconPercentage = 65
|
||||
destinationCoords = size / 100 * (100 - docIconPercentage)
|
||||
destinationSize = size / 100 * docIconPercentage
|
||||
|
||||
ctx.drawImage(docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize)
|
||||
|
||||
try
|
||||
urlCache[doc.slug] = canvas.toDataURL()
|
||||
favicon.href = urlCache[doc.slug]
|
||||
|
||||
currentSlug = doc.slug
|
||||
catch error
|
||||
Raven.captureException error, { level: 'info' }
|
||||
@resetFavicon()
|
||||
)
|
||||
)
|
||||
|
||||
@resetFavicon = () ->
|
||||
if defaultUrl != null and currentSlug != null
|
||||
$('link[rel="icon"]').href = defaultUrl
|
||||
currentSlug = null
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
let defaultUrl = null;
|
||||
let currentSlug = null;
|
||||
|
||||
const imageCache = {};
|
||||
const urlCache = {};
|
||||
|
||||
const withImage = function(url, action) {
|
||||
if (imageCache[url]) {
|
||||
return action(imageCache[url]);
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = url;
|
||||
return img.onload = () => {
|
||||
imageCache[url] = img;
|
||||
return action(img);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
this.setFaviconForDoc = function(doc) {
|
||||
if (currentSlug === doc.slug) { return; }
|
||||
|
||||
const favicon = $('link[rel="icon"]');
|
||||
|
||||
if (defaultUrl === null) {
|
||||
defaultUrl = favicon.href;
|
||||
}
|
||||
|
||||
if (urlCache[doc.slug]) {
|
||||
favicon.href = urlCache[doc.slug];
|
||||
currentSlug = doc.slug;
|
||||
return;
|
||||
}
|
||||
|
||||
const iconEl = $(`._icon-${doc.slug.split('~')[0]}`);
|
||||
if (iconEl === null) { return; }
|
||||
|
||||
const styles = window.getComputedStyle(iconEl, ':before');
|
||||
|
||||
const backgroundPositionX = styles['background-position-x'];
|
||||
const backgroundPositionY = styles['background-position-y'];
|
||||
if ((backgroundPositionX === undefined) || (backgroundPositionY === undefined)) { return; }
|
||||
|
||||
const bgUrl = app.config.favicon_spritesheet;
|
||||
const sourceSize = 16;
|
||||
const sourceX = Math.abs(parseInt(backgroundPositionX.slice(0, -2)));
|
||||
const sourceY = Math.abs(parseInt(backgroundPositionY.slice(0, -2)));
|
||||
|
||||
return withImage(bgUrl, docImg => withImage(defaultUrl, function(defaultImg) {
|
||||
const size = defaultImg.width;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
ctx.drawImage(defaultImg, 0, 0);
|
||||
|
||||
const docIconPercentage = 65;
|
||||
const destinationCoords = (size / 100) * (100 - docIconPercentage);
|
||||
const destinationSize = (size / 100) * docIconPercentage;
|
||||
|
||||
ctx.drawImage(docImg, sourceX, sourceY, sourceSize, sourceSize, destinationCoords, destinationCoords, destinationSize, destinationSize);
|
||||
|
||||
try {
|
||||
urlCache[doc.slug] = canvas.toDataURL();
|
||||
favicon.href = urlCache[doc.slug];
|
||||
|
||||
return currentSlug = doc.slug;
|
||||
} catch (error) {
|
||||
Raven.captureException(error, { level: 'info' });
|
||||
return this.resetFavicon();
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
this.resetFavicon = function() {
|
||||
if ((defaultUrl !== null) && (currentSlug !== null)) {
|
||||
$('link[rel="icon"]').href = defaultUrl;
|
||||
return currentSlug = null;
|
||||
}
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
###
|
||||
/*
|
||||
* Copyright 2013-2023 Thibaut Courouble and other contributors
|
||||
*
|
||||
* This source code is licensed under the terms of the Mozilla
|
||||
* Public License, v. 2.0, a copy of which may be obtained at:
|
||||
* http://mozilla.org/MPL/2.0/
|
||||
###
|
||||
*/
|
||||
|
@ -1,23 +1,33 @@
|
||||
class @LocalStorageStore
|
||||
get: (key) ->
|
||||
try
|
||||
JSON.parse localStorage.getItem(key)
|
||||
catch
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
this.LocalStorageStore = class LocalStorageStore {
|
||||
get(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key));
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
set: (key, value) ->
|
||||
try
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
true
|
||||
catch
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
del: (key) ->
|
||||
try
|
||||
localStorage.removeItem(key)
|
||||
true
|
||||
catch
|
||||
del(key) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
reset: ->
|
||||
try
|
||||
localStorage.clear()
|
||||
true
|
||||
catch
|
||||
reset() {
|
||||
try {
|
||||
localStorage.clear();
|
||||
return true;
|
||||
} catch (error) {}
|
||||
}
|
||||
};
|
||||
|
@ -1,223 +1,280 @@
|
||||
###
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
/*
|
||||
* Based on github.com/visionmedia/page.js
|
||||
* Licensed under the MIT license
|
||||
* Copyright 2012 TJ Holowaychuk <tj@vision-media.ca>
|
||||
###
|
||||
|
||||
running = false
|
||||
currentState = null
|
||||
callbacks = []
|
||||
|
||||
@page = (value, fn) ->
|
||||
if typeof value is 'function'
|
||||
page '*', value
|
||||
else if typeof fn is 'function'
|
||||
route = new Route(value)
|
||||
callbacks.push route.middleware(fn)
|
||||
else if typeof value is 'string'
|
||||
page.show(value, fn)
|
||||
else
|
||||
page.start(value)
|
||||
return
|
||||
|
||||
page.start = (options = {}) ->
|
||||
unless running
|
||||
running = true
|
||||
addEventListener 'popstate', onpopstate
|
||||
addEventListener 'click', onclick
|
||||
page.replace currentPath(), null, null, true
|
||||
return
|
||||
|
||||
page.stop = ->
|
||||
if running
|
||||
running = false
|
||||
removeEventListener 'click', onclick
|
||||
removeEventListener 'popstate', onpopstate
|
||||
return
|
||||
|
||||
page.show = (path, state) ->
|
||||
return if path is currentState?.path
|
||||
context = new Context(path, state)
|
||||
previousState = currentState
|
||||
currentState = context.state
|
||||
if res = page.dispatch(context)
|
||||
currentState = previousState
|
||||
location.assign(res)
|
||||
else
|
||||
context.pushState()
|
||||
updateCanonicalLink()
|
||||
track()
|
||||
context
|
||||
|
||||
page.replace = (path, state, skipDispatch, init) ->
|
||||
context = new Context(path, state or currentState)
|
||||
context.init = init
|
||||
currentState = context.state
|
||||
result = page.dispatch(context) unless skipDispatch
|
||||
if result
|
||||
context = new Context(result)
|
||||
context.init = init
|
||||
currentState = context.state
|
||||
page.dispatch(context)
|
||||
context.replaceState()
|
||||
updateCanonicalLink()
|
||||
track() unless skipDispatch
|
||||
context
|
||||
|
||||
page.dispatch = (context) ->
|
||||
i = 0
|
||||
next = ->
|
||||
res = fn(context, next) if fn = callbacks[i++]
|
||||
return res
|
||||
return next()
|
||||
|
||||
page.canGoBack = ->
|
||||
not Context.isIntialState(currentState)
|
||||
|
||||
page.canGoForward = ->
|
||||
not Context.isLastState(currentState)
|
||||
|
||||
currentPath = ->
|
||||
location.pathname + location.search + location.hash
|
||||
|
||||
class Context
|
||||
@initialPath: currentPath()
|
||||
@sessionId: Date.now()
|
||||
@stateId: 0
|
||||
|
||||
@isIntialState: (state) ->
|
||||
state.id == 0
|
||||
|
||||
@isLastState: (state) ->
|
||||
state.id == @stateId - 1
|
||||
|
||||
@isInitialPopState: (state) ->
|
||||
state.path is @initialPath and @stateId is 1
|
||||
|
||||
@isSameSession: (state) ->
|
||||
state.sessionId is @sessionId
|
||||
|
||||
constructor: (@path = '/', @state = {}) ->
|
||||
@pathname = @path.replace /(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) =>
|
||||
@query = query
|
||||
@hash = hash
|
||||
''
|
||||
|
||||
@state.id ?= @constructor.stateId++
|
||||
@state.sessionId ?= @constructor.sessionId
|
||||
@state.path = @path
|
||||
|
||||
pushState: ->
|
||||
history.pushState @state, '', @path
|
||||
return
|
||||
|
||||
replaceState: ->
|
||||
try history.replaceState @state, '', @path # NS_ERROR_FAILURE in Firefox
|
||||
return
|
||||
|
||||
class Route
|
||||
constructor: (@path, options = {}) ->
|
||||
@keys = []
|
||||
@regexp = pathtoRegexp @path, @keys
|
||||
|
||||
middleware: (fn) ->
|
||||
(context, next) =>
|
||||
if @match context.pathname, params = []
|
||||
context.params = params
|
||||
return fn(context, next)
|
||||
else
|
||||
return next()
|
||||
|
||||
match: (path, params) ->
|
||||
return unless matchData = @regexp.exec(path)
|
||||
|
||||
for value, i in matchData[1..]
|
||||
value = decodeURIComponent value if typeof value is 'string'
|
||||
if key = @keys[i]
|
||||
params[key.name] = value
|
||||
else
|
||||
params.push value
|
||||
true
|
||||
|
||||
pathtoRegexp = (path, keys) ->
|
||||
return path if path instanceof RegExp
|
||||
|
||||
path = "(#{path.join '|'})" if path instanceof Array
|
||||
*/
|
||||
|
||||
let running = false;
|
||||
let currentState = null;
|
||||
const callbacks = [];
|
||||
|
||||
this.page = function(value, fn) {
|
||||
if (typeof value === 'function') {
|
||||
page('*', value);
|
||||
} else if (typeof fn === 'function') {
|
||||
const route = new Route(value);
|
||||
callbacks.push(route.middleware(fn));
|
||||
} else if (typeof value === 'string') {
|
||||
page.show(value, fn);
|
||||
} else {
|
||||
page.start(value);
|
||||
}
|
||||
};
|
||||
|
||||
page.start = function(options) {
|
||||
if (options == null) { options = {}; }
|
||||
if (!running) {
|
||||
running = true;
|
||||
addEventListener('popstate', onpopstate);
|
||||
addEventListener('click', onclick);
|
||||
page.replace(currentPath(), null, null, true);
|
||||
}
|
||||
};
|
||||
|
||||
page.stop = function() {
|
||||
if (running) {
|
||||
running = false;
|
||||
removeEventListener('click', onclick);
|
||||
removeEventListener('popstate', onpopstate);
|
||||
}
|
||||
};
|
||||
|
||||
page.show = function(path, state) {
|
||||
let res;
|
||||
if (path === (currentState != null ? currentState.path : undefined)) { return; }
|
||||
const context = new Context(path, state);
|
||||
const previousState = currentState;
|
||||
currentState = context.state;
|
||||
if (res = page.dispatch(context)) {
|
||||
currentState = previousState;
|
||||
location.assign(res);
|
||||
} else {
|
||||
context.pushState();
|
||||
updateCanonicalLink();
|
||||
track();
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
page.replace = function(path, state, skipDispatch, init) {
|
||||
let result;
|
||||
let context = new Context(path, state || currentState);
|
||||
context.init = init;
|
||||
currentState = context.state;
|
||||
if (!skipDispatch) { result = page.dispatch(context); }
|
||||
if (result) {
|
||||
context = new Context(result);
|
||||
context.init = init;
|
||||
currentState = context.state;
|
||||
page.dispatch(context);
|
||||
}
|
||||
context.replaceState();
|
||||
updateCanonicalLink();
|
||||
if (!skipDispatch) { track(); }
|
||||
return context;
|
||||
};
|
||||
|
||||
page.dispatch = function(context) {
|
||||
let i = 0;
|
||||
var next = function() {
|
||||
let fn, res;
|
||||
if (fn = callbacks[i++]) { res = fn(context, next); }
|
||||
return res;
|
||||
};
|
||||
return next();
|
||||
};
|
||||
|
||||
page.canGoBack = () => !Context.isIntialState(currentState);
|
||||
|
||||
page.canGoForward = () => !Context.isLastState(currentState);
|
||||
|
||||
var currentPath = () => location.pathname + location.search + location.hash;
|
||||
|
||||
class Context {
|
||||
static initClass() {
|
||||
this.initialPath = currentPath();
|
||||
this.sessionId = Date.now();
|
||||
this.stateId = 0;
|
||||
}
|
||||
|
||||
static isIntialState(state) {
|
||||
return state.id === 0;
|
||||
}
|
||||
|
||||
static isLastState(state) {
|
||||
return state.id === (this.stateId - 1);
|
||||
}
|
||||
|
||||
static isInitialPopState(state) {
|
||||
return (state.path === this.initialPath) && (this.stateId === 1);
|
||||
}
|
||||
|
||||
static isSameSession(state) {
|
||||
return state.sessionId === this.sessionId;
|
||||
}
|
||||
|
||||
constructor(path, state) {
|
||||
if (path == null) { path = '/'; }
|
||||
this.path = path;
|
||||
if (state == null) { state = {}; }
|
||||
this.state = state;
|
||||
this.pathname = this.path.replace(/(?:\?([^#]*))?(?:#(.*))?$/, (_, query, hash) => {
|
||||
this.query = query;
|
||||
this.hash = hash;
|
||||
return '';
|
||||
});
|
||||
|
||||
if (this.state.id == null) { this.state.id = this.constructor.stateId++; }
|
||||
if (this.state.sessionId == null) { this.state.sessionId = this.constructor.sessionId; }
|
||||
this.state.path = this.path;
|
||||
}
|
||||
|
||||
pushState() {
|
||||
history.pushState(this.state, '', this.path);
|
||||
}
|
||||
|
||||
replaceState() {
|
||||
try { history.replaceState(this.state, '', this.path); } catch (error) {} // NS_ERROR_FAILURE in Firefox
|
||||
}
|
||||
}
|
||||
Context.initClass();
|
||||
|
||||
class Route {
|
||||
constructor(path, options) {
|
||||
this.path = path;
|
||||
if (options == null) { options = {}; }
|
||||
this.keys = [];
|
||||
this.regexp = pathtoRegexp(this.path, this.keys);
|
||||
}
|
||||
|
||||
middleware(fn) {
|
||||
return (context, next) => {
|
||||
let params;
|
||||
if (this.match(context.pathname, (params = []))) {
|
||||
context.params = params;
|
||||
return fn(context, next);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
match(path, params) {
|
||||
let matchData;
|
||||
if (!(matchData = this.regexp.exec(path))) { return; }
|
||||
|
||||
const iterable = matchData.slice(1);
|
||||
for (let i = 0; i < iterable.length; i++) {
|
||||
var key;
|
||||
var value = iterable[i];
|
||||
if (typeof value === 'string') { value = decodeURIComponent(value); }
|
||||
if ((key = this.keys[i])) {
|
||||
params[key.name] = value;
|
||||
} else {
|
||||
params.push(value);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var pathtoRegexp = function(path, keys) {
|
||||
if (path instanceof RegExp) { return path; }
|
||||
|
||||
if (path instanceof Array) { path = `(${path.join('|')})`; }
|
||||
path = path
|
||||
.replace /\/\(/g, '(?:/'
|
||||
.replace /(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, (_, slash = '', format = '', key, capture, optional) ->
|
||||
keys.push name: key, optional: !!optional
|
||||
str = if optional then '' else slash
|
||||
str += '(?:'
|
||||
str += slash if optional
|
||||
str += format
|
||||
str += capture or if format then '([^/.]+?)' else '([^/]+?)'
|
||||
str += ')'
|
||||
str += optional if optional
|
||||
str
|
||||
.replace /([\/.])/g, '\\$1'
|
||||
.replace /\*/g, '(.*)'
|
||||
|
||||
new RegExp "^#{path}$"
|
||||
|
||||
onpopstate = (event) ->
|
||||
return if not event.state or Context.isInitialPopState(event.state)
|
||||
|
||||
if Context.isSameSession(event.state)
|
||||
page.replace(event.state.path, event.state)
|
||||
else
|
||||
location.reload()
|
||||
return
|
||||
|
||||
onclick = (event) ->
|
||||
try
|
||||
return if event.which isnt 1 or event.metaKey or event.ctrlKey or event.shiftKey or event.defaultPrevented
|
||||
catch
|
||||
return
|
||||
|
||||
link = $.eventTarget(event)
|
||||
link = link.parentNode while link and link.tagName isnt 'A'
|
||||
|
||||
if link and not link.target and isSameOrigin(link.href)
|
||||
event.preventDefault()
|
||||
path = link.pathname + link.search + link.hash
|
||||
path = path.replace /^\/\/+/, '/' # IE11 bug
|
||||
page.show(path)
|
||||
return
|
||||
|
||||
isSameOrigin = (url) ->
|
||||
url.indexOf("#{location.protocol}//#{location.hostname}") is 0
|
||||
|
||||
updateCanonicalLink = ->
|
||||
@canonicalLink ||= document.head.querySelector('link[rel="canonical"]')
|
||||
@canonicalLink.setAttribute('href', "https://#{location.host}#{location.pathname}")
|
||||
|
||||
trackers = []
|
||||
|
||||
page.track = (fn) ->
|
||||
trackers.push(fn)
|
||||
return
|
||||
|
||||
track = ->
|
||||
return unless app.config.env == 'production'
|
||||
return if navigator.doNotTrack == '1'
|
||||
return if navigator.globalPrivacyControl
|
||||
|
||||
consentGiven = Cookies.get('analyticsConsent')
|
||||
consentAsked = Cookies.get('analyticsConsentAsked')
|
||||
|
||||
if consentGiven == '1'
|
||||
tracker.call() for tracker in trackers
|
||||
else if consentGiven == undefined and consentAsked == undefined
|
||||
# Only ask for consent once per browser session
|
||||
Cookies.set('analyticsConsentAsked', '1')
|
||||
|
||||
new app.views.Notif 'AnalyticsConsent', autoHide: null
|
||||
return
|
||||
|
||||
@resetAnalytics = ->
|
||||
for cookie in document.cookie.split(/;\s?/)
|
||||
name = cookie.split('=')[0]
|
||||
if name[0] == '_' && name[1] != '_'
|
||||
Cookies.expire(name)
|
||||
return
|
||||
.replace(/\/\(/g, '(?:/')
|
||||
.replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?/g, function(_, slash, format, key, capture, optional) {
|
||||
if (slash == null) { slash = ''; }
|
||||
if (format == null) { format = ''; }
|
||||
keys.push({name: key, optional: !!optional});
|
||||
let str = optional ? '' : slash;
|
||||
str += '(?:';
|
||||
if (optional) { str += slash; }
|
||||
str += format;
|
||||
str += capture || (format ? '([^/.]+?)' : '([^/]+?)');
|
||||
str += ')';
|
||||
if (optional) { str += optional; }
|
||||
return str;
|
||||
}).replace(/([\/.])/g, '\\$1')
|
||||
.replace(/\*/g, '(.*)');
|
||||
|
||||
return new RegExp(`^${path}$`);
|
||||
};
|
||||
|
||||
var onpopstate = function(event) {
|
||||
if (!event.state || Context.isInitialPopState(event.state)) { return; }
|
||||
|
||||
if (Context.isSameSession(event.state)) {
|
||||
page.replace(event.state.path, event.state);
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
var onclick = function(event) {
|
||||
try {
|
||||
if ((event.which !== 1) || event.metaKey || event.ctrlKey || event.shiftKey || event.defaultPrevented) { return; }
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
let link = $.eventTarget(event);
|
||||
while (link && (link.tagName !== 'A')) { link = link.parentNode; }
|
||||
|
||||
if (link && !link.target && isSameOrigin(link.href)) {
|
||||
event.preventDefault();
|
||||
let path = link.pathname + link.search + link.hash;
|
||||
path = path.replace(/^\/\/+/, '/'); // IE11 bug
|
||||
page.show(path);
|
||||
}
|
||||
};
|
||||
|
||||
var isSameOrigin = url => url.indexOf(`${location.protocol}//${location.hostname}`) === 0;
|
||||
|
||||
var updateCanonicalLink = function() {
|
||||
if (!this.canonicalLink) { this.canonicalLink = document.head.querySelector('link[rel="canonical"]'); }
|
||||
return this.canonicalLink.setAttribute('href', `https://${location.host}${location.pathname}`);
|
||||
};
|
||||
|
||||
const trackers = [];
|
||||
|
||||
page.track = function(fn) {
|
||||
trackers.push(fn);
|
||||
};
|
||||
|
||||
var track = function() {
|
||||
if (app.config.env !== 'production') { return; }
|
||||
if (navigator.doNotTrack === '1') { return; }
|
||||
if (navigator.globalPrivacyControl) { return; }
|
||||
|
||||
const consentGiven = Cookies.get('analyticsConsent');
|
||||
const consentAsked = Cookies.get('analyticsConsentAsked');
|
||||
|
||||
if (consentGiven === '1') {
|
||||
for (var tracker of Array.from(trackers)) { tracker.call(); }
|
||||
} else if ((consentGiven === undefined) && (consentAsked === undefined)) {
|
||||
// Only ask for consent once per browser session
|
||||
Cookies.set('analyticsConsentAsked', '1');
|
||||
|
||||
new app.views.Notif('AnalyticsConsent', {autoHide: null});
|
||||
}
|
||||
};
|
||||
|
||||
this.resetAnalytics = function() {
|
||||
for (var cookie of Array.from(document.cookie.split(/;\s?/))) {
|
||||
var name = cookie.split('=')[0];
|
||||
if ((name[0] === '_') && (name[1] !== '_')) {
|
||||
Cookies.expire(name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -1,399 +1,491 @@
|
||||
#
|
||||
# Traversing
|
||||
#
|
||||
|
||||
@$ = (selector, el = document) ->
|
||||
try el.querySelector(selector) catch
|
||||
|
||||
@$$ = (selector, el = document) ->
|
||||
try el.querySelectorAll(selector) catch
|
||||
|
||||
$.id = (id) ->
|
||||
document.getElementById(id)
|
||||
|
||||
$.hasChild = (parent, el) ->
|
||||
return unless parent
|
||||
while el
|
||||
return true if el is parent
|
||||
return if el is document.body
|
||||
el = el.parentNode
|
||||
|
||||
$.closestLink = (el, parent = document.body) ->
|
||||
while el
|
||||
return el if el.tagName is 'A'
|
||||
return if el is parent
|
||||
el = el.parentNode
|
||||
|
||||
#
|
||||
# Events
|
||||
#
|
||||
|
||||
$.on = (el, event, callback, useCapture = false) ->
|
||||
if event.indexOf(' ') >= 0
|
||||
$.on el, name, callback for name in event.split(' ')
|
||||
else
|
||||
el.addEventListener(event, callback, useCapture)
|
||||
return
|
||||
|
||||
$.off = (el, event, callback, useCapture = false) ->
|
||||
if event.indexOf(' ') >= 0
|
||||
$.off el, name, callback for name in event.split(' ')
|
||||
else
|
||||
el.removeEventListener(event, callback, useCapture)
|
||||
return
|
||||
|
||||
$.trigger = (el, type, canBubble = true, cancelable = true) ->
|
||||
event = document.createEvent 'Event'
|
||||
event.initEvent(type, canBubble, cancelable)
|
||||
el.dispatchEvent(event)
|
||||
return
|
||||
|
||||
$.click = (el) ->
|
||||
event = document.createEvent 'MouseEvent'
|
||||
event.initMouseEvent 'click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null
|
||||
el.dispatchEvent(event)
|
||||
return
|
||||
|
||||
$.stopEvent = (event) ->
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
|
||||
$.eventTarget = (event) ->
|
||||
event.target.correspondingUseElement || event.target
|
||||
|
||||
#
|
||||
# Manipulation
|
||||
#
|
||||
|
||||
buildFragment = (value) ->
|
||||
fragment = document.createDocumentFragment()
|
||||
|
||||
if $.isCollection(value)
|
||||
fragment.appendChild(child) for child in $.makeArray(value)
|
||||
else
|
||||
fragment.innerHTML = value
|
||||
|
||||
fragment
|
||||
|
||||
$.append = (el, value) ->
|
||||
if typeof value is 'string'
|
||||
el.insertAdjacentHTML 'beforeend', value
|
||||
else
|
||||
value = buildFragment(value) if $.isCollection(value)
|
||||
el.appendChild(value)
|
||||
return
|
||||
|
||||
$.prepend = (el, value) ->
|
||||
if not el.firstChild
|
||||
$.append(value)
|
||||
else if typeof value is 'string'
|
||||
el.insertAdjacentHTML 'afterbegin', value
|
||||
else
|
||||
value = buildFragment(value) if $.isCollection(value)
|
||||
el.insertBefore(value, el.firstChild)
|
||||
return
|
||||
|
||||
$.before = (el, value) ->
|
||||
if typeof value is 'string' or $.isCollection(value)
|
||||
value = buildFragment(value)
|
||||
|
||||
el.parentNode.insertBefore(value, el)
|
||||
return
|
||||
|
||||
$.after = (el, value) ->
|
||||
if typeof value is 'string' or $.isCollection(value)
|
||||
value = buildFragment(value)
|
||||
|
||||
if el.nextSibling
|
||||
el.parentNode.insertBefore(value, el.nextSibling)
|
||||
else
|
||||
el.parentNode.appendChild(value)
|
||||
return
|
||||
|
||||
$.remove = (value) ->
|
||||
if $.isCollection(value)
|
||||
el.parentNode?.removeChild(el) for el in $.makeArray(value)
|
||||
else
|
||||
value.parentNode?.removeChild(value)
|
||||
return
|
||||
|
||||
$.empty = (el) ->
|
||||
el.removeChild(el.firstChild) while el.firstChild
|
||||
return
|
||||
|
||||
# Calls the function while the element is off the DOM to avoid triggering
|
||||
# unnecessary reflows and repaints.
|
||||
$.batchUpdate = (el, fn) ->
|
||||
parent = el.parentNode
|
||||
sibling = el.nextSibling
|
||||
parent.removeChild(el)
|
||||
|
||||
fn(el)
|
||||
|
||||
if (sibling)
|
||||
parent.insertBefore(el, sibling)
|
||||
else
|
||||
parent.appendChild(el)
|
||||
return
|
||||
|
||||
#
|
||||
# Offset
|
||||
#
|
||||
|
||||
$.rect = (el) ->
|
||||
el.getBoundingClientRect()
|
||||
|
||||
$.offset = (el, container = document.body) ->
|
||||
top = 0
|
||||
left = 0
|
||||
|
||||
while el and el isnt container
|
||||
top += el.offsetTop
|
||||
left += el.offsetLeft
|
||||
el = el.offsetParent
|
||||
|
||||
top: top
|
||||
left: left
|
||||
|
||||
$.scrollParent = (el) ->
|
||||
while (el = el.parentNode) and el.nodeType is 1
|
||||
break if el.scrollTop > 0
|
||||
break if getComputedStyle(el)?.overflowY in ['auto', 'scroll']
|
||||
el
|
||||
|
||||
$.scrollTo = (el, parent, position = 'center', options = {}) ->
|
||||
return unless el
|
||||
|
||||
parent ?= $.scrollParent(el)
|
||||
return unless parent
|
||||
|
||||
parentHeight = parent.clientHeight
|
||||
parentScrollHeight = parent.scrollHeight
|
||||
return unless parentScrollHeight > parentHeight
|
||||
|
||||
top = $.offset(el, parent).top
|
||||
offsetTop = parent.firstElementChild.offsetTop
|
||||
|
||||
switch position
|
||||
when 'top'
|
||||
parent.scrollTop = top - offsetTop - (if options.margin? then options.margin else 0)
|
||||
when 'center'
|
||||
parent.scrollTop = top - Math.round(parentHeight / 2 - el.offsetHeight / 2)
|
||||
when 'continuous'
|
||||
scrollTop = parent.scrollTop
|
||||
height = el.offsetHeight
|
||||
|
||||
lastElementOffset = parent.lastElementChild.offsetTop + parent.lastElementChild.offsetHeight
|
||||
offsetBottom = if lastElementOffset > 0 then parentScrollHeight - lastElementOffset else 0
|
||||
|
||||
# If the target element is above the visible portion of its scrollable
|
||||
# ancestor, move it near the top with a gap = options.topGap * target's height.
|
||||
if top - offsetTop <= scrollTop + height * (options.topGap or 1)
|
||||
parent.scrollTop = top - offsetTop - height * (options.topGap or 1)
|
||||
# If the target element is below the visible portion of its scrollable
|
||||
# ancestor, move it near the bottom with a gap = options.bottomGap * target's height.
|
||||
else if top + offsetBottom >= scrollTop + parentHeight - height * ((options.bottomGap or 1) + 1)
|
||||
parent.scrollTop = top + offsetBottom - parentHeight + height * ((options.bottomGap or 1) + 1)
|
||||
return
|
||||
|
||||
$.scrollToWithImageLock = (el, parent, args...) ->
|
||||
parent ?= $.scrollParent(el)
|
||||
return unless parent
|
||||
|
||||
$.scrollTo el, parent, args...
|
||||
|
||||
# Lock the scroll position on the target element for up to 3 seconds while
|
||||
# nearby images are loaded and rendered.
|
||||
for image in parent.getElementsByTagName('img') when not image.complete
|
||||
do ->
|
||||
onLoad = (event) ->
|
||||
clearTimeout(timeout)
|
||||
unbind(event.target)
|
||||
$.scrollTo el, parent, args...
|
||||
|
||||
unbind = (target) ->
|
||||
$.off target, 'load', onLoad
|
||||
|
||||
$.on image, 'load', onLoad
|
||||
timeout = setTimeout unbind.bind(null, image), 3000
|
||||
return
|
||||
|
||||
# Calls the function while locking the element's position relative to the window.
|
||||
$.lockScroll = (el, fn) ->
|
||||
if parent = $.scrollParent(el)
|
||||
top = $.rect(el).top
|
||||
top -= $.rect(parent).top unless parent in [document.body, document.documentElement]
|
||||
fn()
|
||||
parent.scrollTop = $.offset(el, parent).top - top
|
||||
else
|
||||
fn()
|
||||
return
|
||||
|
||||
smoothScroll = smoothStart = smoothEnd = smoothDistance = smoothDuration = null
|
||||
|
||||
$.smoothScroll = (el, end) ->
|
||||
unless window.requestAnimationFrame
|
||||
el.scrollTop = end
|
||||
return
|
||||
|
||||
smoothEnd = end
|
||||
|
||||
if smoothScroll
|
||||
newDistance = smoothEnd - smoothStart
|
||||
smoothDuration += Math.min 300, Math.abs(smoothDistance - newDistance)
|
||||
smoothDistance = newDistance
|
||||
return
|
||||
|
||||
smoothStart = el.scrollTop
|
||||
smoothDistance = smoothEnd - smoothStart
|
||||
smoothDuration = Math.min 300, Math.abs(smoothDistance)
|
||||
startTime = Date.now()
|
||||
|
||||
smoothScroll = ->
|
||||
p = Math.min 1, (Date.now() - startTime) / smoothDuration
|
||||
y = Math.max 0, Math.floor(smoothStart + smoothDistance * (if p < 0.5 then 2 * p * p else p * (4 - p * 2) - 1))
|
||||
el.scrollTop = y
|
||||
if p is 1
|
||||
smoothScroll = null
|
||||
else
|
||||
requestAnimationFrame(smoothScroll)
|
||||
requestAnimationFrame(smoothScroll)
|
||||
|
||||
#
|
||||
# Utilities
|
||||
#
|
||||
|
||||
$.extend = (target, objects...) ->
|
||||
for object in objects when object
|
||||
for key, value of object
|
||||
target[key] = value
|
||||
target
|
||||
|
||||
$.makeArray = (object) ->
|
||||
if Array.isArray(object)
|
||||
object
|
||||
else
|
||||
Array::slice.apply(object)
|
||||
|
||||
$.arrayDelete = (array, object) ->
|
||||
index = array.indexOf(object)
|
||||
if index >= 0
|
||||
array.splice(index, 1)
|
||||
true
|
||||
else
|
||||
false
|
||||
|
||||
# Returns true if the object is an array or a collection of DOM elements.
|
||||
$.isCollection = (object) ->
|
||||
Array.isArray(object) or typeof object?.item is 'function'
|
||||
|
||||
ESCAPE_HTML_MAP =
|
||||
'&': '&'
|
||||
'<': '<'
|
||||
'>': '>'
|
||||
'"': '"'
|
||||
"'": '''
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS104: Avoid inline assignments
|
||||
* DS204: Change includes calls to have a more natural evaluation order
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* DS208: Avoid top-level this
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//
|
||||
// Traversing
|
||||
//
|
||||
|
||||
let smoothDistance, smoothDuration, smoothEnd, smoothStart;
|
||||
this.$ = function(selector, el) {
|
||||
if (el == null) { el = document; }
|
||||
try { return el.querySelector(selector); } catch (error) {}
|
||||
};
|
||||
|
||||
this.$$ = function(selector, el) {
|
||||
if (el == null) { el = document; }
|
||||
try { return el.querySelectorAll(selector); } catch (error) {}
|
||||
};
|
||||
|
||||
$.id = id => document.getElementById(id);
|
||||
|
||||
$.hasChild = function(parent, el) {
|
||||
if (!parent) { return; }
|
||||
while (el) {
|
||||
if (el === parent) { return true; }
|
||||
if (el === document.body) { return; }
|
||||
el = el.parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
$.closestLink = function(el, parent) {
|
||||
if (parent == null) { parent = document.body; }
|
||||
while (el) {
|
||||
if (el.tagName === 'A') { return el; }
|
||||
if (el === parent) { return; }
|
||||
el = el.parentNode;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Events
|
||||
//
|
||||
|
||||
$.on = function(el, event, callback, useCapture) {
|
||||
if (useCapture == null) { useCapture = false; }
|
||||
if (event.indexOf(' ') >= 0) {
|
||||
for (var name of Array.from(event.split(' '))) { $.on(el, name, callback); }
|
||||
} else {
|
||||
el.addEventListener(event, callback, useCapture);
|
||||
}
|
||||
};
|
||||
|
||||
$.off = function(el, event, callback, useCapture) {
|
||||
if (useCapture == null) { useCapture = false; }
|
||||
if (event.indexOf(' ') >= 0) {
|
||||
for (var name of Array.from(event.split(' '))) { $.off(el, name, callback); }
|
||||
} else {
|
||||
el.removeEventListener(event, callback, useCapture);
|
||||
}
|
||||
};
|
||||
|
||||
$.trigger = function(el, type, canBubble, cancelable) {
|
||||
if (canBubble == null) { canBubble = true; }
|
||||
if (cancelable == null) { cancelable = true; }
|
||||
const event = document.createEvent('Event');
|
||||
event.initEvent(type, canBubble, cancelable);
|
||||
el.dispatchEvent(event);
|
||||
};
|
||||
|
||||
$.click = function(el) {
|
||||
const event = document.createEvent('MouseEvent');
|
||||
event.initMouseEvent('click', true, true, window, null, 0, 0, 0, 0, false, false, false, false, 0, null);
|
||||
el.dispatchEvent(event);
|
||||
};
|
||||
|
||||
$.stopEvent = function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
$.eventTarget = event => event.target.correspondingUseElement || event.target;
|
||||
|
||||
//
|
||||
// Manipulation
|
||||
//
|
||||
|
||||
const buildFragment = function(value) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if ($.isCollection(value)) {
|
||||
for (var child of Array.from($.makeArray(value))) { fragment.appendChild(child); }
|
||||
} else {
|
||||
fragment.innerHTML = value;
|
||||
}
|
||||
|
||||
return fragment;
|
||||
};
|
||||
|
||||
$.append = function(el, value) {
|
||||
if (typeof value === 'string') {
|
||||
el.insertAdjacentHTML('beforeend', value);
|
||||
} else {
|
||||
if ($.isCollection(value)) { value = buildFragment(value); }
|
||||
el.appendChild(value);
|
||||
}
|
||||
};
|
||||
|
||||
$.prepend = function(el, value) {
|
||||
if (!el.firstChild) {
|
||||
$.append(value);
|
||||
} else if (typeof value === 'string') {
|
||||
el.insertAdjacentHTML('afterbegin', value);
|
||||
} else {
|
||||
if ($.isCollection(value)) { value = buildFragment(value); }
|
||||
el.insertBefore(value, el.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
$.before = function(el, value) {
|
||||
if ((typeof value === 'string') || $.isCollection(value)) {
|
||||
value = buildFragment(value);
|
||||
}
|
||||
|
||||
el.parentNode.insertBefore(value, el);
|
||||
};
|
||||
|
||||
$.after = function(el, value) {
|
||||
if ((typeof value === 'string') || $.isCollection(value)) {
|
||||
value = buildFragment(value);
|
||||
}
|
||||
|
||||
if (el.nextSibling) {
|
||||
el.parentNode.insertBefore(value, el.nextSibling);
|
||||
} else {
|
||||
el.parentNode.appendChild(value);
|
||||
}
|
||||
};
|
||||
|
||||
$.remove = function(value) {
|
||||
if ($.isCollection(value)) {
|
||||
for (var el of Array.from($.makeArray(value))) { if (el.parentNode != null) {
|
||||
el.parentNode.removeChild(el);
|
||||
} }
|
||||
} else {
|
||||
if (value.parentNode != null) {
|
||||
value.parentNode.removeChild(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$.empty = function(el) {
|
||||
while (el.firstChild) { el.removeChild(el.firstChild); }
|
||||
};
|
||||
|
||||
// Calls the function while the element is off the DOM to avoid triggering
|
||||
// unnecessary reflows and repaints.
|
||||
$.batchUpdate = function(el, fn) {
|
||||
const parent = el.parentNode;
|
||||
const sibling = el.nextSibling;
|
||||
parent.removeChild(el);
|
||||
|
||||
fn(el);
|
||||
|
||||
if (sibling) {
|
||||
parent.insertBefore(el, sibling);
|
||||
} else {
|
||||
parent.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Offset
|
||||
//
|
||||
|
||||
$.rect = el => el.getBoundingClientRect();
|
||||
|
||||
$.offset = function(el, container) {
|
||||
if (container == null) { container = document.body; }
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
while (el && (el !== container)) {
|
||||
top += el.offsetTop;
|
||||
left += el.offsetLeft;
|
||||
el = el.offsetParent;
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left
|
||||
};
|
||||
};
|
||||
|
||||
$.scrollParent = function(el) {
|
||||
while ((el = el.parentNode) && (el.nodeType === 1)) {
|
||||
var needle;
|
||||
if (el.scrollTop > 0) { break; }
|
||||
if ((needle = __guard__(getComputedStyle(el), x => x.overflowY), ['auto', 'scroll'].includes(needle))) { break; }
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
$.scrollTo = function(el, parent, position, options) {
|
||||
if (position == null) { position = 'center'; }
|
||||
if (options == null) { options = {}; }
|
||||
if (!el) { return; }
|
||||
|
||||
if (parent == null) { parent = $.scrollParent(el); }
|
||||
if (!parent) { return; }
|
||||
|
||||
const parentHeight = parent.clientHeight;
|
||||
const parentScrollHeight = parent.scrollHeight;
|
||||
if (!(parentScrollHeight > parentHeight)) { return; }
|
||||
|
||||
const {
|
||||
top
|
||||
} = $.offset(el, parent);
|
||||
const {
|
||||
offsetTop
|
||||
} = parent.firstElementChild;
|
||||
|
||||
switch (position) {
|
||||
case 'top':
|
||||
parent.scrollTop = top - offsetTop - ((options.margin != null) ? options.margin : 0);
|
||||
break;
|
||||
case 'center':
|
||||
parent.scrollTop = top - Math.round((parentHeight / 2) - (el.offsetHeight / 2));
|
||||
break;
|
||||
case 'continuous':
|
||||
var {
|
||||
scrollTop
|
||||
} = parent;
|
||||
var height = el.offsetHeight;
|
||||
|
||||
var lastElementOffset = parent.lastElementChild.offsetTop + parent.lastElementChild.offsetHeight;
|
||||
var offsetBottom = lastElementOffset > 0 ? parentScrollHeight - lastElementOffset : 0;
|
||||
|
||||
// If the target element is above the visible portion of its scrollable
|
||||
// ancestor, move it near the top with a gap = options.topGap * target's height.
|
||||
if ((top - offsetTop) <= (scrollTop + (height * (options.topGap || 1)))) {
|
||||
parent.scrollTop = top - offsetTop - (height * (options.topGap || 1));
|
||||
// If the target element is below the visible portion of its scrollable
|
||||
// ancestor, move it near the bottom with a gap = options.bottomGap * target's height.
|
||||
} else if ((top + offsetBottom) >= ((scrollTop + parentHeight) - (height * ((options.bottomGap || 1) + 1)))) {
|
||||
parent.scrollTop = ((top + offsetBottom) - parentHeight) + (height * ((options.bottomGap || 1) + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
$.scrollToWithImageLock = function(el, parent, ...args) {
|
||||
if (parent == null) { parent = $.scrollParent(el); }
|
||||
if (!parent) { return; }
|
||||
|
||||
$.scrollTo(el, parent, ...Array.from(args));
|
||||
|
||||
// Lock the scroll position on the target element for up to 3 seconds while
|
||||
// nearby images are loaded and rendered.
|
||||
for (var image of Array.from(parent.getElementsByTagName('img'))) {
|
||||
if (!image.complete) {
|
||||
(function() {
|
||||
let timeout;
|
||||
const onLoad = function(event) {
|
||||
clearTimeout(timeout);
|
||||
unbind(event.target);
|
||||
return $.scrollTo(el, parent, ...Array.from(args));
|
||||
};
|
||||
|
||||
var unbind = target => $.off(target, 'load', onLoad);
|
||||
|
||||
$.on(image, 'load', onLoad);
|
||||
return timeout = setTimeout(unbind.bind(null, image), 3000);
|
||||
})();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Calls the function while locking the element's position relative to the window.
|
||||
$.lockScroll = function(el, fn) {
|
||||
let parent;
|
||||
if (parent = $.scrollParent(el)) {
|
||||
let {
|
||||
top
|
||||
} = $.rect(el);
|
||||
if (![document.body, document.documentElement].includes(parent)) { top -= $.rect(parent).top; }
|
||||
fn();
|
||||
parent.scrollTop = $.offset(el, parent).top - top;
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
let smoothScroll = (smoothStart = (smoothEnd = (smoothDistance = (smoothDuration = null))));
|
||||
|
||||
$.smoothScroll = function(el, end) {
|
||||
if (!window.requestAnimationFrame) {
|
||||
el.scrollTop = end;
|
||||
return;
|
||||
}
|
||||
|
||||
smoothEnd = end;
|
||||
|
||||
if (smoothScroll) {
|
||||
const newDistance = smoothEnd - smoothStart;
|
||||
smoothDuration += Math.min(300, Math.abs(smoothDistance - newDistance));
|
||||
smoothDistance = newDistance;
|
||||
return;
|
||||
}
|
||||
|
||||
smoothStart = el.scrollTop;
|
||||
smoothDistance = smoothEnd - smoothStart;
|
||||
smoothDuration = Math.min(300, Math.abs(smoothDistance));
|
||||
const startTime = Date.now();
|
||||
|
||||
smoothScroll = function() {
|
||||
const p = Math.min(1, (Date.now() - startTime) / smoothDuration);
|
||||
const y = Math.max(0, Math.floor(smoothStart + (smoothDistance * (p < 0.5 ? 2 * p * p : (p * (4 - (p * 2))) - 1))));
|
||||
el.scrollTop = y;
|
||||
if (p === 1) {
|
||||
return smoothScroll = null;
|
||||
} else {
|
||||
return requestAnimationFrame(smoothScroll);
|
||||
}
|
||||
};
|
||||
return requestAnimationFrame(smoothScroll);
|
||||
};
|
||||
|
||||
//
|
||||
// Utilities
|
||||
//
|
||||
|
||||
$.extend = function(target, ...objects) {
|
||||
for (var object of Array.from(objects)) {
|
||||
if (object) {
|
||||
for (var key in object) {
|
||||
var value = object[key];
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
$.makeArray = function(object) {
|
||||
if (Array.isArray(object)) {
|
||||
return object;
|
||||
} else {
|
||||
return Array.prototype.slice.apply(object);
|
||||
}
|
||||
};
|
||||
|
||||
$.arrayDelete = function(array, object) {
|
||||
const index = array.indexOf(object);
|
||||
if (index >= 0) {
|
||||
array.splice(index, 1);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Returns true if the object is an array or a collection of DOM elements.
|
||||
$.isCollection = object => Array.isArray(object) || (typeof (object != null ? object.item : undefined) === 'function');
|
||||
|
||||
const ESCAPE_HTML_MAP = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/'
|
||||
|
||||
ESCAPE_HTML_REGEXP = /[&<>"'\/]/g
|
||||
|
||||
$.escape = (string) ->
|
||||
string.replace ESCAPE_HTML_REGEXP, (match) -> ESCAPE_HTML_MAP[match]
|
||||
|
||||
ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g
|
||||
|
||||
$.escapeRegexp = (string) ->
|
||||
string.replace ESCAPE_REGEXP, "\\$1"
|
||||
|
||||
$.urlDecode = (string) ->
|
||||
decodeURIComponent string.replace(/\+/g, '%20')
|
||||
|
||||
$.classify = (string) ->
|
||||
string = string.split('_')
|
||||
for substr, i in string
|
||||
string[i] = substr[0].toUpperCase() + substr[1..]
|
||||
string.join('')
|
||||
|
||||
$.framify = (fn, obj) ->
|
||||
if window.requestAnimationFrame
|
||||
(args...) -> requestAnimationFrame(fn.bind(obj, args...))
|
||||
else
|
||||
fn
|
||||
|
||||
$.requestAnimationFrame = (fn) ->
|
||||
if window.requestAnimationFrame
|
||||
requestAnimationFrame(fn)
|
||||
else
|
||||
setTimeout(fn, 0)
|
||||
return
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
#
|
||||
|
||||
$.noop = ->
|
||||
|
||||
$.popup = (value) ->
|
||||
try
|
||||
win = window.open()
|
||||
win.opener = null if win.opener
|
||||
win.location = value.href or value
|
||||
catch
|
||||
window.open value.href or value, '_blank'
|
||||
return
|
||||
|
||||
isMac = null
|
||||
$.isMac = ->
|
||||
isMac ?= navigator.userAgent?.indexOf('Mac') >= 0
|
||||
|
||||
isIE = null
|
||||
$.isIE = ->
|
||||
isIE ?= navigator.userAgent?.indexOf('MSIE') >= 0 || navigator.userAgent?.indexOf('rv:11.0') >= 0
|
||||
|
||||
isChromeForAndroid = null
|
||||
$.isChromeForAndroid = ->
|
||||
isChromeForAndroid ?= navigator.userAgent?.indexOf('Android') >= 0 && /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent)
|
||||
|
||||
isAndroid = null
|
||||
$.isAndroid = ->
|
||||
isAndroid ?= navigator.userAgent?.indexOf('Android') >= 0
|
||||
|
||||
isIOS = null
|
||||
$.isIOS = ->
|
||||
isIOS ?= navigator.userAgent?.indexOf('iPhone') >= 0 || navigator.userAgent?.indexOf('iPad') >= 0
|
||||
|
||||
$.overlayScrollbarsEnabled = ->
|
||||
return false unless $.isMac()
|
||||
div = document.createElement('div')
|
||||
div.setAttribute('style', 'width: 100px; height: 100px; overflow: scroll; position: absolute')
|
||||
document.body.appendChild(div)
|
||||
result = div.offsetWidth is div.clientWidth
|
||||
document.body.removeChild(div)
|
||||
result
|
||||
|
||||
HIGHLIGHT_DEFAULTS =
|
||||
className: 'highlight'
|
||||
};
|
||||
|
||||
const ESCAPE_HTML_REGEXP = /[&<>"'\/]/g;
|
||||
|
||||
$.escape = string => string.replace(ESCAPE_HTML_REGEXP, match => ESCAPE_HTML_MAP[match]);
|
||||
|
||||
const ESCAPE_REGEXP = /([.*+?^=!:${}()|\[\]\/\\])/g;
|
||||
|
||||
$.escapeRegexp = string => string.replace(ESCAPE_REGEXP, "\\$1");
|
||||
|
||||
$.urlDecode = string => decodeURIComponent(string.replace(/\+/g, '%20'));
|
||||
|
||||
$.classify = function(string) {
|
||||
string = string.split('_');
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
var substr = string[i];
|
||||
string[i] = substr[0].toUpperCase() + substr.slice(1);
|
||||
}
|
||||
return string.join('');
|
||||
};
|
||||
|
||||
$.framify = function(fn, obj) {
|
||||
if (window.requestAnimationFrame) {
|
||||
return (...args) => requestAnimationFrame(fn.bind(obj, ...Array.from(args)));
|
||||
} else {
|
||||
return fn;
|
||||
}
|
||||
};
|
||||
|
||||
$.requestAnimationFrame = function(fn) {
|
||||
if (window.requestAnimationFrame) {
|
||||
requestAnimationFrame(fn);
|
||||
} else {
|
||||
setTimeout(fn, 0);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Miscellaneous
|
||||
//
|
||||
|
||||
$.noop = function() {};
|
||||
|
||||
$.popup = function(value) {
|
||||
try {
|
||||
const win = window.open();
|
||||
if (win.opener) { win.opener = null; }
|
||||
win.location = value.href || value;
|
||||
} catch (error) {
|
||||
window.open(value.href || value, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
let isMac = null;
|
||||
$.isMac = () => isMac != null ? isMac : (isMac = (navigator.userAgent != null ? navigator.userAgent.indexOf('Mac') : undefined) >= 0);
|
||||
|
||||
let isIE = null;
|
||||
$.isIE = () => isIE != null ? isIE : (isIE = ((navigator.userAgent != null ? navigator.userAgent.indexOf('MSIE') : undefined) >= 0) || ((navigator.userAgent != null ? navigator.userAgent.indexOf('rv:11.0') : undefined) >= 0));
|
||||
|
||||
let isChromeForAndroid = null;
|
||||
$.isChromeForAndroid = () => isChromeForAndroid != null ? isChromeForAndroid : (isChromeForAndroid = ((navigator.userAgent != null ? navigator.userAgent.indexOf('Android') : undefined) >= 0) && /Chrome\/([.0-9])+ Mobile/.test(navigator.userAgent));
|
||||
|
||||
let isAndroid = null;
|
||||
$.isAndroid = () => isAndroid != null ? isAndroid : (isAndroid = (navigator.userAgent != null ? navigator.userAgent.indexOf('Android') : undefined) >= 0);
|
||||
|
||||
let isIOS = null;
|
||||
$.isIOS = () => isIOS != null ? isIOS : (isIOS = ((navigator.userAgent != null ? navigator.userAgent.indexOf('iPhone') : undefined) >= 0) || ((navigator.userAgent != null ? navigator.userAgent.indexOf('iPad') : undefined) >= 0));
|
||||
|
||||
$.overlayScrollbarsEnabled = function() {
|
||||
if (!$.isMac()) { return false; }
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('style', 'width: 100px; height: 100px; overflow: scroll; position: absolute');
|
||||
document.body.appendChild(div);
|
||||
const result = div.offsetWidth === div.clientWidth;
|
||||
document.body.removeChild(div);
|
||||
return result;
|
||||
};
|
||||
|
||||
const HIGHLIGHT_DEFAULTS = {
|
||||
className: 'highlight',
|
||||
delay: 1000
|
||||
|
||||
$.highlight = (el, options = {}) ->
|
||||
options = $.extend {}, HIGHLIGHT_DEFAULTS, options
|
||||
el.classList.add(options.className)
|
||||
setTimeout (-> el.classList.remove(options.className)), options.delay
|
||||
return
|
||||
|
||||
$.copyToClipboard = (string) ->
|
||||
textarea = document.createElement('textarea')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = 0
|
||||
textarea.value = string
|
||||
document.body.appendChild(textarea)
|
||||
try
|
||||
textarea.select()
|
||||
result = !!document.execCommand('copy')
|
||||
catch
|
||||
result = false
|
||||
finally
|
||||
document.body.removeChild(textarea)
|
||||
result
|
||||
};
|
||||
|
||||
$.highlight = function(el, options) {
|
||||
if (options == null) { options = {}; }
|
||||
options = $.extend({}, HIGHLIGHT_DEFAULTS, options);
|
||||
el.classList.add(options.className);
|
||||
setTimeout((() => el.classList.remove(options.className)), options.delay);
|
||||
};
|
||||
|
||||
$.copyToClipboard = function(string) {
|
||||
let result;
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = 0;
|
||||
textarea.value = string;
|
||||
document.body.appendChild(textarea);
|
||||
try {
|
||||
textarea.select();
|
||||
result = !!document.execCommand('copy');
|
||||
} catch (error) {
|
||||
result = false;
|
||||
}
|
||||
finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,147 +1,174 @@
|
||||
class app.models.Doc extends app.Model
|
||||
# Attributes: name, slug, type, version, release, db_size, mtime, links
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@reset @
|
||||
@slug_without_version = @slug.split('~')[0]
|
||||
@fullName = "#{@name}" + if @version then " #{@version}" else ''
|
||||
@icon = @slug_without_version
|
||||
@short_version = @version.split(' ')[0] if @version
|
||||
@text = @toEntry().text
|
||||
|
||||
reset: (data) ->
|
||||
@resetEntries data.entries
|
||||
@resetTypes data.types
|
||||
return
|
||||
|
||||
resetEntries: (entries) ->
|
||||
@entries = new app.collections.Entries(entries)
|
||||
@entries.each (entry) => entry.doc = @
|
||||
return
|
||||
|
||||
resetTypes: (types) ->
|
||||
@types = new app.collections.Types(types)
|
||||
@types.each (type) => type.doc = @
|
||||
return
|
||||
|
||||
fullPath: (path = '') ->
|
||||
path = "/#{path}" unless path[0] is '/'
|
||||
"/#{@slug}#{path}"
|
||||
|
||||
fileUrl: (path) ->
|
||||
"#{app.config.docs_origin}#{@fullPath(path)}?#{@mtime}"
|
||||
|
||||
dbUrl: ->
|
||||
"#{app.config.docs_origin}/#{@slug}/#{app.config.db_filename}?#{@mtime}"
|
||||
|
||||
indexUrl: ->
|
||||
"#{app.indexHost()}/#{@slug}/#{app.config.index_filename}?#{@mtime}"
|
||||
|
||||
toEntry: ->
|
||||
return @entry if @entry
|
||||
@entry = new app.models.Entry
|
||||
doc: @
|
||||
name: @fullName
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.models.Doc = class Doc extends app.Model {
|
||||
// Attributes: name, slug, type, version, release, db_size, mtime, links
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.reset(this);
|
||||
this.slug_without_version = this.slug.split('~')[0];
|
||||
this.fullName = `${this.name}` + (this.version ? ` ${this.version}` : '');
|
||||
this.icon = this.slug_without_version;
|
||||
if (this.version) { this.short_version = this.version.split(' ')[0]; }
|
||||
this.text = this.toEntry().text;
|
||||
}
|
||||
|
||||
reset(data) {
|
||||
this.resetEntries(data.entries);
|
||||
this.resetTypes(data.types);
|
||||
}
|
||||
|
||||
resetEntries(entries) {
|
||||
this.entries = new app.collections.Entries(entries);
|
||||
this.entries.each(entry => { return entry.doc = this; });
|
||||
}
|
||||
|
||||
resetTypes(types) {
|
||||
this.types = new app.collections.Types(types);
|
||||
this.types.each(type => { return type.doc = this; });
|
||||
}
|
||||
|
||||
fullPath(path) {
|
||||
if (path == null) { path = ''; }
|
||||
if (path[0] !== '/') { path = `/${path}`; }
|
||||
return `/${this.slug}${path}`;
|
||||
}
|
||||
|
||||
fileUrl(path) {
|
||||
return `${app.config.docs_origin}${this.fullPath(path)}?${this.mtime}`;
|
||||
}
|
||||
|
||||
dbUrl() {
|
||||
return `${app.config.docs_origin}/${this.slug}/${app.config.db_filename}?${this.mtime}`;
|
||||
}
|
||||
|
||||
indexUrl() {
|
||||
return `${app.indexHost()}/${this.slug}/${app.config.index_filename}?${this.mtime}`;
|
||||
}
|
||||
|
||||
toEntry() {
|
||||
if (this.entry) { return this.entry; }
|
||||
this.entry = new app.models.Entry({
|
||||
doc: this,
|
||||
name: this.fullName,
|
||||
path: 'index'
|
||||
@entry.addAlias(@name) if @version
|
||||
@entry
|
||||
|
||||
findEntryByPathAndHash: (path, hash) ->
|
||||
if hash and entry = @entries.findBy 'path', "#{path}##{hash}"
|
||||
entry
|
||||
else if path is 'index'
|
||||
@toEntry()
|
||||
else
|
||||
@entries.findBy 'path', path
|
||||
|
||||
load: (onSuccess, onError, options = {}) ->
|
||||
return if options.readCache and @_loadFromCache(onSuccess)
|
||||
|
||||
callback = (data) =>
|
||||
@reset data
|
||||
onSuccess()
|
||||
@_setCache data if options.writeCache
|
||||
return
|
||||
|
||||
ajax
|
||||
url: @indexUrl()
|
||||
success: callback
|
||||
});
|
||||
if (this.version) { this.entry.addAlias(this.name); }
|
||||
return this.entry;
|
||||
}
|
||||
|
||||
findEntryByPathAndHash(path, hash) {
|
||||
let entry;
|
||||
if (hash && (entry = this.entries.findBy('path', `${path}#${hash}`))) {
|
||||
return entry;
|
||||
} else if (path === 'index') {
|
||||
return this.toEntry();
|
||||
} else {
|
||||
return this.entries.findBy('path', path);
|
||||
}
|
||||
}
|
||||
|
||||
load(onSuccess, onError, options) {
|
||||
if (options == null) { options = {}; }
|
||||
if (options.readCache && this._loadFromCache(onSuccess)) { return; }
|
||||
|
||||
const callback = data => {
|
||||
this.reset(data);
|
||||
onSuccess();
|
||||
if (options.writeCache) { this._setCache(data); }
|
||||
};
|
||||
|
||||
return ajax({
|
||||
url: this.indexUrl(),
|
||||
success: callback,
|
||||
error: onError
|
||||
|
||||
clearCache: ->
|
||||
app.localStorage.del @slug
|
||||
return
|
||||
|
||||
_loadFromCache: (onSuccess) ->
|
||||
return unless data = @_getCache()
|
||||
|
||||
callback = =>
|
||||
@reset data
|
||||
onSuccess()
|
||||
return
|
||||
|
||||
setTimeout callback, 0
|
||||
true
|
||||
|
||||
_getCache: ->
|
||||
return unless data = app.localStorage.get @slug
|
||||
|
||||
if data[0] is @mtime
|
||||
return data[1]
|
||||
else
|
||||
@clearCache()
|
||||
return
|
||||
|
||||
_setCache: (data) ->
|
||||
app.localStorage.set @slug, [@mtime, data]
|
||||
return
|
||||
|
||||
install: (onSuccess, onError, onProgress) ->
|
||||
return if @installing
|
||||
@installing = true
|
||||
|
||||
error = =>
|
||||
@installing = null
|
||||
onError()
|
||||
return
|
||||
|
||||
success = (data) =>
|
||||
@installing = null
|
||||
app.db.store @, data, onSuccess, error
|
||||
return
|
||||
|
||||
ajax
|
||||
url: @dbUrl()
|
||||
success: success
|
||||
error: error
|
||||
progress: onProgress
|
||||
});
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
app.localStorage.del(this.slug);
|
||||
}
|
||||
|
||||
_loadFromCache(onSuccess) {
|
||||
let data;
|
||||
if (!(data = this._getCache())) { return; }
|
||||
|
||||
const callback = () => {
|
||||
this.reset(data);
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
setTimeout(callback, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
_getCache() {
|
||||
let data;
|
||||
if (!(data = app.localStorage.get(this.slug))) { return; }
|
||||
|
||||
if (data[0] === this.mtime) {
|
||||
return data[1];
|
||||
} else {
|
||||
this.clearCache();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_setCache(data) {
|
||||
app.localStorage.set(this.slug, [this.mtime, data]);
|
||||
}
|
||||
|
||||
install(onSuccess, onError, onProgress) {
|
||||
if (this.installing) { return; }
|
||||
this.installing = true;
|
||||
|
||||
const error = () => {
|
||||
this.installing = null;
|
||||
onError();
|
||||
};
|
||||
|
||||
const success = data => {
|
||||
this.installing = null;
|
||||
app.db.store(this, data, onSuccess, error);
|
||||
};
|
||||
|
||||
ajax({
|
||||
url: this.dbUrl(),
|
||||
success,
|
||||
error,
|
||||
progress: onProgress,
|
||||
timeout: 3600
|
||||
return
|
||||
|
||||
uninstall: (onSuccess, onError) ->
|
||||
return if @installing
|
||||
@installing = true
|
||||
|
||||
success = =>
|
||||
@installing = null
|
||||
onSuccess()
|
||||
return
|
||||
|
||||
error = =>
|
||||
@installing = null
|
||||
onError()
|
||||
return
|
||||
|
||||
app.db.unstore @, success, error
|
||||
return
|
||||
|
||||
getInstallStatus: (callback) ->
|
||||
app.db.version @, (value) ->
|
||||
callback installed: !!value, mtime: value
|
||||
return
|
||||
|
||||
isOutdated: (status) ->
|
||||
return false if not status
|
||||
isInstalled = status.installed or app.settings.get('autoInstall')
|
||||
isInstalled and @mtime isnt status.mtime
|
||||
});
|
||||
}
|
||||
|
||||
uninstall(onSuccess, onError) {
|
||||
if (this.installing) { return; }
|
||||
this.installing = true;
|
||||
|
||||
const success = () => {
|
||||
this.installing = null;
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
const error = () => {
|
||||
this.installing = null;
|
||||
onError();
|
||||
};
|
||||
|
||||
app.db.unstore(this, success, error);
|
||||
}
|
||||
|
||||
getInstallStatus(callback) {
|
||||
app.db.version(this, value => callback({installed: !!value, mtime: value}));
|
||||
}
|
||||
|
||||
isOutdated(status) {
|
||||
if (!status) { return false; }
|
||||
const isInstalled = status.installed || app.settings.get('autoInstall');
|
||||
return isInstalled && (this.mtime !== status.mtime);
|
||||
}
|
||||
};
|
||||
|
@ -1,85 +1,116 @@
|
||||
#= require app/searcher
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require app/searcher
|
||||
|
||||
class app.models.Entry extends app.Model
|
||||
# Attributes: name, type, path
|
||||
(function() {
|
||||
let applyAliases = undefined;
|
||||
const Cls = (app.models.Entry = class Entry extends app.Model {
|
||||
static initClass() {
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
@text = applyAliases(app.Searcher.normalizeString(@name))
|
||||
let ALIASES;
|
||||
applyAliases = function(string) {
|
||||
if (ALIASES.hasOwnProperty(string)) {
|
||||
return [string, ALIASES[string]];
|
||||
} else {
|
||||
const words = string.split('.');
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
var word = words[i];
|
||||
if (ALIASES.hasOwnProperty(word)) {
|
||||
words[i] = ALIASES[word];
|
||||
return [string, words.join('.')];
|
||||
}
|
||||
}
|
||||
}
|
||||
return string;
|
||||
};
|
||||
|
||||
addAlias: (name) ->
|
||||
text = applyAliases(app.Searcher.normalizeString(name))
|
||||
@text = [@text] unless Array.isArray(@text)
|
||||
@text.push(if Array.isArray(text) then text[1] else text)
|
||||
return
|
||||
this.ALIASES = (ALIASES = {
|
||||
'angular': 'ng',
|
||||
'angular.js': 'ng',
|
||||
'backbone.js': 'bb',
|
||||
'c++': 'cpp',
|
||||
'coffeescript': 'cs',
|
||||
'crystal': 'cr',
|
||||
'elixir': 'ex',
|
||||
'javascript': 'js',
|
||||
'julia': 'jl',
|
||||
'jquery': '$',
|
||||
'knockout.js': 'ko',
|
||||
'kubernetes': 'k8s',
|
||||
'less': 'ls',
|
||||
'lodash': '_',
|
||||
'löve': 'love',
|
||||
'marionette': 'mn',
|
||||
'markdown': 'md',
|
||||
'matplotlib': 'mpl',
|
||||
'modernizr': 'mdr',
|
||||
'moment.js': 'mt',
|
||||
'openjdk': 'java',
|
||||
'nginx': 'ngx',
|
||||
'numpy': 'np',
|
||||
'pandas': 'pd',
|
||||
'postgresql': 'pg',
|
||||
'python': 'py',
|
||||
'ruby.on.rails': 'ror',
|
||||
'ruby': 'rb',
|
||||
'rust': 'rs',
|
||||
'sass': 'scss',
|
||||
'tensorflow': 'tf',
|
||||
'typescript': 'ts',
|
||||
'underscore.js': '_'
|
||||
});
|
||||
}
|
||||
// Attributes: name, type, path
|
||||
|
||||
fullPath: ->
|
||||
@doc.fullPath if @isIndex() then '' else @path
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.text = applyAliases(app.Searcher.normalizeString(this.name));
|
||||
}
|
||||
|
||||
dbPath: ->
|
||||
@path.replace /#.*/, ''
|
||||
addAlias(name) {
|
||||
const text = applyAliases(app.Searcher.normalizeString(name));
|
||||
if (!Array.isArray(this.text)) { this.text = [this.text]; }
|
||||
this.text.push(Array.isArray(text) ? text[1] : text);
|
||||
}
|
||||
|
||||
filePath: ->
|
||||
@doc.fullPath @_filePath()
|
||||
fullPath() {
|
||||
return this.doc.fullPath(this.isIndex() ? '' : this.path);
|
||||
}
|
||||
|
||||
fileUrl: ->
|
||||
@doc.fileUrl @_filePath()
|
||||
dbPath() {
|
||||
return this.path.replace(/#.*/, '');
|
||||
}
|
||||
|
||||
_filePath: ->
|
||||
result = @path.replace /#.*/, ''
|
||||
result += '.html' unless result[-5..-1] is '.html'
|
||||
result
|
||||
filePath() {
|
||||
return this.doc.fullPath(this._filePath());
|
||||
}
|
||||
|
||||
isIndex: ->
|
||||
@path is 'index'
|
||||
fileUrl() {
|
||||
return this.doc.fileUrl(this._filePath());
|
||||
}
|
||||
|
||||
getType: ->
|
||||
@doc.types.findBy 'name', @type
|
||||
_filePath() {
|
||||
let result = this.path.replace(/#.*/, '');
|
||||
if (result.slice(-5) !== '.html') { result += '.html'; }
|
||||
return result;
|
||||
}
|
||||
|
||||
loadFile: (onSuccess, onError) ->
|
||||
app.db.load(@, onSuccess, onError)
|
||||
isIndex() {
|
||||
return this.path === 'index';
|
||||
}
|
||||
|
||||
applyAliases = (string) ->
|
||||
if ALIASES.hasOwnProperty(string)
|
||||
return [string, ALIASES[string]]
|
||||
else
|
||||
words = string.split('.')
|
||||
for word, i in words when ALIASES.hasOwnProperty(word)
|
||||
words[i] = ALIASES[word]
|
||||
return [string, words.join('.')]
|
||||
return string
|
||||
getType() {
|
||||
return this.doc.types.findBy('name', this.type);
|
||||
}
|
||||
|
||||
@ALIASES = ALIASES =
|
||||
'angular': 'ng'
|
||||
'angular.js': 'ng'
|
||||
'backbone.js': 'bb'
|
||||
'c++': 'cpp'
|
||||
'coffeescript': 'cs'
|
||||
'crystal': 'cr'
|
||||
'elixir': 'ex'
|
||||
'javascript': 'js'
|
||||
'julia': 'jl'
|
||||
'jquery': '$'
|
||||
'knockout.js': 'ko'
|
||||
'kubernetes': 'k8s'
|
||||
'less': 'ls'
|
||||
'lodash': '_'
|
||||
'löve': 'love'
|
||||
'marionette': 'mn'
|
||||
'markdown': 'md'
|
||||
'matplotlib': 'mpl'
|
||||
'modernizr': 'mdr'
|
||||
'moment.js': 'mt'
|
||||
'openjdk': 'java'
|
||||
'nginx': 'ngx'
|
||||
'numpy': 'np'
|
||||
'pandas': 'pd'
|
||||
'postgresql': 'pg'
|
||||
'python': 'py'
|
||||
'ruby.on.rails': 'ror'
|
||||
'ruby': 'rb'
|
||||
'rust': 'rs'
|
||||
'sass': 'scss'
|
||||
'tensorflow': 'tf'
|
||||
'typescript': 'ts'
|
||||
'underscore.js': '_'
|
||||
loadFile(onSuccess, onError) {
|
||||
return app.db.load(this, onSuccess, onError);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,3 +1,5 @@
|
||||
class app.Model
|
||||
constructor: (attributes) ->
|
||||
@[key] = value for key, value of attributes
|
||||
app.Model = class Model {
|
||||
constructor(attributes) {
|
||||
for (var key in attributes) { var value = attributes[key]; this[key] = value; }
|
||||
}
|
||||
};
|
||||
|
@ -1,14 +1,24 @@
|
||||
class app.models.Type extends app.Model
|
||||
# Attributes: name, slug, count
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.models.Type = class Type extends app.Model {
|
||||
// Attributes: name, slug, count
|
||||
|
||||
fullPath: ->
|
||||
"/#{@doc.slug}-#{@slug}/"
|
||||
fullPath() {
|
||||
return `/${this.doc.slug}-${this.slug}/`;
|
||||
}
|
||||
|
||||
entries: ->
|
||||
@doc.entries.findAllBy 'type', @name
|
||||
entries() {
|
||||
return this.doc.entries.findAllBy('type', this.name);
|
||||
}
|
||||
|
||||
toEntry: ->
|
||||
new app.models.Entry
|
||||
doc: @doc
|
||||
name: "#{@doc.name} / #{@name}"
|
||||
path: '..' + @fullPath()
|
||||
toEntry() {
|
||||
return new app.models.Entry({
|
||||
doc: this.doc,
|
||||
name: `${this.doc.name} / ${this.name}`,
|
||||
path: '..' + this.fullPath()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -1,11 +1,19 @@
|
||||
app.templates.render = (name, value, args...) ->
|
||||
template = app.templates[name]
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.templates.render = function(name, value, ...args) {
|
||||
const template = app.templates[name];
|
||||
|
||||
if Array.isArray(value)
|
||||
result = ''
|
||||
result += template(val, args...) for val in value
|
||||
result
|
||||
else if typeof template is 'function'
|
||||
template(value, args...)
|
||||
else
|
||||
template
|
||||
if (Array.isArray(value)) {
|
||||
let result = '';
|
||||
for (var val of Array.from(value)) { result += template(val, ...Array.from(args)); }
|
||||
return result;
|
||||
} else if (typeof template === 'function') {
|
||||
return template(value, ...Array.from(args));
|
||||
} else {
|
||||
return template;
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,14 @@
|
||||
notice = (text) -> """<p class="_notice-text">#{text}</p>"""
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const notice = text => `<p class="_notice-text">${text}</p>`;
|
||||
|
||||
app.templates.singleDocNotice = (doc) ->
|
||||
notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to
|
||||
<a href="//#{app.config.production_host}" target="_top">#{app.config.production_host}</a> (or press <code>esc</code>). """
|
||||
app.templates.singleDocNotice = doc => notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to
|
||||
<a href="//${app.config.production_host}" target="_top">${app.config.production_host}</a> (or press <code>esc</code>). `
|
||||
);
|
||||
|
||||
app.templates.disabledDocNotice = ->
|
||||
notice """ <strong>This documentation is disabled.</strong>
|
||||
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. """
|
||||
app.templates.disabledDocNotice = () => notice(` <strong>This documentation is disabled.</strong>
|
||||
To enable it, go to <a href="/settings" class="_notice-link">Preferences</a>. `
|
||||
);
|
||||
|
@ -1,76 +1,81 @@
|
||||
notif = (title, html) ->
|
||||
html = html.replace /<a /g, '<a class="_notif-link" '
|
||||
""" <h5 class="_notif-title">#{title}</h5>
|
||||
#{html}
|
||||
<button type="button" class="_notif-close" title="Close"><svg><use xlink:href="#icon-close"/></svg>Close</a>
|
||||
"""
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const notif = function(title, html) {
|
||||
html = html.replace(/<a /g, '<a class="_notif-link" ');
|
||||
return ` <h5 class="_notif-title">${title}</h5>
|
||||
${html}
|
||||
<button type="button" class="_notif-close" title="Close"><svg><use xlink:href="#icon-close"/></svg>Close</a>\
|
||||
`;
|
||||
};
|
||||
|
||||
textNotif = (title, message) ->
|
||||
notif title, """<p class="_notif-text">#{message}"""
|
||||
const textNotif = (title, message) => notif(title, `<p class="_notif-text">${message}`);
|
||||
|
||||
app.templates.notifUpdateReady = ->
|
||||
textNotif """<span data-behavior="reboot">DevDocs has been updated.</span>""",
|
||||
"""<span data-behavior="reboot"><a href="#" data-behavior="reboot">Reload the page</a> to use the new version.</span>"""
|
||||
app.templates.notifUpdateReady = () => textNotif("<span data-behavior=\"reboot\">DevDocs has been updated.</span>",
|
||||
"<span data-behavior=\"reboot\"><a href=\"#\" data-behavior=\"reboot\">Reload the page</a> to use the new version.</span>");
|
||||
|
||||
app.templates.notifError = ->
|
||||
textNotif """ Oops, an error occurred. """,
|
||||
""" Try <a href="#" data-behavior="hard-reload">reloading</a>, and if the problem persists,
|
||||
<a href="#" data-behavior="reset">resetting the app</a>.<br>
|
||||
You can also report this issue on <a href="https://github.com/freeCodeCamp/devdocs/issues/new" target="_blank" rel="noopener">GitHub</a>. """
|
||||
app.templates.notifError = () => textNotif(" Oops, an error occurred. ",
|
||||
` Try <a href="#" data-behavior="hard-reload">reloading</a>, and if the problem persists,
|
||||
<a href="#" data-behavior="reset">resetting the app</a>.<br>
|
||||
You can also report this issue on <a href="https://github.com/freeCodeCamp/devdocs/issues/new" target="_blank" rel="noopener">GitHub</a>. `
|
||||
);
|
||||
|
||||
app.templates.notifQuotaExceeded = ->
|
||||
textNotif """ The offline database has exceeded its size limitation. """,
|
||||
""" Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. """
|
||||
app.templates.notifQuotaExceeded = () => textNotif(" The offline database has exceeded its size limitation. ",
|
||||
" Unfortunately this quota can't be detected programmatically, and the database can't be opened while over the quota, so it had to be reset. ");
|
||||
|
||||
app.templates.notifCookieBlocked = ->
|
||||
textNotif """ Please enable cookies. """,
|
||||
""" DevDocs will not work properly if cookies are disabled. """
|
||||
app.templates.notifCookieBlocked = () => textNotif(" Please enable cookies. ",
|
||||
" DevDocs will not work properly if cookies are disabled. ");
|
||||
|
||||
app.templates.notifInvalidLocation = ->
|
||||
textNotif """ DevDocs must be loaded from #{app.config.production_host} """,
|
||||
""" Otherwise things are likely to break. """
|
||||
app.templates.notifInvalidLocation = () => textNotif(` DevDocs must be loaded from ${app.config.production_host} `,
|
||||
" Otherwise things are likely to break. ");
|
||||
|
||||
app.templates.notifImportInvalid = ->
|
||||
textNotif """ Oops, an error occurred. """,
|
||||
""" The file you selected is invalid. """
|
||||
app.templates.notifImportInvalid = () => textNotif(" Oops, an error occurred. ",
|
||||
" The file you selected is invalid. ");
|
||||
|
||||
app.templates.notifNews = (news) ->
|
||||
notif 'Changelog', """<div class="_notif-content _notif-news">#{app.templates.newsList(news, years: false)}</div>"""
|
||||
app.templates.notifNews = news => notif('Changelog', `<div class="_notif-content _notif-news">${app.templates.newsList(news, {years: false})}</div>`);
|
||||
|
||||
app.templates.notifUpdates = (docs, disabledDocs) ->
|
||||
html = '<div class="_notif-content _notif-news">'
|
||||
app.templates.notifUpdates = function(docs, disabledDocs) {
|
||||
let doc;
|
||||
let html = '<div class="_notif-content _notif-news">';
|
||||
|
||||
if docs.length > 0
|
||||
html += '<div class="_news-row">'
|
||||
html += '<ul class="_notif-list">'
|
||||
for doc in docs
|
||||
html += "<li>#{doc.name}"
|
||||
html += " <code>→</code> #{doc.release}" if doc.release
|
||||
html += '</ul></div>'
|
||||
if (docs.length > 0) {
|
||||
html += '<div class="_news-row">';
|
||||
html += '<ul class="_notif-list">';
|
||||
for (doc of Array.from(docs)) {
|
||||
html += `<li>${doc.name}`;
|
||||
if (doc.release) { html += ` <code>→</code> ${doc.release}`; }
|
||||
}
|
||||
html += '</ul></div>';
|
||||
}
|
||||
|
||||
if disabledDocs.length > 0
|
||||
html += '<div class="_news-row"><p class="_news-title">Disabled:'
|
||||
html += '<ul class="_notif-list">'
|
||||
for doc in disabledDocs
|
||||
html += "<li>#{doc.name}"
|
||||
html += " <code>→</code> #{doc.release}" if doc.release
|
||||
html += """<span class="_notif-info"><a href="/settings">Enable</a></span>"""
|
||||
html += '</ul></div>'
|
||||
if (disabledDocs.length > 0) {
|
||||
html += '<div class="_news-row"><p class="_news-title">Disabled:';
|
||||
html += '<ul class="_notif-list">';
|
||||
for (doc of Array.from(disabledDocs)) {
|
||||
html += `<li>${doc.name}`;
|
||||
if (doc.release) { html += ` <code>→</code> ${doc.release}`; }
|
||||
html += "<span class=\"_notif-info\"><a href=\"/settings\">Enable</a></span>";
|
||||
}
|
||||
html += '</ul></div>';
|
||||
}
|
||||
|
||||
notif 'Updates', "#{html}</div>"
|
||||
return notif('Updates', `${html}</div>`);
|
||||
};
|
||||
|
||||
app.templates.notifShare = ->
|
||||
textNotif """ Hi there! """,
|
||||
""" Like DevDocs? Help us reach more developers by sharing the link with your friends on
|
||||
<a href="https://out.devdocs.io/s/tw" target="_blank" rel="noopener">Twitter</a>, <a href="https://out.devdocs.io/s/fb" target="_blank" rel="noopener">Facebook</a>,
|
||||
<a href="https://out.devdocs.io/s/re" target="_blank" rel="noopener">Reddit</a>, etc.<br>Thanks :) """
|
||||
app.templates.notifShare = () => textNotif(" Hi there! ",
|
||||
` Like DevDocs? Help us reach more developers by sharing the link with your friends on
|
||||
<a href="https://out.devdocs.io/s/tw" target="_blank" rel="noopener">Twitter</a>, <a href="https://out.devdocs.io/s/fb" target="_blank" rel="noopener">Facebook</a>,
|
||||
<a href="https://out.devdocs.io/s/re" target="_blank" rel="noopener">Reddit</a>, etc.<br>Thanks :) `
|
||||
);
|
||||
|
||||
app.templates.notifUpdateDocs = ->
|
||||
textNotif """ Documentation updates available. """,
|
||||
""" <a href="/offline">Install them</a> as soon as possible to avoid broken pages. """
|
||||
app.templates.notifUpdateDocs = () => textNotif(" Documentation updates available. ",
|
||||
" <a href=\"/offline\">Install them</a> as soon as possible to avoid broken pages. ");
|
||||
|
||||
app.templates.notifAnalyticsConsent = ->
|
||||
textNotif """ Tracking cookies """,
|
||||
""" We would like to gather usage data about how DevDocs is used through Google Analytics and Gauges. We only collect anonymous traffic information.
|
||||
Please confirm if you accept our tracking cookies. You can always change your decision in the settings.
|
||||
<br><span class="_notif-right"><a href="#" data-behavior="accept-analytics">Accept</a> or <a href="#" data-behavior="decline-analytics">Decline</a></span> """
|
||||
app.templates.notifAnalyticsConsent = () => textNotif(" Tracking cookies ",
|
||||
` We would like to gather usage data about how DevDocs is used through Google Analytics and Gauges. We only collect anonymous traffic information.
|
||||
Please confirm if you accept our tracking cookies. You can always change your decision in the settings.
|
||||
<br><span class="_notif-right"><a href="#" data-behavior="accept-analytics">Accept</a> or <a href="#" data-behavior="decline-analytics">Decline</a></span> `
|
||||
);
|
||||
|
@ -1,81 +1,86 @@
|
||||
themeOption = ({ label, value }, settings) -> """
|
||||
<label class="_settings-label _theme-label">
|
||||
<input type="radio" name="theme" value="#{value}"#{if settings.theme == value then ' checked' else ''}>
|
||||
#{label}
|
||||
</label>
|
||||
"""
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const themeOption = ({ label, value }, settings) => `\
|
||||
<label class="_settings-label _theme-label">
|
||||
<input type="radio" name="theme" value="${value}"${settings.theme === value ? ' checked' : ''}>
|
||||
${label}
|
||||
</label>\
|
||||
`;
|
||||
|
||||
app.templates.settingsPage = (settings) -> """
|
||||
<h1 class="_lined-heading">Preferences</h1>
|
||||
app.templates.settingsPage = settings => `\
|
||||
<h1 class="_lined-heading">Preferences</h1>
|
||||
|
||||
<div class="_settings-fieldset">
|
||||
<div class="_settings-fieldset">
|
||||
<h2 class="_settings-legend">Theme:</h2>
|
||||
<div class="_settings-inputs">
|
||||
#{if settings.autoSupported
|
||||
themeOption label: "Automatic <small>Matches system setting</small>", value: "auto", settings
|
||||
else
|
||||
${settings.autoSupported ?
|
||||
themeOption({label: "Automatic <small>Matches system setting</small>", value: "auto"}, settings)
|
||||
:
|
||||
""}
|
||||
#{themeOption label: "Light", value: "default", settings}
|
||||
#{themeOption label: "Dark", value: "dark", settings}
|
||||
</div>
|
||||
${themeOption({label: "Light", value: "default"}, settings)}
|
||||
${themeOption({label: "Dark", value: "dark"}, settings)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="_settings-fieldset">
|
||||
<div class="_settings-fieldset">
|
||||
<h2 class="_settings-legend">General:</h2>
|
||||
|
||||
<div class="_settings-inputs">
|
||||
<label class="_settings-label _setting-max-width">
|
||||
<input type="checkbox" form="settings" name="layout" value="_max-width"#{if settings['_max-width'] then ' checked' else ''}>Enable fixed-width layout
|
||||
<input type="checkbox" form="settings" name="layout" value="_max-width"${settings['_max-width'] ? ' checked' : ''}>Enable fixed-width layout
|
||||
</label>
|
||||
<label class="_settings-label _setting-text-justify-hyphenate">
|
||||
<input type="checkbox" form="settings" name="layout" value="_text-justify-hyphenate"#{if settings['_text-justify-hyphenate'] then ' checked' else ''}>Enable justified layout and automatic hyphenation
|
||||
<input type="checkbox" form="settings" name="layout" value="_text-justify-hyphenate"${settings['_text-justify-hyphenate'] ? ' checked' : ''}>Enable justified layout and automatic hyphenation
|
||||
</label>
|
||||
<label class="_settings-label _hide-on-mobile">
|
||||
<input type="checkbox" form="settings" name="layout" value="_sidebar-hidden"#{if settings['_sidebar-hidden'] then ' checked' else ''}>Automatically hide and show the sidebar
|
||||
<input type="checkbox" form="settings" name="layout" value="_sidebar-hidden"${settings['_sidebar-hidden'] ? ' checked' : ''}>Automatically hide and show the sidebar
|
||||
<small>Tip: drag the edge of the sidebar to resize it.</small>
|
||||
</label>
|
||||
<label class="_settings-label _hide-on-mobile">
|
||||
<input type="checkbox" form="settings" name="noAutofocus" value="_no-autofocus"#{if settings.noAutofocus then ' checked' else ''}>Disable autofocus of search input
|
||||
<input type="checkbox" form="settings" name="noAutofocus" value="_no-autofocus"${settings.noAutofocus ? ' checked' : ''}>Disable autofocus of search input
|
||||
</label>
|
||||
<label class="_settings-label">
|
||||
<input type="checkbox" form="settings" name="autoInstall" value="_auto-install"#{if settings.autoInstall then ' checked' else ''}>Automatically download documentation for offline use
|
||||
<input type="checkbox" form="settings" name="autoInstall" value="_auto-install"${settings.autoInstall ? ' checked' : ''}>Automatically download documentation for offline use
|
||||
<small>Only enable this when bandwidth isn't a concern to you.</small>
|
||||
</label>
|
||||
<label class="_settings-label _hide-in-development">
|
||||
<input type="checkbox" form="settings" name="analyticsConsent"#{if settings.analyticsConsent then ' checked' else ''}>Enable tracking cookies
|
||||
<input type="checkbox" form="settings" name="analyticsConsent"${settings.analyticsConsent ? ' checked' : ''}>Enable tracking cookies
|
||||
<small>With this checked, we enable Google Analytics and Gauges to collect anonymous traffic information.</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="_settings-fieldset _hide-on-mobile">
|
||||
<div class="_settings-fieldset _hide-on-mobile">
|
||||
<h2 class="_settings-legend">Scrolling:</h2>
|
||||
|
||||
<div class="_settings-inputs">
|
||||
<label class="_settings-label">
|
||||
<input type="checkbox" form="settings" name="smoothScroll" value="1"#{if settings.smoothScroll then ' checked' else ''}>Use smooth scrolling
|
||||
<input type="checkbox" form="settings" name="smoothScroll" value="1"${settings.smoothScroll ? ' checked' : ''}>Use smooth scrolling
|
||||
</label>
|
||||
<label class="_settings-label _setting-native-scrollbar">
|
||||
<input type="checkbox" form="settings" name="layout" value="_native-scrollbars"#{if settings['_native-scrollbars'] then ' checked' else ''}>Use native scrollbars
|
||||
<input type="checkbox" form="settings" name="layout" value="_native-scrollbars"${settings['_native-scrollbars'] ? ' checked' : ''}>Use native scrollbars
|
||||
</label>
|
||||
<label class="_settings-label">
|
||||
<input type="checkbox" form="settings" name="arrowScroll" value="1"#{if settings.arrowScroll then ' checked' else ''}>Use arrow keys to scroll the main content area
|
||||
<input type="checkbox" form="settings" name="arrowScroll" value="1"${settings.arrowScroll ? ' checked' : ''}>Use arrow keys to scroll the main content area
|
||||
<small>With this checked, use <code class="_label">shift</code> + <code class="_label">↑</code><code class="_label">↓</code><code class="_label">←</code><code class="_label">→</code> to navigate the sidebar.</small>
|
||||
</label>
|
||||
<label class="_settings-label">
|
||||
<input type="checkbox" form="settings" name="spaceScroll" value="1"#{if settings.spaceScroll then ' checked' else ''}>Use spacebar to scroll during search
|
||||
<input type="checkbox" form="settings" name="spaceScroll" value="1"${settings.spaceScroll ? ' checked' : ''}>Use spacebar to scroll during search
|
||||
</label>
|
||||
<label class="_settings-label">
|
||||
<input type="number" step="0.1" form="settings" name="spaceTimeout" min="0" max="5" value="#{settings.spaceTimeout}"> Delay until you can scroll by pressing space
|
||||
<input type="number" step="0.1" form="settings" name="spaceTimeout" min="0" max="5" value="${settings.spaceTimeout}"> Delay until you can scroll by pressing space
|
||||
<small>Time in seconds</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="_hide-on-mobile">
|
||||
<p class="_hide-on-mobile">
|
||||
<button type="button" class="_btn" data-action="export">Export</button>
|
||||
<label class="_btn _file-btn"><input type="file" form="settings" name="import" accept=".json">Import</label>
|
||||
|
||||
<p>
|
||||
<button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button>
|
||||
"""
|
||||
<p>
|
||||
<button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button>\
|
||||
`;
|
||||
|
@ -1,6 +1,9 @@
|
||||
app.templates.typePage = (type) ->
|
||||
""" <h1>#{type.doc.fullName} / #{type.name}</h1>
|
||||
<ul class="_entry-list">#{app.templates.render 'typePageEntry', type.entries()}</ul> """
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.templates.typePage = type => ` <h1>${type.doc.fullName} / ${type.name}</h1>
|
||||
<ul class="_entry-list">${app.templates.render('typePageEntry', type.entries())}</ul> `;
|
||||
|
||||
app.templates.typePageEntry = (entry) ->
|
||||
"""<li><a href="#{entry.fullPath()}">#{$.escape entry.name}</a></li>"""
|
||||
app.templates.typePageEntry = entry => `<li><a href="${entry.fullPath()}">${$.escape(entry.name)}</a></li>`;
|
||||
|
@ -1,7 +1,13 @@
|
||||
arrow = """<svg class="_path-arrow"><use xlink:href="#icon-dir"/></svg>"""
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const arrow = "<svg class=\"_path-arrow\"><use xlink:href=\"#icon-dir\"/></svg>";
|
||||
|
||||
app.templates.path = (doc, type, entry) ->
|
||||
html = """<a href="#{doc.fullPath()}" class="_path-item _icon-#{doc.icon}">#{doc.fullName}</a>"""
|
||||
html += """#{arrow}<a href="#{type.fullPath()}" class="_path-item">#{type.name}</a>""" if type
|
||||
html += """#{arrow}<span class="_path-item">#{$.escape entry.name}</span>""" if entry
|
||||
html
|
||||
app.templates.path = function(doc, type, entry) {
|
||||
let html = `<a href="${doc.fullPath()}" class="_path-item _icon-${doc.icon}">${doc.fullName}</a>`;
|
||||
if (type) { html += `${arrow}<a href="${type.fullPath()}" class="_path-item">${type.name}</a>`; }
|
||||
if (entry) { html += `${arrow}<span class="_path-item">${$.escape(entry.name)}</span>`; }
|
||||
return html;
|
||||
};
|
||||
|
@ -1,68 +1,79 @@
|
||||
templates = app.templates
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const {
|
||||
templates
|
||||
} = app;
|
||||
|
||||
arrow = """<svg class="_list-arrow"><use xlink:href="#icon-dir"/></svg>"""
|
||||
const arrow = "<svg class=\"_list-arrow\"><use xlink:href=\"#icon-dir\"/></svg>";
|
||||
|
||||
templates.sidebarDoc = (doc, options = {}) ->
|
||||
link = """<a href="#{doc.fullPath()}" class="_list-item _icon-#{doc.icon} """
|
||||
link += if options.disabled then '_list-disabled' else '_list-dir'
|
||||
link += """" data-slug="#{doc.slug}" title="#{doc.fullName}" tabindex="-1">"""
|
||||
if options.disabled
|
||||
link += """<span class="_list-enable" data-enable="#{doc.slug}">Enable</span>"""
|
||||
else
|
||||
link += arrow
|
||||
link += """<span class="_list-count">#{doc.release}</span>""" if doc.release
|
||||
link += """<span class="_list-text">#{doc.name}"""
|
||||
link += " #{doc.version}" if options.fullName or options.disabled and doc.version
|
||||
link + "</span></a>"
|
||||
templates.sidebarDoc = function(doc, options) {
|
||||
if (options == null) { options = {}; }
|
||||
let link = `<a href="${doc.fullPath()}" class="_list-item _icon-${doc.icon} `;
|
||||
link += options.disabled ? '_list-disabled' : '_list-dir';
|
||||
link += `" data-slug="${doc.slug}" title="${doc.fullName}" tabindex="-1">`;
|
||||
if (options.disabled) {
|
||||
link += `<span class="_list-enable" data-enable="${doc.slug}">Enable</span>`;
|
||||
} else {
|
||||
link += arrow;
|
||||
}
|
||||
if (doc.release) { link += `<span class="_list-count">${doc.release}</span>`; }
|
||||
link += `<span class="_list-text">${doc.name}`;
|
||||
if (options.fullName || (options.disabled && doc.version)) { link += ` ${doc.version}`; }
|
||||
return link + "</span></a>";
|
||||
};
|
||||
|
||||
templates.sidebarType = (type) ->
|
||||
"""<a href="#{type.fullPath()}" class="_list-item _list-dir" data-slug="#{type.slug}" tabindex="-1">#{arrow}<span class="_list-count">#{type.count}</span><span class="_list-text">#{$.escape type.name}</span></a>"""
|
||||
templates.sidebarType = type => `<a href="${type.fullPath()}" class="_list-item _list-dir" data-slug="${type.slug}" tabindex="-1">${arrow}<span class="_list-count">${type.count}</span><span class="_list-text">${$.escape(type.name)}</span></a>`;
|
||||
|
||||
templates.sidebarEntry = (entry) ->
|
||||
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover" tabindex="-1">#{$.escape entry.name}</a>"""
|
||||
templates.sidebarEntry = entry => `<a href="${entry.fullPath()}" class="_list-item _list-hover" tabindex="-1">${$.escape(entry.name)}</a>`;
|
||||
|
||||
templates.sidebarResult = (entry) ->
|
||||
addons = if entry.isIndex() and app.disabledDocs.contains(entry.doc)
|
||||
"""<span class="_list-enable" data-enable="#{entry.doc.slug}">Enable</span>"""
|
||||
else
|
||||
"""<span class="_list-reveal" data-reset-list title="Reveal in list"></span>"""
|
||||
addons += """<span class="_list-count">#{entry.doc.short_version}</span>""" if entry.doc.version and not entry.isIndex()
|
||||
"""<a href="#{entry.fullPath()}" class="_list-item _list-hover _list-result _icon-#{entry.doc.icon}" tabindex="-1">#{addons}<span class="_list-text">#{$.escape entry.name}</span></a>"""
|
||||
templates.sidebarResult = function(entry) {
|
||||
let addons = entry.isIndex() && app.disabledDocs.contains(entry.doc) ?
|
||||
`<span class="_list-enable" data-enable="${entry.doc.slug}">Enable</span>`
|
||||
:
|
||||
"<span class=\"_list-reveal\" data-reset-list title=\"Reveal in list\"></span>";
|
||||
if (entry.doc.version && !entry.isIndex()) { addons += `<span class="_list-count">${entry.doc.short_version}</span>`; }
|
||||
return `<a href="${entry.fullPath()}" class="_list-item _list-hover _list-result _icon-${entry.doc.icon}" tabindex="-1">${addons}<span class="_list-text">${$.escape(entry.name)}</span></a>`;
|
||||
};
|
||||
|
||||
templates.sidebarNoResults = ->
|
||||
html = """ <div class="_list-note">No results.</div> """
|
||||
html += """
|
||||
<div class="_list-note">Note: documentations must be <a href="/settings" class="_list-note-link">enabled</a> to appear in the search.</div>
|
||||
""" unless app.isSingleDoc() or app.disabledDocs.isEmpty()
|
||||
html
|
||||
templates.sidebarNoResults = function() {
|
||||
let html = " <div class=\"_list-note\">No results.</div> ";
|
||||
if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { html += `\
|
||||
<div class="_list-note">Note: documentations must be <a href="/settings" class="_list-note-link">enabled</a> to appear in the search.</div>\
|
||||
`; }
|
||||
return html;
|
||||
};
|
||||
|
||||
templates.sidebarPageLink = (count) ->
|
||||
"""<span role="link" class="_list-item _list-pagelink">Show more\u2026 (#{count})</span>"""
|
||||
templates.sidebarPageLink = count => `<span role="link" class="_list-item _list-pagelink">Show more\u2026 (${count})</span>`;
|
||||
|
||||
templates.sidebarLabel = (doc, options = {}) ->
|
||||
label = """<label class="_list-item"""
|
||||
label += " _icon-#{doc.icon}" unless doc.version
|
||||
label += """"><input type="checkbox" name="#{doc.slug}" class="_list-checkbox" """
|
||||
label += "checked" if options.checked
|
||||
label + """><span class="_list-text">#{doc.fullName}</span></label>"""
|
||||
templates.sidebarLabel = function(doc, options) {
|
||||
if (options == null) { options = {}; }
|
||||
let label = "<label class=\"_list-item";
|
||||
if (!doc.version) { label += ` _icon-${doc.icon}`; }
|
||||
label += `"><input type="checkbox" name="${doc.slug}" class="_list-checkbox" `;
|
||||
if (options.checked) { label += "checked"; }
|
||||
return label + `><span class="_list-text">${doc.fullName}</span></label>`;
|
||||
};
|
||||
|
||||
templates.sidebarVersionedDoc = (doc, versions, options = {}) ->
|
||||
html = """<div class="_list-item _list-dir _list-rdir _icon-#{doc.icon}"""
|
||||
html += " open" if options.open
|
||||
html + """" tabindex="0">#{arrow}#{doc.name}</div><div class="_list _list-sub">#{versions}</div>"""
|
||||
templates.sidebarVersionedDoc = function(doc, versions, options) {
|
||||
if (options == null) { options = {}; }
|
||||
let html = `<div class="_list-item _list-dir _list-rdir _icon-${doc.icon}`;
|
||||
if (options.open) { html += " open"; }
|
||||
return html + `" tabindex="0">${arrow}${doc.name}</div><div class="_list _list-sub">${versions}</div>`;
|
||||
};
|
||||
|
||||
templates.sidebarDisabled = (options) ->
|
||||
"""<h6 class="_list-title">#{arrow}Disabled (#{options.count}) <a href="/settings" class="_list-title-link" tabindex="-1">Customize</a></h6>"""
|
||||
templates.sidebarDisabled = options => `<h6 class="_list-title">${arrow}Disabled (${options.count}) <a href="/settings" class="_list-title-link" tabindex="-1">Customize</a></h6>`;
|
||||
|
||||
templates.sidebarDisabledList = (html) ->
|
||||
"""<div class="_disabled-list">#{html}</div>"""
|
||||
templates.sidebarDisabledList = html => `<div class="_disabled-list">${html}</div>`;
|
||||
|
||||
templates.sidebarDisabledVersionedDoc = (doc, versions) ->
|
||||
"""<a class="_list-item _list-dir _icon-#{doc.icon} _list-disabled" data-slug="#{doc.slug_without_version}" tabindex="-1">#{arrow}#{doc.name}</a><div class="_list _list-sub">#{versions}</div>"""
|
||||
templates.sidebarDisabledVersionedDoc = (doc, versions) => `<a class="_list-item _list-dir _icon-${doc.icon} _list-disabled" data-slug="${doc.slug_without_version}" tabindex="-1">${arrow}${doc.name}</a><div class="_list _list-sub">${versions}</div>`;
|
||||
|
||||
templates.docPickerHeader = """<div class="_list-picker-head"><span>Documentation</span> <span>Enable</span></div>"""
|
||||
templates.docPickerHeader = "<div class=\"_list-picker-head\"><span>Documentation</span> <span>Enable</span></div>";
|
||||
|
||||
templates.docPickerNote = """
|
||||
<div class="_list-note">Tip: for faster and better search results, select only the docs you need.</div>
|
||||
<a href="https://trello.com/b/6BmTulfx/devdocs-documentation" class="_list-link" target="_blank" rel="noopener">Vote for new documentation</a>
|
||||
"""
|
||||
templates.docPickerNote = `\
|
||||
<div class="_list-note">Tip: for faster and better search results, select only the docs you need.</div>
|
||||
<a href="https://trello.com/b/6BmTulfx/devdocs-documentation" class="_list-link" target="_blank" rel="noopener">Vote for new documentation</a>\
|
||||
`;
|
||||
|
@ -1,10 +1,15 @@
|
||||
app.templates.tipKeyNav = () -> """
|
||||
<p class="_notif-text">
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.templates.tipKeyNav = () => `\
|
||||
<p class="_notif-text">
|
||||
<strong>ProTip</strong>
|
||||
<span class="_notif-info">(click to dismiss)</span>
|
||||
<p class="_notif-text">
|
||||
Hit #{if app.settings.get('arrowScroll') then '<code class="_label">shift</code> +' else ''} <code class="_label">↓</code> <code class="_label">↑</code> <code class="_label">←</code> <code class="_label">→</code> to navigate the sidebar.<br>
|
||||
Hit <code class="_label">space / shift space</code>#{if app.settings.get('arrowScroll') then ' or <code class="_label">↓/↑</code>' else ', <code class="_label">alt ↓/↑</code> or <code class="_label">shift ↓/↑</code>'} to scroll the page.
|
||||
<p class="_notif-text">
|
||||
<a href="/help#shortcuts" class="_notif-link">See all keyboard shortcuts</a>
|
||||
"""
|
||||
<p class="_notif-text">
|
||||
Hit ${app.settings.get('arrowScroll') ? '<code class="_label">shift</code> +' : ''} <code class="_label">↓</code> <code class="_label">↑</code> <code class="_label">←</code> <code class="_label">→</code> to navigate the sidebar.<br>
|
||||
Hit <code class="_label">space / shift space</code>${app.settings.get('arrowScroll') ? ' or <code class="_label">↓/↑</code>' : ', <code class="_label">alt ↓/↑</code> or <code class="_label">shift ↓/↑</code>'} to scroll the page.
|
||||
<p class="_notif-text">
|
||||
<a href="/help#shortcuts" class="_notif-link">See all keyboard shortcuts</a>\
|
||||
`;
|
||||
|
@ -1,195 +1,259 @@
|
||||
class app.views.Content extends app.View
|
||||
@el: '._content'
|
||||
@loadingClass: '_content-loading'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
@shortcuts:
|
||||
altUp: 'scrollStepUp'
|
||||
altDown: 'scrollStepDown'
|
||||
pageUp: 'scrollPageUp'
|
||||
pageDown: 'scrollPageDown'
|
||||
pageTop: 'scrollToTop'
|
||||
pageBottom: 'scrollToBottom'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS104: Avoid inline assignments
|
||||
* DS204: Change includes calls to have a more natural evaluation order
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Content = class Content extends app.View {
|
||||
constructor(...args) {
|
||||
this.scrollToTop = this.scrollToTop.bind(this);
|
||||
this.scrollToBottom = this.scrollToBottom.bind(this);
|
||||
this.scrollStepUp = this.scrollStepUp.bind(this);
|
||||
this.scrollStepDown = this.scrollStepDown.bind(this);
|
||||
this.scrollPageUp = this.scrollPageUp.bind(this);
|
||||
this.scrollPageDown = this.scrollPageDown.bind(this);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onBootError = this.onBootError.bind(this);
|
||||
this.onEntryLoading = this.onEntryLoading.bind(this);
|
||||
this.onEntryLoaded = this.onEntryLoaded.bind(this);
|
||||
this.beforeRoute = this.beforeRoute.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onAltF = this.onAltF.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.el = '._content';
|
||||
this.loadingClass = '_content-loading';
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
this.shortcuts = {
|
||||
altUp: 'scrollStepUp',
|
||||
altDown: 'scrollStepDown',
|
||||
pageUp: 'scrollPageUp',
|
||||
pageDown: 'scrollPageDown',
|
||||
pageTop: 'scrollToTop',
|
||||
pageBottom: 'scrollToBottom',
|
||||
altF: 'onAltF'
|
||||
};
|
||||
|
||||
@routes:
|
||||
before: 'beforeRoute'
|
||||
this.routes = {
|
||||
before: 'beforeRoute',
|
||||
after: 'afterRoute'
|
||||
};
|
||||
}
|
||||
|
||||
init: ->
|
||||
@scrollEl = if app.isMobile()
|
||||
init() {
|
||||
this.scrollEl = app.isMobile() ?
|
||||
(document.scrollingElement || document.body)
|
||||
else
|
||||
@el
|
||||
@scrollMap = {}
|
||||
@scrollStack = []
|
||||
|
||||
@rootPage = new app.views.RootPage
|
||||
@staticPage = new app.views.StaticPage
|
||||
@settingsPage = new app.views.SettingsPage
|
||||
@offlinePage = new app.views.OfflinePage
|
||||
@typePage = new app.views.TypePage
|
||||
@entryPage = new app.views.EntryPage
|
||||
|
||||
@entryPage
|
||||
.on 'loading', @onEntryLoading
|
||||
.on 'loaded', @onEntryLoaded
|
||||
:
|
||||
this.el;
|
||||
this.scrollMap = {};
|
||||
this.scrollStack = [];
|
||||
|
||||
this.rootPage = new app.views.RootPage;
|
||||
this.staticPage = new app.views.StaticPage;
|
||||
this.settingsPage = new app.views.SettingsPage;
|
||||
this.offlinePage = new app.views.OfflinePage;
|
||||
this.typePage = new app.views.TypePage;
|
||||
this.entryPage = new app.views.EntryPage;
|
||||
|
||||
this.entryPage
|
||||
.on('loading', this.onEntryLoading)
|
||||
.on('loaded', this.onEntryLoaded);
|
||||
|
||||
app
|
||||
.on 'ready', @onReady
|
||||
.on 'bootError', @onBootError
|
||||
|
||||
return
|
||||
|
||||
show: (view) ->
|
||||
@hideLoading()
|
||||
unless view is @view
|
||||
@view?.deactivate()
|
||||
@html @view = view
|
||||
@view.activate()
|
||||
return
|
||||
|
||||
showLoading: ->
|
||||
@addClass @constructor.loadingClass
|
||||
return
|
||||
|
||||
isLoading: ->
|
||||
@el.classList.contains @constructor.loadingClass
|
||||
|
||||
hideLoading: ->
|
||||
@removeClass @constructor.loadingClass
|
||||
return
|
||||
|
||||
scrollTo: (value) ->
|
||||
@scrollEl.scrollTop = value or 0
|
||||
return
|
||||
|
||||
smoothScrollTo: (value) ->
|
||||
if app.settings.get('fastScroll')
|
||||
@scrollTo value
|
||||
else
|
||||
$.smoothScroll @scrollEl, value or 0
|
||||
return
|
||||
|
||||
scrollBy: (n) ->
|
||||
@smoothScrollTo @scrollEl.scrollTop + n
|
||||
return
|
||||
|
||||
scrollToTop: =>
|
||||
@smoothScrollTo 0
|
||||
return
|
||||
|
||||
scrollToBottom: =>
|
||||
@smoothScrollTo @scrollEl.scrollHeight
|
||||
return
|
||||
|
||||
scrollStepUp: =>
|
||||
@scrollBy -80
|
||||
return
|
||||
|
||||
scrollStepDown: =>
|
||||
@scrollBy 80
|
||||
return
|
||||
|
||||
scrollPageUp: =>
|
||||
@scrollBy 40 - @scrollEl.clientHeight
|
||||
return
|
||||
|
||||
scrollPageDown: =>
|
||||
@scrollBy @scrollEl.clientHeight - 40
|
||||
return
|
||||
|
||||
scrollToTarget: ->
|
||||
if @routeCtx.hash and el = @findTargetByHash @routeCtx.hash
|
||||
$.scrollToWithImageLock el, @scrollEl, 'top',
|
||||
margin: if @scrollEl is @el then 0 else $.offset(@el).top
|
||||
$.highlight el, className: '_highlight'
|
||||
else
|
||||
@scrollTo @scrollMap[@routeCtx.state.id]
|
||||
return
|
||||
|
||||
onReady: =>
|
||||
@hideLoading()
|
||||
return
|
||||
|
||||
onBootError: =>
|
||||
@hideLoading()
|
||||
@html @tmpl('bootError')
|
||||
return
|
||||
|
||||
onEntryLoading: =>
|
||||
@showLoading()
|
||||
if @scrollToTargetTimeout
|
||||
clearTimeout @scrollToTargetTimeout
|
||||
@scrollToTargetTimeout = null
|
||||
return
|
||||
|
||||
onEntryLoaded: =>
|
||||
@hideLoading()
|
||||
if @scrollToTargetTimeout
|
||||
clearTimeout @scrollToTargetTimeout
|
||||
@scrollToTargetTimeout = null
|
||||
@scrollToTarget()
|
||||
return
|
||||
|
||||
beforeRoute: (context) =>
|
||||
@cacheScrollPosition()
|
||||
@routeCtx = context
|
||||
@scrollToTargetTimeout = @delay @scrollToTarget
|
||||
return
|
||||
|
||||
cacheScrollPosition: ->
|
||||
return if not @routeCtx or @routeCtx.hash
|
||||
return if @routeCtx.path is '/'
|
||||
|
||||
unless @scrollMap[@routeCtx.state.id]?
|
||||
@scrollStack.push @routeCtx.state.id
|
||||
while @scrollStack.length > app.config.history_cache_size
|
||||
delete @scrollMap[@scrollStack.shift()]
|
||||
|
||||
@scrollMap[@routeCtx.state.id] = @scrollEl.scrollTop
|
||||
return
|
||||
|
||||
afterRoute: (route, context) =>
|
||||
if route != 'entry' and route != 'type'
|
||||
resetFavicon()
|
||||
|
||||
switch route
|
||||
when 'root'
|
||||
@show @rootPage
|
||||
when 'entry'
|
||||
@show @entryPage
|
||||
when 'type'
|
||||
@show @typePage
|
||||
when 'settings'
|
||||
@show @settingsPage
|
||||
when 'offline'
|
||||
@show @offlinePage
|
||||
else
|
||||
@show @staticPage
|
||||
|
||||
@view.onRoute(context)
|
||||
app.document.setTitle @view.getTitle?()
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
link = $.closestLink $.eventTarget(event), @el
|
||||
if link and @isExternalUrl link.getAttribute('href')
|
||||
$.stopEvent(event)
|
||||
$.popup(link)
|
||||
return
|
||||
|
||||
onAltF: (event) =>
|
||||
unless document.activeElement and $.hasChild @el, document.activeElement
|
||||
@find('a:not(:empty)')?.focus()
|
||||
$.stopEvent(event)
|
||||
|
||||
findTargetByHash: (hash) ->
|
||||
el = try $.id decodeURIComponent(hash) catch
|
||||
el or= try $.id(hash) catch
|
||||
el
|
||||
|
||||
isExternalUrl: (url) ->
|
||||
url?[0..5] in ['http:/', 'https:']
|
||||
.on('ready', this.onReady)
|
||||
.on('bootError', this.onBootError);
|
||||
|
||||
}
|
||||
|
||||
show(view) {
|
||||
this.hideLoading();
|
||||
if (view !== this.view) {
|
||||
if (this.view != null) {
|
||||
this.view.deactivate();
|
||||
}
|
||||
this.html(this.view = view);
|
||||
this.view.activate();
|
||||
}
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.addClass(this.constructor.loadingClass);
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.el.classList.contains(this.constructor.loadingClass);
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
this.removeClass(this.constructor.loadingClass);
|
||||
}
|
||||
|
||||
scrollTo(value) {
|
||||
this.scrollEl.scrollTop = value || 0;
|
||||
}
|
||||
|
||||
smoothScrollTo(value) {
|
||||
if (app.settings.get('fastScroll')) {
|
||||
this.scrollTo(value);
|
||||
} else {
|
||||
$.smoothScroll(this.scrollEl, value || 0);
|
||||
}
|
||||
}
|
||||
|
||||
scrollBy(n) {
|
||||
this.smoothScrollTo(this.scrollEl.scrollTop + n);
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.smoothScrollTo(0);
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.smoothScrollTo(this.scrollEl.scrollHeight);
|
||||
}
|
||||
|
||||
scrollStepUp() {
|
||||
this.scrollBy(-80);
|
||||
}
|
||||
|
||||
scrollStepDown() {
|
||||
this.scrollBy(80);
|
||||
}
|
||||
|
||||
scrollPageUp() {
|
||||
this.scrollBy(40 - this.scrollEl.clientHeight);
|
||||
}
|
||||
|
||||
scrollPageDown() {
|
||||
this.scrollBy(this.scrollEl.clientHeight - 40);
|
||||
}
|
||||
|
||||
scrollToTarget() {
|
||||
let el;
|
||||
if (this.routeCtx.hash && (el = this.findTargetByHash(this.routeCtx.hash))) {
|
||||
$.scrollToWithImageLock(el, this.scrollEl, 'top',
|
||||
{margin: this.scrollEl === this.el ? 0 : $.offset(this.el).top});
|
||||
$.highlight(el, {className: '_highlight'});
|
||||
} else {
|
||||
this.scrollTo(this.scrollMap[this.routeCtx.state.id]);
|
||||
}
|
||||
}
|
||||
|
||||
onReady() {
|
||||
this.hideLoading();
|
||||
}
|
||||
|
||||
onBootError() {
|
||||
this.hideLoading();
|
||||
this.html(this.tmpl('bootError'));
|
||||
}
|
||||
|
||||
onEntryLoading() {
|
||||
this.showLoading();
|
||||
if (this.scrollToTargetTimeout) {
|
||||
clearTimeout(this.scrollToTargetTimeout);
|
||||
this.scrollToTargetTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
onEntryLoaded() {
|
||||
this.hideLoading();
|
||||
if (this.scrollToTargetTimeout) {
|
||||
clearTimeout(this.scrollToTargetTimeout);
|
||||
this.scrollToTargetTimeout = null;
|
||||
}
|
||||
this.scrollToTarget();
|
||||
}
|
||||
|
||||
beforeRoute(context) {
|
||||
this.cacheScrollPosition();
|
||||
this.routeCtx = context;
|
||||
this.scrollToTargetTimeout = this.delay(this.scrollToTarget);
|
||||
}
|
||||
|
||||
cacheScrollPosition() {
|
||||
if (!this.routeCtx || this.routeCtx.hash) { return; }
|
||||
if (this.routeCtx.path === '/') { return; }
|
||||
|
||||
if (this.scrollMap[this.routeCtx.state.id] == null) {
|
||||
this.scrollStack.push(this.routeCtx.state.id);
|
||||
while (this.scrollStack.length > app.config.history_cache_size) {
|
||||
delete this.scrollMap[this.scrollStack.shift()];
|
||||
}
|
||||
}
|
||||
|
||||
this.scrollMap[this.routeCtx.state.id] = this.scrollEl.scrollTop;
|
||||
}
|
||||
|
||||
afterRoute(route, context) {
|
||||
if ((route !== 'entry') && (route !== 'type')) {
|
||||
resetFavicon();
|
||||
}
|
||||
|
||||
switch (route) {
|
||||
case 'root':
|
||||
this.show(this.rootPage);
|
||||
break;
|
||||
case 'entry':
|
||||
this.show(this.entryPage);
|
||||
break;
|
||||
case 'type':
|
||||
this.show(this.typePage);
|
||||
break;
|
||||
case 'settings':
|
||||
this.show(this.settingsPage);
|
||||
break;
|
||||
case 'offline':
|
||||
this.show(this.offlinePage);
|
||||
break;
|
||||
default:
|
||||
this.show(this.staticPage);
|
||||
}
|
||||
|
||||
this.view.onRoute(context);
|
||||
app.document.setTitle(typeof this.view.getTitle === 'function' ? this.view.getTitle() : undefined);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const link = $.closestLink($.eventTarget(event), this.el);
|
||||
if (link && this.isExternalUrl(link.getAttribute('href'))) {
|
||||
$.stopEvent(event);
|
||||
$.popup(link);
|
||||
}
|
||||
}
|
||||
|
||||
onAltF(event) {
|
||||
if (!document.activeElement || !$.hasChild(this.el, document.activeElement)) {
|
||||
__guard__(this.find('a:not(:empty)'), x => x.focus());
|
||||
return $.stopEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
findTargetByHash(hash) {
|
||||
let el = (() => { try { return $.id(decodeURIComponent(hash)); } catch (error) {} })();
|
||||
if (!el) { el = (() => { try { return $.id(hash); } catch (error1) {} })(); }
|
||||
return el;
|
||||
}
|
||||
|
||||
isExternalUrl(url) {
|
||||
let needle;
|
||||
return (needle = __guard__(url, x => x.slice(0, 6)), ['http:/', 'https:'].includes(needle));
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,166 +1,225 @@
|
||||
class app.views.EntryPage extends app.View
|
||||
@className: '_page'
|
||||
@errorClass: '_page-error'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
@shortcuts:
|
||||
altC: 'onAltC'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let LINKS = undefined;
|
||||
const Cls = (app.views.EntryPage = class EntryPage extends app.View {
|
||||
constructor(...args) {
|
||||
this.beforeRoute = this.beforeRoute.bind(this);
|
||||
this.onSuccess = this.onSuccess.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onAltC = this.onAltC.bind(this);
|
||||
this.onAltO = this.onAltO.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.className = '_page';
|
||||
this.errorClass = '_page-error';
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
this.shortcuts = {
|
||||
altC: 'onAltC',
|
||||
altO: 'onAltO'
|
||||
};
|
||||
|
||||
@routes:
|
||||
before: 'beforeRoute'
|
||||
|
||||
init: ->
|
||||
@cacheMap = {}
|
||||
@cacheStack = []
|
||||
return
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
@entry = null
|
||||
return
|
||||
|
||||
loading: ->
|
||||
@empty()
|
||||
@trigger 'loading'
|
||||
return
|
||||
|
||||
render: (content = '', fromCache = false) ->
|
||||
return unless @activated
|
||||
@empty()
|
||||
@subview = new (@subViewClass()) @el, @entry
|
||||
|
||||
$.batchUpdate @el, =>
|
||||
@subview.render(content, fromCache)
|
||||
@addCopyButtons() unless fromCache
|
||||
return
|
||||
|
||||
if app.disabledDocs.findBy 'slug', @entry.doc.slug
|
||||
@hiddenView = new app.views.HiddenPage @el, @entry
|
||||
|
||||
setFaviconForDoc(@entry.doc)
|
||||
@delay @polyfillMathML
|
||||
@trigger 'loaded'
|
||||
return
|
||||
|
||||
addCopyButtons: ->
|
||||
unless @copyButton
|
||||
@copyButton = document.createElement('button')
|
||||
@copyButton.innerHTML = '<svg><use xlink:href="#icon-copy"/></svg>'
|
||||
@copyButton.type = 'button'
|
||||
@copyButton.className = '_pre-clip'
|
||||
@copyButton.title = 'Copy to clipboard'
|
||||
@copyButton.setAttribute 'aria-label', 'Copy to clipboard'
|
||||
el.appendChild @copyButton.cloneNode(true) for el in @findAllByTag('pre')
|
||||
return
|
||||
|
||||
polyfillMathML: ->
|
||||
return unless window.supportsMathML is false and !@polyfilledMathML and @findByTag('math')
|
||||
@polyfilledMathML = true
|
||||
$.append document.head, """<link rel="stylesheet" href="#{app.config.mathml_stylesheet}">"""
|
||||
return
|
||||
|
||||
LINKS =
|
||||
home: 'Homepage'
|
||||
code: 'Source code'
|
||||
this.routes =
|
||||
{before: 'beforeRoute'};
|
||||
|
||||
prepareContent: (content) ->
|
||||
return content unless @entry.isIndex() and @entry.doc.links
|
||||
|
||||
links = for link, url of @entry.doc.links
|
||||
"""<a href="#{url}" class="_links-link">#{LINKS[link]}</a>"""
|
||||
|
||||
"""<p class="_links">#{links.join('')}</p>#{content}"""
|
||||
|
||||
empty: ->
|
||||
@subview?.deactivate()
|
||||
@subview = null
|
||||
|
||||
@hiddenView?.deactivate()
|
||||
@hiddenView = null
|
||||
|
||||
@resetClass()
|
||||
super
|
||||
return
|
||||
|
||||
subViewClass: ->
|
||||
app.views["#{$.classify(@entry.doc.type)}Page"] or app.views.BasePage
|
||||
|
||||
getTitle: ->
|
||||
@entry.doc.fullName + if @entry.isIndex() then ' documentation' else " / #{@entry.name}"
|
||||
|
||||
beforeRoute: =>
|
||||
@cache()
|
||||
@abort()
|
||||
return
|
||||
|
||||
onRoute: (context) ->
|
||||
isSameFile = context.entry.filePath() is @entry?.filePath()
|
||||
@entry = context.entry
|
||||
@restore() or @load() unless isSameFile
|
||||
return
|
||||
|
||||
load: ->
|
||||
@loading()
|
||||
@xhr = @entry.loadFile @onSuccess, @onError
|
||||
return
|
||||
|
||||
abort: ->
|
||||
if @xhr
|
||||
@xhr.abort()
|
||||
@xhr = @entry = null
|
||||
return
|
||||
|
||||
onSuccess: (response) =>
|
||||
return unless @activated
|
||||
@xhr = null
|
||||
@render @prepareContent(response)
|
||||
return
|
||||
|
||||
onError: =>
|
||||
@xhr = null
|
||||
@render @tmpl('pageLoadError')
|
||||
@resetClass()
|
||||
@addClass @constructor.errorClass
|
||||
app.serviceWorker?.update()
|
||||
return
|
||||
|
||||
cache: ->
|
||||
return if @xhr or not @entry or @cacheMap[path = @entry.filePath()]
|
||||
|
||||
@cacheMap[path] = @el.innerHTML
|
||||
@cacheStack.push(path)
|
||||
|
||||
while @cacheStack.length > app.config.history_cache_size
|
||||
delete @cacheMap[@cacheStack.shift()]
|
||||
return
|
||||
|
||||
restore: ->
|
||||
if @cacheMap[path = @entry.filePath()]
|
||||
@render @cacheMap[path], true
|
||||
true
|
||||
|
||||
onClick: (event) =>
|
||||
target = $.eventTarget(event)
|
||||
if target.hasAttribute 'data-retry'
|
||||
$.stopEvent(event)
|
||||
@load()
|
||||
else if target.classList.contains '_pre-clip'
|
||||
$.stopEvent(event)
|
||||
target.classList.add if $.copyToClipboard(target.parentNode.textContent) then '_pre-clip-success' else '_pre-clip-error'
|
||||
setTimeout (-> target.className = '_pre-clip'), 2000
|
||||
return
|
||||
|
||||
onAltC: =>
|
||||
return unless link = @find('._attribution:last-child ._attribution-link')
|
||||
console.log(link.href + location.hash)
|
||||
navigator.clipboard.writeText(link.href + location.hash)
|
||||
return
|
||||
|
||||
onAltO: =>
|
||||
return unless link = @find('._attribution:last-child ._attribution-link')
|
||||
@delay -> $.popup(link.href + location.hash)
|
||||
return
|
||||
LINKS = {
|
||||
home: 'Homepage',
|
||||
code: 'Source code'
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.cacheMap = {};
|
||||
this.cacheStack = [];
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
this.entry = null;
|
||||
}
|
||||
}
|
||||
|
||||
loading() {
|
||||
this.empty();
|
||||
this.trigger('loading');
|
||||
}
|
||||
|
||||
render(content, fromCache) {
|
||||
if (content == null) { content = ''; }
|
||||
if (fromCache == null) { fromCache = false; }
|
||||
if (!this.activated) { return; }
|
||||
this.empty();
|
||||
this.subview = new (this.subViewClass())(this.el, this.entry);
|
||||
|
||||
$.batchUpdate(this.el, () => {
|
||||
this.subview.render(content, fromCache);
|
||||
if (!fromCache) { this.addCopyButtons(); }
|
||||
});
|
||||
|
||||
if (app.disabledDocs.findBy('slug', this.entry.doc.slug)) {
|
||||
this.hiddenView = new app.views.HiddenPage(this.el, this.entry);
|
||||
}
|
||||
|
||||
setFaviconForDoc(this.entry.doc);
|
||||
this.delay(this.polyfillMathML);
|
||||
this.trigger('loaded');
|
||||
}
|
||||
|
||||
addCopyButtons() {
|
||||
if (!this.copyButton) {
|
||||
this.copyButton = document.createElement('button');
|
||||
this.copyButton.innerHTML = '<svg><use xlink:href="#icon-copy"/></svg>';
|
||||
this.copyButton.type = 'button';
|
||||
this.copyButton.className = '_pre-clip';
|
||||
this.copyButton.title = 'Copy to clipboard';
|
||||
this.copyButton.setAttribute('aria-label', 'Copy to clipboard');
|
||||
}
|
||||
for (var el of Array.from(this.findAllByTag('pre'))) { el.appendChild(this.copyButton.cloneNode(true)); }
|
||||
}
|
||||
|
||||
polyfillMathML() {
|
||||
if ((window.supportsMathML !== false) || !!this.polyfilledMathML || !this.findByTag('math')) { return; }
|
||||
this.polyfilledMathML = true;
|
||||
$.append(document.head, `<link rel="stylesheet" href="${app.config.mathml_stylesheet}">`);
|
||||
}
|
||||
|
||||
prepareContent(content) {
|
||||
if (!this.entry.isIndex() || !this.entry.doc.links) { return content; }
|
||||
|
||||
const links = (() => {
|
||||
const result = [];
|
||||
for (var link in this.entry.doc.links) {
|
||||
var url = this.entry.doc.links[link];
|
||||
result.push(`<a href="${url}" class="_links-link">${LINKS[link]}</a>`);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
return `<p class="_links">${links.join('')}</p>${content}`;
|
||||
}
|
||||
|
||||
empty() {
|
||||
if (this.subview != null) {
|
||||
this.subview.deactivate();
|
||||
}
|
||||
this.subview = null;
|
||||
|
||||
if (this.hiddenView != null) {
|
||||
this.hiddenView.deactivate();
|
||||
}
|
||||
this.hiddenView = null;
|
||||
|
||||
this.resetClass();
|
||||
super.empty(...arguments);
|
||||
}
|
||||
|
||||
subViewClass() {
|
||||
return app.views[`${$.classify(this.entry.doc.type)}Page`] || app.views.BasePage;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return this.entry.doc.fullName + (this.entry.isIndex() ? ' documentation' : ` / ${this.entry.name}`);
|
||||
}
|
||||
|
||||
beforeRoute() {
|
||||
this.cache();
|
||||
this.abort();
|
||||
}
|
||||
|
||||
onRoute(context) {
|
||||
const isSameFile = context.entry.filePath() === (this.entry != null ? this.entry.filePath() : undefined);
|
||||
this.entry = context.entry;
|
||||
if (!isSameFile) { this.restore() || this.load(); }
|
||||
}
|
||||
|
||||
load() {
|
||||
this.loading();
|
||||
this.xhr = this.entry.loadFile(this.onSuccess, this.onError);
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort();
|
||||
this.xhr = (this.entry = null);
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess(response) {
|
||||
if (!this.activated) { return; }
|
||||
this.xhr = null;
|
||||
this.render(this.prepareContent(response));
|
||||
}
|
||||
|
||||
onError() {
|
||||
this.xhr = null;
|
||||
this.render(this.tmpl('pageLoadError'));
|
||||
this.resetClass();
|
||||
this.addClass(this.constructor.errorClass);
|
||||
if (app.serviceWorker != null) {
|
||||
app.serviceWorker.update();
|
||||
}
|
||||
}
|
||||
|
||||
cache() {
|
||||
let path;
|
||||
if (this.xhr || !this.entry || this.cacheMap[(path = this.entry.filePath())]) { return; }
|
||||
|
||||
this.cacheMap[path] = this.el.innerHTML;
|
||||
this.cacheStack.push(path);
|
||||
|
||||
while (this.cacheStack.length > app.config.history_cache_size) {
|
||||
delete this.cacheMap[this.cacheStack.shift()];
|
||||
}
|
||||
}
|
||||
|
||||
restore() {
|
||||
let path;
|
||||
if (this.cacheMap[(path = this.entry.filePath())]) {
|
||||
this.render(this.cacheMap[path], true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const target = $.eventTarget(event);
|
||||
if (target.hasAttribute('data-retry')) {
|
||||
$.stopEvent(event);
|
||||
this.load();
|
||||
} else if (target.classList.contains('_pre-clip')) {
|
||||
$.stopEvent(event);
|
||||
target.classList.add($.copyToClipboard(target.parentNode.textContent) ? '_pre-clip-success' : '_pre-clip-error');
|
||||
setTimeout((() => target.className = '_pre-clip'), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
onAltC() {
|
||||
let link;
|
||||
if (!(link = this.find('._attribution:last-child ._attribution-link'))) { return; }
|
||||
console.log(link.href + location.hash);
|
||||
navigator.clipboard.writeText(link.href + location.hash);
|
||||
}
|
||||
|
||||
onAltO() {
|
||||
let link;
|
||||
if (!(link = this.find('._attribution:last-child ._attribution-link'))) { return; }
|
||||
this.delay(() => $.popup(link.href + location.hash));
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,92 +1,128 @@
|
||||
class app.views.OfflinePage extends app.View
|
||||
@className: '_static'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.OfflinePage = class OfflinePage extends app.View {
|
||||
constructor(...args) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
static initClass() {
|
||||
this.className = '_static';
|
||||
|
||||
this.events = {
|
||||
click: 'onClick',
|
||||
change: 'onChange'
|
||||
};
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (app.cookieBlocked) {
|
||||
this.html(this.tmpl('offlineError', 'cookie_blocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
app.docs.getInstallStatuses(statuses => {
|
||||
if (!this.activated) { return; }
|
||||
if (statuses === false) {
|
||||
this.html(this.tmpl('offlineError', app.db.reason, app.db.error));
|
||||
} else {
|
||||
let html = '';
|
||||
for (var doc of Array.from(app.docs.all())) { html += this.renderDoc(doc, statuses[doc.slug]); }
|
||||
this.html(this.tmpl('offlinePage', html));
|
||||
this.refreshLinks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderDoc(doc, status) {
|
||||
return app.templates.render('offlineDoc', doc, status);
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return 'Offline';
|
||||
}
|
||||
|
||||
refreshLinks() {
|
||||
for (var action of ['install', 'update', 'uninstall']) {
|
||||
this.find(`[data-action-all='${action}']`).classList[this.find(`[data-action='${action}']`) ? 'add' : 'remove']('_show');
|
||||
}
|
||||
}
|
||||
|
||||
docByEl(el) {
|
||||
let slug;
|
||||
while (!(slug = el.getAttribute('data-slug'))) { el = el.parentNode; }
|
||||
return app.docs.findBy('slug', slug);
|
||||
}
|
||||
|
||||
docEl(doc) {
|
||||
return this.find(`[data-slug='${doc.slug}']`);
|
||||
}
|
||||
|
||||
onRoute(context) {
|
||||
this.render();
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
let action;
|
||||
let el = $.eventTarget(event);
|
||||
if (action = el.getAttribute('data-action')) {
|
||||
const doc = this.docByEl(el);
|
||||
if (action === 'update') { action = 'install'; }
|
||||
doc[action](this.onInstallSuccess.bind(this, doc), this.onInstallError.bind(this, doc), this.onInstallProgress.bind(this, doc));
|
||||
el.parentNode.innerHTML = `${el.textContent.replace(/e$/, '')}ing…`;
|
||||
} else if (action = el.getAttribute('data-action-all') || el.parentElement.getAttribute('data-action-all')) {
|
||||
if ((action === 'uninstall') && !window.confirm('Uninstall all docs?')) { return; }
|
||||
app.db.migrate();
|
||||
for (el of Array.from(this.findAll(`[data-action='${action}']`))) { $.click(el); }
|
||||
}
|
||||
}
|
||||
|
||||
onInstallSuccess(doc) {
|
||||
if (!this.activated) { return; }
|
||||
doc.getInstallStatus(status => {
|
||||
let el;
|
||||
if (!this.activated) { return; }
|
||||
if (el = this.docEl(doc)) {
|
||||
el.outerHTML = this.renderDoc(doc, status);
|
||||
$.highlight(el, {className: '_highlight'});
|
||||
this.refreshLinks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onInstallError(doc) {
|
||||
let el;
|
||||
if (!this.activated) { return; }
|
||||
if (el = this.docEl(doc)) {
|
||||
el.lastElementChild.textContent = 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
onInstallProgress(doc, event) {
|
||||
let el;
|
||||
if (!this.activated || !event.lengthComputable) { return; }
|
||||
if (el = this.docEl(doc)) {
|
||||
const percentage = Math.round((event.loaded * 100) / event.total);
|
||||
el.lastElementChild.textContent = el.lastElementChild.textContent.replace(/(\s.+)?$/, ` (${percentage}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
return
|
||||
|
||||
render: ->
|
||||
if app.cookieBlocked
|
||||
@html @tmpl('offlineError', 'cookie_blocked')
|
||||
return
|
||||
|
||||
app.docs.getInstallStatuses (statuses) =>
|
||||
return unless @activated
|
||||
if statuses is false
|
||||
@html @tmpl('offlineError', app.db.reason, app.db.error)
|
||||
else
|
||||
html = ''
|
||||
html += @renderDoc(doc, statuses[doc.slug]) for doc in app.docs.all()
|
||||
@html @tmpl('offlinePage', html)
|
||||
@refreshLinks()
|
||||
return
|
||||
return
|
||||
|
||||
renderDoc: (doc, status) ->
|
||||
app.templates.render('offlineDoc', doc, status)
|
||||
|
||||
getTitle: ->
|
||||
'Offline'
|
||||
|
||||
refreshLinks: ->
|
||||
for action in ['install', 'update', 'uninstall']
|
||||
@find("[data-action-all='#{action}']").classList[if @find("[data-action='#{action}']") then 'add' else 'remove']('_show')
|
||||
return
|
||||
|
||||
docByEl: (el) ->
|
||||
el = el.parentNode until slug = el.getAttribute('data-slug')
|
||||
app.docs.findBy('slug', slug)
|
||||
|
||||
docEl: (doc) ->
|
||||
@find("[data-slug='#{doc.slug}']")
|
||||
|
||||
onRoute: (context) ->
|
||||
@render()
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
el = $.eventTarget(event)
|
||||
if action = el.getAttribute('data-action')
|
||||
doc = @docByEl(el)
|
||||
action = 'install' if action is 'update'
|
||||
doc[action](@onInstallSuccess.bind(@, doc), @onInstallError.bind(@, doc), @onInstallProgress.bind(@, doc))
|
||||
el.parentNode.innerHTML = "#{el.textContent.replace(/e$/, '')}ing…"
|
||||
else if action = el.getAttribute('data-action-all') || el.parentElement.getAttribute('data-action-all')
|
||||
return unless action isnt 'uninstall' or window.confirm('Uninstall all docs?')
|
||||
app.db.migrate()
|
||||
$.click(el) for el in @findAll("[data-action='#{action}']")
|
||||
return
|
||||
|
||||
onInstallSuccess: (doc) ->
|
||||
return unless @activated
|
||||
doc.getInstallStatus (status) =>
|
||||
return unless @activated
|
||||
if el = @docEl(doc)
|
||||
el.outerHTML = @renderDoc(doc, status)
|
||||
$.highlight el, className: '_highlight'
|
||||
@refreshLinks()
|
||||
return
|
||||
return
|
||||
|
||||
onInstallError: (doc) ->
|
||||
return unless @activated
|
||||
if el = @docEl(doc)
|
||||
el.lastElementChild.textContent = 'Error'
|
||||
return
|
||||
|
||||
onInstallProgress: (doc, event) ->
|
||||
return unless @activated and event.lengthComputable
|
||||
if el = @docEl(doc)
|
||||
percentage = Math.round event.loaded * 100 / event.total
|
||||
el.lastElementChild.textContent = el.lastElementChild.textContent.replace(/(\s.+)?$/, " (#{percentage}%)")
|
||||
return
|
||||
|
||||
onChange: (event) ->
|
||||
if event.target.name is 'autoUpdate'
|
||||
app.settings.set 'manualUpdate', !event.target.checked
|
||||
return
|
||||
onChange(event) {
|
||||
if (event.target.name === 'autoUpdate') {
|
||||
app.settings.set('manualUpdate', !event.target.checked);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,43 +1,61 @@
|
||||
class app.views.RootPage extends app.View
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
init: ->
|
||||
@setHidden false unless @isHidden() # reserve space in local storage
|
||||
@render()
|
||||
return
|
||||
|
||||
render: ->
|
||||
@empty()
|
||||
|
||||
tmpl = if app.isAndroidWebview()
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.RootPage = class RootPage extends app.View {
|
||||
constructor(...args) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.isHidden()) { this.setHidden(false); } // reserve space in local storage
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.empty();
|
||||
|
||||
const tmpl = app.isAndroidWebview() ?
|
||||
'androidWarning'
|
||||
else if @isHidden()
|
||||
: this.isHidden() ?
|
||||
'splash'
|
||||
else if app.isMobile()
|
||||
: app.isMobile() ?
|
||||
'mobileIntro'
|
||||
else
|
||||
'intro'
|
||||
|
||||
@append @tmpl(tmpl)
|
||||
return
|
||||
|
||||
hideIntro: ->
|
||||
@setHidden true
|
||||
@render()
|
||||
return
|
||||
|
||||
setHidden: (value) ->
|
||||
app.settings.set 'hideIntro', value
|
||||
return
|
||||
|
||||
isHidden: ->
|
||||
app.isSingleDoc() or app.settings.get 'hideIntro'
|
||||
|
||||
onRoute: ->
|
||||
|
||||
onClick: (event) =>
|
||||
if $.eventTarget(event).hasAttribute 'data-hide-intro'
|
||||
$.stopEvent(event)
|
||||
@hideIntro()
|
||||
return
|
||||
:
|
||||
'intro';
|
||||
|
||||
this.append(this.tmpl(tmpl));
|
||||
}
|
||||
|
||||
hideIntro() {
|
||||
this.setHidden(true);
|
||||
this.render();
|
||||
}
|
||||
|
||||
setHidden(value) {
|
||||
app.settings.set('hideIntro', value);
|
||||
}
|
||||
|
||||
isHidden() {
|
||||
return app.isSingleDoc() || app.settings.get('hideIntro');
|
||||
}
|
||||
|
||||
onRoute() {}
|
||||
|
||||
onClick(event) {
|
||||
if ($.eventTarget(event).hasAttribute('data-hide-intro')) {
|
||||
$.stopEvent(event);
|
||||
this.hideIntro();
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,116 +1,151 @@
|
||||
class app.views.SettingsPage extends app.View
|
||||
@className: '_static'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.SettingsPage = class SettingsPage extends app.View {
|
||||
constructor(...args) {
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.className = '_static';
|
||||
|
||||
this.events = {
|
||||
click: 'onClick',
|
||||
change: 'onChange'
|
||||
|
||||
render: ->
|
||||
@html @tmpl('settingsPage', @currentSettings())
|
||||
return
|
||||
|
||||
currentSettings: ->
|
||||
settings = {}
|
||||
settings.theme = app.settings.get('theme')
|
||||
settings.smoothScroll = !app.settings.get('fastScroll')
|
||||
settings.arrowScroll = app.settings.get('arrowScroll')
|
||||
settings.noAutofocus = app.settings.get('noAutofocus')
|
||||
settings.autoInstall = app.settings.get('autoInstall')
|
||||
settings.analyticsConsent = app.settings.get('analyticsConsent')
|
||||
settings.spaceScroll = app.settings.get('spaceScroll')
|
||||
settings.spaceTimeout = app.settings.get('spaceTimeout')
|
||||
settings.autoSupported = app.settings.autoSupported
|
||||
settings[layout] = app.settings.hasLayout(layout) for layout in app.settings.LAYOUTS
|
||||
settings
|
||||
|
||||
getTitle: ->
|
||||
'Preferences'
|
||||
|
||||
setTheme: (value) ->
|
||||
app.settings.set('theme', value)
|
||||
return
|
||||
|
||||
toggleLayout: (layout, enable) ->
|
||||
app.settings.setLayout(layout, enable)
|
||||
return
|
||||
|
||||
toggleSmoothScroll: (enable) ->
|
||||
app.settings.set('fastScroll', !enable)
|
||||
return
|
||||
|
||||
toggleAnalyticsConsent: (enable) ->
|
||||
app.settings.set('analyticsConsent', if enable then '1' else '0')
|
||||
resetAnalytics() unless enable
|
||||
return
|
||||
|
||||
toggleSpaceScroll: (enable) ->
|
||||
app.settings.set('spaceScroll', if enable then 1 else 0)
|
||||
return
|
||||
|
||||
setScrollTimeout: (value) ->
|
||||
app.settings.set('spaceTimeout', value)
|
||||
|
||||
toggle: (name, enable) ->
|
||||
app.settings.set(name, enable)
|
||||
return
|
||||
|
||||
export: ->
|
||||
data = new Blob([JSON.stringify(app.settings.export())], type: 'application/json')
|
||||
link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(data)
|
||||
link.download = 'devdocs.json'
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
return
|
||||
|
||||
import: (file, input) ->
|
||||
unless file and file.type is 'application/json'
|
||||
new app.views.Notif 'ImportInvalid', autoHide: false
|
||||
return
|
||||
|
||||
reader = new FileReader()
|
||||
reader.onloadend = ->
|
||||
data = try JSON.parse(reader.result)
|
||||
unless data and data.constructor is Object
|
||||
new app.views.Notif 'ImportInvalid', autoHide: false
|
||||
return
|
||||
app.settings.import(data)
|
||||
$.trigger input.form, 'import'
|
||||
return
|
||||
reader.readAsText(file)
|
||||
return
|
||||
|
||||
onChange: (event) =>
|
||||
input = event.target
|
||||
switch input.name
|
||||
when 'theme'
|
||||
@setTheme input.value
|
||||
when 'layout'
|
||||
@toggleLayout input.value, input.checked
|
||||
when 'smoothScroll'
|
||||
@toggleSmoothScroll input.checked
|
||||
when 'import'
|
||||
@import input.files[0], input
|
||||
when 'analyticsConsent'
|
||||
@toggleAnalyticsConsent input.checked
|
||||
when 'spaceScroll'
|
||||
@toggleSpaceScroll input.checked
|
||||
when 'spaceTimeout'
|
||||
@setScrollTimeout input.value
|
||||
else
|
||||
@toggle input.name, input.checked
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
target = $.eventTarget(event)
|
||||
switch target.getAttribute('data-action')
|
||||
when 'export'
|
||||
$.stopEvent(event)
|
||||
@export()
|
||||
return
|
||||
|
||||
onRoute: (context) ->
|
||||
@render()
|
||||
return
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
this.html(this.tmpl('settingsPage', this.currentSettings()));
|
||||
}
|
||||
|
||||
currentSettings() {
|
||||
const settings = {};
|
||||
settings.theme = app.settings.get('theme');
|
||||
settings.smoothScroll = !app.settings.get('fastScroll');
|
||||
settings.arrowScroll = app.settings.get('arrowScroll');
|
||||
settings.noAutofocus = app.settings.get('noAutofocus');
|
||||
settings.autoInstall = app.settings.get('autoInstall');
|
||||
settings.analyticsConsent = app.settings.get('analyticsConsent');
|
||||
settings.spaceScroll = app.settings.get('spaceScroll');
|
||||
settings.spaceTimeout = app.settings.get('spaceTimeout');
|
||||
settings.autoSupported = app.settings.autoSupported;
|
||||
for (var layout of Array.from(app.settings.LAYOUTS)) { settings[layout] = app.settings.hasLayout(layout); }
|
||||
return settings;
|
||||
}
|
||||
|
||||
getTitle() {
|
||||
return 'Preferences';
|
||||
}
|
||||
|
||||
setTheme(value) {
|
||||
app.settings.set('theme', value);
|
||||
}
|
||||
|
||||
toggleLayout(layout, enable) {
|
||||
app.settings.setLayout(layout, enable);
|
||||
}
|
||||
|
||||
toggleSmoothScroll(enable) {
|
||||
app.settings.set('fastScroll', !enable);
|
||||
}
|
||||
|
||||
toggleAnalyticsConsent(enable) {
|
||||
app.settings.set('analyticsConsent', enable ? '1' : '0');
|
||||
if (!enable) { resetAnalytics(); }
|
||||
}
|
||||
|
||||
toggleSpaceScroll(enable) {
|
||||
app.settings.set('spaceScroll', enable ? 1 : 0);
|
||||
}
|
||||
|
||||
setScrollTimeout(value) {
|
||||
return app.settings.set('spaceTimeout', value);
|
||||
}
|
||||
|
||||
toggle(name, enable) {
|
||||
app.settings.set(name, enable);
|
||||
}
|
||||
|
||||
export() {
|
||||
const data = new Blob([JSON.stringify(app.settings.export())], {type: 'application/json'});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(data);
|
||||
link.download = 'devdocs.json';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
import(file, input) {
|
||||
if (!file || (file.type !== 'application/json')) {
|
||||
new app.views.Notif('ImportInvalid', {autoHide: false});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function() {
|
||||
const data = (() => { try { return JSON.parse(reader.result); } catch (error) {} })();
|
||||
if (!data || (data.constructor !== Object)) {
|
||||
new app.views.Notif('ImportInvalid', {autoHide: false});
|
||||
return;
|
||||
}
|
||||
app.settings.import(data);
|
||||
$.trigger(input.form, 'import');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
const input = event.target;
|
||||
switch (input.name) {
|
||||
case 'theme':
|
||||
this.setTheme(input.value);
|
||||
break;
|
||||
case 'layout':
|
||||
this.toggleLayout(input.value, input.checked);
|
||||
break;
|
||||
case 'smoothScroll':
|
||||
this.toggleSmoothScroll(input.checked);
|
||||
break;
|
||||
case 'import':
|
||||
this.import(input.files[0], input);
|
||||
break;
|
||||
case 'analyticsConsent':
|
||||
this.toggleAnalyticsConsent(input.checked);
|
||||
break;
|
||||
case 'spaceScroll':
|
||||
this.toggleSpaceScroll(input.checked);
|
||||
break;
|
||||
case 'spaceTimeout':
|
||||
this.setScrollTimeout(input.value);
|
||||
break;
|
||||
default:
|
||||
this.toggle(input.name, input.checked);
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const target = $.eventTarget(event);
|
||||
switch (target.getAttribute('data-action')) {
|
||||
case 'export':
|
||||
$.stopEvent(event);
|
||||
this.export();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onRoute(context) {
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,26 +1,39 @@
|
||||
class app.views.StaticPage extends app.View
|
||||
@className: '_static'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.StaticPage = class StaticPage extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_static';
|
||||
|
||||
@titles:
|
||||
about: 'About'
|
||||
news: 'News'
|
||||
help: 'User Guide'
|
||||
this.titles = {
|
||||
about: 'About',
|
||||
news: 'News',
|
||||
help: 'User Guide',
|
||||
notFound: '404'
|
||||
};
|
||||
}
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
@page = null
|
||||
return
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
this.page = null;
|
||||
}
|
||||
}
|
||||
|
||||
render: (page) ->
|
||||
@page = page
|
||||
@html @tmpl("#{@page}Page")
|
||||
return
|
||||
render(page) {
|
||||
this.page = page;
|
||||
this.html(this.tmpl(`${this.page}Page`));
|
||||
}
|
||||
|
||||
getTitle: ->
|
||||
@constructor.titles[@page]
|
||||
getTitle() {
|
||||
return this.constructor.titles[this.page];
|
||||
}
|
||||
|
||||
onRoute: (context) ->
|
||||
@render context.page or 'notFound'
|
||||
return
|
||||
onRoute(context) {
|
||||
this.render(context.page || 'notFound');
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,20 +1,33 @@
|
||||
class app.views.TypePage extends app.View
|
||||
@className: '_page'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.TypePage = class TypePage extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_page';
|
||||
}
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
@type = null
|
||||
return
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
this.type = null;
|
||||
}
|
||||
}
|
||||
|
||||
render: (@type) ->
|
||||
@html @tmpl('typePage', @type)
|
||||
setFaviconForDoc(@type.doc)
|
||||
return
|
||||
render(type) {
|
||||
this.type = type;
|
||||
this.html(this.tmpl('typePage', this.type));
|
||||
setFaviconForDoc(this.type.doc);
|
||||
}
|
||||
|
||||
getTitle: ->
|
||||
"#{@type.doc.fullName} / #{@type.name}"
|
||||
getTitle() {
|
||||
return `${this.type.doc.fullName} / ${this.type.name}`;
|
||||
}
|
||||
|
||||
onRoute: (context) ->
|
||||
@render context.type
|
||||
return
|
||||
onRoute(context) {
|
||||
this.render(context.type);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,85 +1,111 @@
|
||||
class app.views.Document extends app.View
|
||||
@el: document
|
||||
|
||||
@events:
|
||||
visibilitychange: 'onVisibilityChange'
|
||||
|
||||
@shortcuts:
|
||||
help: 'onHelp'
|
||||
preferences: 'onPreferences'
|
||||
escape: 'onEscape'
|
||||
superLeft: 'onBack'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Document = class Document extends app.View {
|
||||
constructor(...args) {
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.el = document;
|
||||
|
||||
this.events =
|
||||
{visibilitychange: 'onVisibilityChange'};
|
||||
|
||||
this.shortcuts = {
|
||||
help: 'onHelp',
|
||||
preferences: 'onPreferences',
|
||||
escape: 'onEscape',
|
||||
superLeft: 'onBack',
|
||||
superRight: 'onForward'
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
|
||||
init: ->
|
||||
@addSubview @menu = new app.views.Menu,
|
||||
@addSubview @sidebar = new app.views.Sidebar
|
||||
@addSubview @resizer = new app.views.Resizer if app.views.Resizer.isSupported()
|
||||
@addSubview @content = new app.views.Content
|
||||
@addSubview @path = new app.views.Path unless app.isSingleDoc() or app.isMobile()
|
||||
@settings = new app.views.Settings unless app.isSingleDoc()
|
||||
|
||||
$.on document.body, 'click', @onClick
|
||||
|
||||
@activate()
|
||||
return
|
||||
|
||||
setTitle: (title) ->
|
||||
@el.title = if title then "#{title} — DevDocs" else 'DevDocs API Documentation'
|
||||
|
||||
afterRoute: (route) =>
|
||||
if route is 'settings'
|
||||
@settings?.activate()
|
||||
else
|
||||
@settings?.deactivate()
|
||||
return
|
||||
|
||||
onVisibilityChange: =>
|
||||
return unless @el.visibilityState is 'visible'
|
||||
@delay ->
|
||||
location.reload() if app.isMobile() isnt app.views.Mobile.detect()
|
||||
return
|
||||
, 300
|
||||
return
|
||||
|
||||
onHelp: ->
|
||||
app.router.show '/help#shortcuts'
|
||||
return
|
||||
|
||||
onPreferences: ->
|
||||
app.router.show '/settings'
|
||||
return
|
||||
|
||||
onEscape: ->
|
||||
path = if !app.isSingleDoc() or location.pathname is app.doc.fullPath()
|
||||
};
|
||||
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addSubview((this.menu = new app.views.Menu),
|
||||
this.addSubview(this.sidebar = new app.views.Sidebar));
|
||||
if (app.views.Resizer.isSupported()) { this.addSubview(this.resizer = new app.views.Resizer); }
|
||||
this.addSubview(this.content = new app.views.Content);
|
||||
if (!app.isSingleDoc() && !app.isMobile()) { this.addSubview(this.path = new app.views.Path); }
|
||||
if (!app.isSingleDoc()) { this.settings = new app.views.Settings; }
|
||||
|
||||
$.on(document.body, 'click', this.onClick);
|
||||
|
||||
this.activate();
|
||||
}
|
||||
|
||||
setTitle(title) {
|
||||
return this.el.title = title ? `${title} — DevDocs` : 'DevDocs API Documentation';
|
||||
}
|
||||
|
||||
afterRoute(route) {
|
||||
if (route === 'settings') {
|
||||
if (this.settings != null) {
|
||||
this.settings.activate();
|
||||
}
|
||||
} else {
|
||||
if (this.settings != null) {
|
||||
this.settings.deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVisibilityChange() {
|
||||
if (this.el.visibilityState !== 'visible') { return; }
|
||||
this.delay(function() {
|
||||
if (app.isMobile() !== app.views.Mobile.detect()) { location.reload(); }
|
||||
}
|
||||
, 300);
|
||||
}
|
||||
|
||||
onHelp() {
|
||||
app.router.show('/help#shortcuts');
|
||||
}
|
||||
|
||||
onPreferences() {
|
||||
app.router.show('/settings');
|
||||
}
|
||||
|
||||
onEscape() {
|
||||
const path = !app.isSingleDoc() || (location.pathname === app.doc.fullPath()) ?
|
||||
'/'
|
||||
else
|
||||
app.doc.fullPath()
|
||||
|
||||
app.router.show(path)
|
||||
return
|
||||
|
||||
onBack: ->
|
||||
history.back()
|
||||
return
|
||||
|
||||
onForward: ->
|
||||
history.forward()
|
||||
return
|
||||
|
||||
onClick: (event) ->
|
||||
target = $.eventTarget(event)
|
||||
return unless target.hasAttribute('data-behavior')
|
||||
$.stopEvent(event)
|
||||
switch target.getAttribute('data-behavior')
|
||||
when 'back' then history.back()
|
||||
when 'reload' then window.location.reload()
|
||||
when 'reboot' then app.reboot()
|
||||
when 'hard-reload' then app.reload()
|
||||
when 'reset' then app.reset() if confirm('Are you sure you want to reset DevDocs?')
|
||||
when 'accept-analytics' then Cookies.set('analyticsConsent', '1', expires: 1e8) && app.reboot()
|
||||
when 'decline-analytics' then Cookies.set('analyticsConsent', '0', expires: 1e8) && app.reboot()
|
||||
return
|
||||
:
|
||||
app.doc.fullPath();
|
||||
|
||||
app.router.show(path);
|
||||
}
|
||||
|
||||
onBack() {
|
||||
history.back();
|
||||
}
|
||||
|
||||
onForward() {
|
||||
history.forward();
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const target = $.eventTarget(event);
|
||||
if (!target.hasAttribute('data-behavior')) { return; }
|
||||
$.stopEvent(event);
|
||||
switch (target.getAttribute('data-behavior')) {
|
||||
case 'back': history.back(); break;
|
||||
case 'reload': window.location.reload(); break;
|
||||
case 'reboot': app.reboot(); break;
|
||||
case 'hard-reload': app.reload(); break;
|
||||
case 'reset': if (confirm('Are you sure you want to reset DevDocs?')) { app.reset(); } break;
|
||||
case 'accept-analytics': Cookies.set('analyticsConsent', '1', {expires: 1e8}) && app.reboot(); break;
|
||||
case 'decline-analytics': Cookies.set('analyticsConsent', '0', {expires: 1e8}) && app.reboot(); break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,23 +1,39 @@
|
||||
class app.views.Menu extends app.View
|
||||
@el: '._menu'
|
||||
@activeClass: 'active'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Menu = class Menu extends app.View {
|
||||
constructor(...args) {
|
||||
this.onGlobalClick = this.onGlobalClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
static initClass() {
|
||||
this.el = '._menu';
|
||||
this.activeClass = 'active';
|
||||
|
||||
init: ->
|
||||
$.on document.body, 'click', @onGlobalClick
|
||||
return
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
onClick: (event) ->
|
||||
target = $.eventTarget(event)
|
||||
target.blur() if target.tagName is 'A'
|
||||
return
|
||||
init() {
|
||||
$.on(document.body, 'click', this.onGlobalClick);
|
||||
}
|
||||
|
||||
onGlobalClick: (event) =>
|
||||
return if event.which isnt 1
|
||||
if event.target.hasAttribute?('data-toggle-menu')
|
||||
@toggleClass @constructor.activeClass
|
||||
else if @hasClass @constructor.activeClass
|
||||
@removeClass @constructor.activeClass
|
||||
return
|
||||
onClick(event) {
|
||||
const target = $.eventTarget(event);
|
||||
if (target.tagName === 'A') { target.blur(); }
|
||||
}
|
||||
|
||||
onGlobalClick(event) {
|
||||
if (event.which !== 1) { return; }
|
||||
if (typeof event.target.hasAttribute === 'function' ? event.target.hasAttribute('data-toggle-menu') : undefined) {
|
||||
this.toggleClass(this.constructor.activeClass);
|
||||
} else if (this.hasClass(this.constructor.activeClass)) {
|
||||
this.removeClass(this.constructor.activeClass);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,155 +1,195 @@
|
||||
class app.views.Mobile extends app.View
|
||||
@className: '_mobile'
|
||||
|
||||
@elements:
|
||||
body: 'body'
|
||||
content: '._container'
|
||||
sidebar: '._sidebar'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Mobile = class Mobile extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_mobile';
|
||||
|
||||
this.elements = {
|
||||
body: 'body',
|
||||
content: '._container',
|
||||
sidebar: '._sidebar',
|
||||
docPicker: '._settings ._sidebar'
|
||||
|
||||
@shortcuts:
|
||||
escape: 'onEscape'
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
|
||||
@detect: ->
|
||||
if Cookies.get('override-mobile-detect')?
|
||||
return JSON.parse Cookies.get('override-mobile-detect')
|
||||
try
|
||||
(window.matchMedia('(max-width: 480px)').matches) or
|
||||
(window.matchMedia('(max-width: 767px)').matches) or
|
||||
(window.matchMedia('(max-height: 767px) and (max-width: 1024px)').matches) or
|
||||
# Need to sniff the user agent because some Android and Windows Phone devices don't take
|
||||
# resolution (dpi) into account when reporting device width/height.
|
||||
(navigator.userAgent.indexOf('Android') isnt -1 and navigator.userAgent.indexOf('Mobile') isnt -1) or
|
||||
(navigator.userAgent.indexOf('IEMobile') isnt -1)
|
||||
catch
|
||||
false
|
||||
|
||||
@detectAndroidWebview: ->
|
||||
try
|
||||
/(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent)
|
||||
catch
|
||||
false
|
||||
|
||||
constructor: ->
|
||||
@el = document.documentElement
|
||||
super
|
||||
|
||||
init: ->
|
||||
$.on $('._search'), 'touchend', @onTapSearch
|
||||
|
||||
@toggleSidebar = $('button[data-toggle-sidebar]')
|
||||
@toggleSidebar.removeAttribute('hidden')
|
||||
$.on @toggleSidebar, 'click', @onClickToggleSidebar
|
||||
|
||||
@back = $('button[data-back]')
|
||||
@back.removeAttribute('hidden')
|
||||
$.on @back, 'click', @onClickBack
|
||||
|
||||
@forward = $('button[data-forward]')
|
||||
@forward.removeAttribute('hidden')
|
||||
$.on @forward, 'click', @onClickForward
|
||||
|
||||
@docPickerTab = $('button[data-tab="doc-picker"]')
|
||||
@docPickerTab.removeAttribute('hidden')
|
||||
$.on @docPickerTab, 'click', @onClickDocPickerTab
|
||||
|
||||
@settingsTab = $('button[data-tab="settings"]')
|
||||
@settingsTab.removeAttribute('hidden')
|
||||
$.on @settingsTab, 'click', @onClickSettingsTab
|
||||
};
|
||||
|
||||
this.shortcuts =
|
||||
{escape: 'onEscape'};
|
||||
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
}
|
||||
|
||||
static detect() {
|
||||
if (Cookies.get('override-mobile-detect') != null) {
|
||||
return JSON.parse(Cookies.get('override-mobile-detect'));
|
||||
}
|
||||
try {
|
||||
return (window.matchMedia('(max-width: 480px)').matches) ||
|
||||
(window.matchMedia('(max-width: 767px)').matches) ||
|
||||
(window.matchMedia('(max-height: 767px) and (max-width: 1024px)').matches) ||
|
||||
// Need to sniff the user agent because some Android and Windows Phone devices don't take
|
||||
// resolution (dpi) into account when reporting device width/height.
|
||||
((navigator.userAgent.indexOf('Android') !== -1) && (navigator.userAgent.indexOf('Mobile') !== -1)) ||
|
||||
(navigator.userAgent.indexOf('IEMobile') !== -1);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static detectAndroidWebview() {
|
||||
try {
|
||||
return /(Android).*( Version\/.\.. ).*(Chrome)/.test(navigator.userAgent);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.showSidebar = this.showSidebar.bind(this);
|
||||
this.hideSidebar = this.hideSidebar.bind(this);
|
||||
this.onClickBack = this.onClickBack.bind(this);
|
||||
this.onClickForward = this.onClickForward.bind(this);
|
||||
this.onClickToggleSidebar = this.onClickToggleSidebar.bind(this);
|
||||
this.onClickDocPickerTab = this.onClickDocPickerTab.bind(this);
|
||||
this.onClickSettingsTab = this.onClickSettingsTab.bind(this);
|
||||
this.onTapSearch = this.onTapSearch.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
this.el = document.documentElement;
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
init() {
|
||||
$.on($('._search'), 'touchend', this.onTapSearch);
|
||||
|
||||
this.toggleSidebar = $('button[data-toggle-sidebar]');
|
||||
this.toggleSidebar.removeAttribute('hidden');
|
||||
$.on(this.toggleSidebar, 'click', this.onClickToggleSidebar);
|
||||
|
||||
this.back = $('button[data-back]');
|
||||
this.back.removeAttribute('hidden');
|
||||
$.on(this.back, 'click', this.onClickBack);
|
||||
|
||||
this.forward = $('button[data-forward]');
|
||||
this.forward.removeAttribute('hidden');
|
||||
$.on(this.forward, 'click', this.onClickForward);
|
||||
|
||||
this.docPickerTab = $('button[data-tab="doc-picker"]');
|
||||
this.docPickerTab.removeAttribute('hidden');
|
||||
$.on(this.docPickerTab, 'click', this.onClickDocPickerTab);
|
||||
|
||||
this.settingsTab = $('button[data-tab="settings"]');
|
||||
this.settingsTab.removeAttribute('hidden');
|
||||
$.on(this.settingsTab, 'click', this.onClickSettingsTab);
|
||||
|
||||
app.document.sidebar.search
|
||||
.on 'searching', @showSidebar
|
||||
|
||||
@activate()
|
||||
return
|
||||
|
||||
showSidebar: =>
|
||||
if @isSidebarShown()
|
||||
window.scrollTo 0, 0
|
||||
return
|
||||
|
||||
@contentTop = window.scrollY
|
||||
@content.style.display = 'none'
|
||||
@sidebar.style.display = 'block'
|
||||
|
||||
if selection = @findByClass app.views.ListSelect.activeClass
|
||||
scrollContainer = if window.scrollY is @body.scrollTop then @body else document.documentElement
|
||||
$.scrollTo selection, scrollContainer, 'center'
|
||||
else
|
||||
window.scrollTo 0, @findByClass(app.views.ListFold.activeClass) and @sidebarTop or 0
|
||||
return
|
||||
|
||||
hideSidebar: =>
|
||||
return unless @isSidebarShown()
|
||||
@sidebarTop = window.scrollY
|
||||
@sidebar.style.display = 'none'
|
||||
@content.style.display = 'block'
|
||||
window.scrollTo 0, @contentTop or 0
|
||||
return
|
||||
|
||||
isSidebarShown: ->
|
||||
@sidebar.style.display isnt 'none'
|
||||
|
||||
onClickBack: =>
|
||||
history.back()
|
||||
|
||||
onClickForward: =>
|
||||
history.forward()
|
||||
|
||||
onClickToggleSidebar: =>
|
||||
if @isSidebarShown() then @hideSidebar() else @showSidebar()
|
||||
return
|
||||
|
||||
onClickDocPickerTab: (event) =>
|
||||
$.stopEvent(event)
|
||||
@showDocPicker()
|
||||
return
|
||||
|
||||
onClickSettingsTab: (event) =>
|
||||
$.stopEvent(event)
|
||||
@showSettings()
|
||||
return
|
||||
|
||||
showDocPicker: ->
|
||||
window.scrollTo 0, 0
|
||||
@docPickerTab.classList.add 'active'
|
||||
@settingsTab.classList.remove 'active'
|
||||
@docPicker.style.display = 'block'
|
||||
@content.style.display = 'none'
|
||||
return
|
||||
|
||||
showSettings: ->
|
||||
window.scrollTo 0, 0
|
||||
@docPickerTab.classList.remove 'active'
|
||||
@settingsTab.classList.add 'active'
|
||||
@docPicker.style.display = 'none'
|
||||
@content.style.display = 'block'
|
||||
return
|
||||
|
||||
onTapSearch: =>
|
||||
window.scrollTo 0, 0
|
||||
|
||||
onEscape: =>
|
||||
@hideSidebar()
|
||||
|
||||
afterRoute: (route) =>
|
||||
@hideSidebar()
|
||||
|
||||
if route is 'settings'
|
||||
@showDocPicker()
|
||||
else
|
||||
@content.style.display = 'block'
|
||||
|
||||
if page.canGoBack()
|
||||
@back.removeAttribute('disabled')
|
||||
else
|
||||
@back.setAttribute('disabled', 'disabled')
|
||||
|
||||
if page.canGoForward()
|
||||
@forward.removeAttribute('disabled')
|
||||
else
|
||||
@forward.setAttribute('disabled', 'disabled')
|
||||
return
|
||||
.on('searching', this.showSidebar);
|
||||
|
||||
this.activate();
|
||||
}
|
||||
|
||||
showSidebar() {
|
||||
let selection;
|
||||
if (this.isSidebarShown()) {
|
||||
window.scrollTo(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.contentTop = window.scrollY;
|
||||
this.content.style.display = 'none';
|
||||
this.sidebar.style.display = 'block';
|
||||
|
||||
if (selection = this.findByClass(app.views.ListSelect.activeClass)) {
|
||||
const scrollContainer = window.scrollY === this.body.scrollTop ? this.body : document.documentElement;
|
||||
$.scrollTo(selection, scrollContainer, 'center');
|
||||
} else {
|
||||
window.scrollTo(0, (this.findByClass(app.views.ListFold.activeClass) && this.sidebarTop) || 0);
|
||||
}
|
||||
}
|
||||
|
||||
hideSidebar() {
|
||||
if (!this.isSidebarShown()) { return; }
|
||||
this.sidebarTop = window.scrollY;
|
||||
this.sidebar.style.display = 'none';
|
||||
this.content.style.display = 'block';
|
||||
window.scrollTo(0, this.contentTop || 0);
|
||||
}
|
||||
|
||||
isSidebarShown() {
|
||||
return this.sidebar.style.display !== 'none';
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
return history.back();
|
||||
}
|
||||
|
||||
onClickForward() {
|
||||
return history.forward();
|
||||
}
|
||||
|
||||
onClickToggleSidebar() {
|
||||
if (this.isSidebarShown()) { this.hideSidebar(); } else { this.showSidebar(); }
|
||||
}
|
||||
|
||||
onClickDocPickerTab(event) {
|
||||
$.stopEvent(event);
|
||||
this.showDocPicker();
|
||||
}
|
||||
|
||||
onClickSettingsTab(event) {
|
||||
$.stopEvent(event);
|
||||
this.showSettings();
|
||||
}
|
||||
|
||||
showDocPicker() {
|
||||
window.scrollTo(0, 0);
|
||||
this.docPickerTab.classList.add('active');
|
||||
this.settingsTab.classList.remove('active');
|
||||
this.docPicker.style.display = 'block';
|
||||
this.content.style.display = 'none';
|
||||
}
|
||||
|
||||
showSettings() {
|
||||
window.scrollTo(0, 0);
|
||||
this.docPickerTab.classList.remove('active');
|
||||
this.settingsTab.classList.add('active');
|
||||
this.docPicker.style.display = 'none';
|
||||
this.content.style.display = 'block';
|
||||
}
|
||||
|
||||
onTapSearch() {
|
||||
return window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
onEscape() {
|
||||
return this.hideSidebar();
|
||||
}
|
||||
|
||||
afterRoute(route) {
|
||||
this.hideSidebar();
|
||||
|
||||
if (route === 'settings') {
|
||||
this.showDocPicker();
|
||||
} else {
|
||||
this.content.style.display = 'block';
|
||||
}
|
||||
|
||||
if (page.canGoBack()) {
|
||||
this.back.removeAttribute('disabled');
|
||||
} else {
|
||||
this.back.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
|
||||
if (page.canGoForward()) {
|
||||
this.forward.removeAttribute('disabled');
|
||||
} else {
|
||||
this.forward.setAttribute('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,43 +1,64 @@
|
||||
class app.views.Path extends app.View
|
||||
@className: '_path'
|
||||
@attributes:
|
||||
role: 'complementary'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
|
||||
render: (args...) ->
|
||||
@html @tmpl 'path', args...
|
||||
@show()
|
||||
return
|
||||
|
||||
show: ->
|
||||
@prependTo app.el unless @el.parentNode
|
||||
return
|
||||
|
||||
hide: ->
|
||||
$.remove @el if @el.parentNode
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
@clicked = true if link = $.closestLink event.target, @el
|
||||
return
|
||||
|
||||
afterRoute: (route, context) =>
|
||||
if context.type
|
||||
@render context.doc, context.type
|
||||
else if context.entry
|
||||
if context.entry.isIndex()
|
||||
@render context.doc
|
||||
else
|
||||
@render context.doc, context.entry.getType(), context.entry
|
||||
else
|
||||
@hide()
|
||||
|
||||
if @clicked
|
||||
@clicked = null
|
||||
app.document.sidebar.reset()
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Path = class Path extends app.View {
|
||||
constructor(...args) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.className = '_path';
|
||||
this.attributes =
|
||||
{role: 'complementary'};
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
}
|
||||
|
||||
render(...args) {
|
||||
this.html(this.tmpl('path', ...Array.from(args)));
|
||||
this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
if (!this.el.parentNode) { this.prependTo(app.el); }
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.el.parentNode) { $.remove(this.el); }
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
let link;
|
||||
if (link = $.closestLink(event.target, this.el)) { this.clicked = true; }
|
||||
}
|
||||
|
||||
afterRoute(route, context) {
|
||||
if (context.type) {
|
||||
this.render(context.doc, context.type);
|
||||
} else if (context.entry) {
|
||||
if (context.entry.isIndex()) {
|
||||
this.render(context.doc);
|
||||
} else {
|
||||
this.render(context.doc, context.entry.getType(), context.entry);
|
||||
}
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
if (this.clicked) {
|
||||
this.clicked = null;
|
||||
app.document.sidebar.reset();
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,49 +1,75 @@
|
||||
class app.views.Resizer extends app.View
|
||||
@className: '_resizer'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let MIN = undefined;
|
||||
let MAX = undefined;
|
||||
const Cls = (app.views.Resizer = class Resizer extends app.View {
|
||||
constructor(...args) {
|
||||
this.onDragStart = this.onDragStart.bind(this);
|
||||
this.onDrag = this.onDrag.bind(this);
|
||||
this.onDragEnd = this.onDragEnd.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
@events:
|
||||
dragstart: 'onDragStart'
|
||||
static initClass() {
|
||||
this.className = '_resizer';
|
||||
|
||||
this.events = {
|
||||
dragstart: 'onDragStart',
|
||||
dragend: 'onDragEnd'
|
||||
};
|
||||
|
||||
MIN = 260;
|
||||
MAX = 600;
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return 'ondragstart' in document.createElement('div') && !app.isMobile();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.el.setAttribute('draggable', 'true');
|
||||
this.appendTo($('._app'));
|
||||
}
|
||||
|
||||
resize(value, save) {
|
||||
value -= app.el.offsetLeft;
|
||||
if (!(value > 0)) { return; }
|
||||
value = Math.min(Math.max(Math.round(value), MIN), MAX);
|
||||
const newSize = `${value}px`;
|
||||
document.documentElement.style.setProperty('--sidebarWidth', newSize);
|
||||
if (save) { app.settings.setSize(value); }
|
||||
}
|
||||
|
||||
onDragStart(event) {
|
||||
event.dataTransfer.effectAllowed = 'link';
|
||||
event.dataTransfer.setData('Text', '');
|
||||
$.on(window, 'dragover', this.onDrag);
|
||||
}
|
||||
|
||||
onDrag(event) {
|
||||
const value = event.pageX;
|
||||
if (!(value > 0)) { return; }
|
||||
this.lastDragValue = value;
|
||||
if (this.lastDrag && (this.lastDrag > (Date.now() - 50))) { return; }
|
||||
this.lastDrag = Date.now();
|
||||
this.resize(value, false);
|
||||
}
|
||||
|
||||
@isSupported: ->
|
||||
'ondragstart' of document.createElement('div') and !app.isMobile()
|
||||
|
||||
init: ->
|
||||
@el.setAttribute('draggable', 'true')
|
||||
@appendTo $('._app')
|
||||
return
|
||||
|
||||
MIN = 260
|
||||
MAX = 600
|
||||
|
||||
resize: (value, save) ->
|
||||
value -= app.el.offsetLeft
|
||||
return unless value > 0
|
||||
value = Math.min(Math.max(Math.round(value), MIN), MAX)
|
||||
newSize = "#{value}px"
|
||||
document.documentElement.style.setProperty('--sidebarWidth', newSize)
|
||||
app.settings.setSize(value) if save
|
||||
return
|
||||
|
||||
onDragStart: (event) =>
|
||||
event.dataTransfer.effectAllowed = 'link'
|
||||
event.dataTransfer.setData('Text', '')
|
||||
$.on(window, 'dragover', @onDrag)
|
||||
return
|
||||
|
||||
onDrag: (event) =>
|
||||
value = event.pageX
|
||||
return unless value > 0
|
||||
@lastDragValue = value
|
||||
return if @lastDrag and @lastDrag > Date.now() - 50
|
||||
@lastDrag = Date.now()
|
||||
@resize(value, false)
|
||||
return
|
||||
|
||||
onDragEnd: (event) =>
|
||||
$.off(window, 'dragover', @onDrag)
|
||||
value = event.pageX or (event.screenX - window.screenX)
|
||||
if @lastDragValue and not (@lastDragValue - 5 < value < @lastDragValue + 5) # https://github.com/freeCodeCamp/devdocs/issues/265
|
||||
value = @lastDragValue
|
||||
@resize(value, true)
|
||||
return
|
||||
onDragEnd(event) {
|
||||
$.off(window, 'dragover', this.onDrag);
|
||||
let value = event.pageX || (event.screenX - window.screenX);
|
||||
if (this.lastDragValue && !(this.lastDragValue - 5 < value && value < this.lastDragValue + 5)) { // https://github.com/freeCodeCamp/devdocs/issues/265
|
||||
value = this.lastDragValue;
|
||||
}
|
||||
this.resize(value, true);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,83 +1,127 @@
|
||||
class app.views.Settings extends app.View
|
||||
SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden'
|
||||
|
||||
@el: '._settings'
|
||||
|
||||
@elements:
|
||||
sidebar: '._sidebar'
|
||||
saveBtn: 'button[type="submit"]'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let SIDEBAR_HIDDEN_LAYOUT = undefined;
|
||||
const Cls = (app.views.Settings = class Settings extends app.View {
|
||||
constructor(...args) {
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onEnter = this.onEnter.bind(this);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
this.onImport = this.onImport.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
SIDEBAR_HIDDEN_LAYOUT = '_sidebar-hidden';
|
||||
|
||||
this.el = '._settings';
|
||||
|
||||
this.elements = {
|
||||
sidebar: '._sidebar',
|
||||
saveBtn: 'button[type="submit"]',
|
||||
backBtn: 'button[data-back]'
|
||||
};
|
||||
|
||||
@events:
|
||||
import: 'onImport'
|
||||
change: 'onChange'
|
||||
submit: 'onSubmit'
|
||||
this.events = {
|
||||
import: 'onImport',
|
||||
change: 'onChange',
|
||||
submit: 'onSubmit',
|
||||
click: 'onClick'
|
||||
|
||||
@shortcuts:
|
||||
enter: 'onEnter'
|
||||
|
||||
init: ->
|
||||
@addSubview @docPicker = new app.views.DocPicker
|
||||
return
|
||||
|
||||
activate: ->
|
||||
if super
|
||||
@render()
|
||||
document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT)
|
||||
return
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@resetClass()
|
||||
@docPicker.detach()
|
||||
document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT) if app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)
|
||||
return
|
||||
|
||||
render: ->
|
||||
@docPicker.appendTo @sidebar
|
||||
@refreshElements()
|
||||
@addClass '_in'
|
||||
return
|
||||
|
||||
save: (options = {}) ->
|
||||
unless @saving
|
||||
@saving = true
|
||||
|
||||
if options.import
|
||||
docs = app.settings.getDocs()
|
||||
else
|
||||
docs = @docPicker.getSelectedDocs()
|
||||
app.settings.setDocs(docs)
|
||||
|
||||
@saveBtn.textContent = 'Saving\u2026'
|
||||
disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1)
|
||||
disabledDocs.uninstall ->
|
||||
app.db.migrate()
|
||||
app.reload()
|
||||
return
|
||||
|
||||
onChange: =>
|
||||
@addClass('_dirty')
|
||||
return
|
||||
|
||||
onEnter: =>
|
||||
@save()
|
||||
return
|
||||
|
||||
onSubmit: (event) =>
|
||||
event.preventDefault()
|
||||
@save()
|
||||
return
|
||||
|
||||
onImport: =>
|
||||
@addClass('_dirty')
|
||||
@save(import: true)
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1
|
||||
if event.target is @backBtn
|
||||
$.stopEvent(event)
|
||||
app.router.show '/'
|
||||
return
|
||||
};
|
||||
|
||||
this.shortcuts =
|
||||
{enter: 'onEnter'};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addSubview(this.docPicker = new app.views.DocPicker);
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (super.activate(...arguments)) {
|
||||
this.render();
|
||||
document.body.classList.remove(SIDEBAR_HIDDEN_LAYOUT);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.resetClass();
|
||||
this.docPicker.detach();
|
||||
if (app.settings.hasLayout(SIDEBAR_HIDDEN_LAYOUT)) { document.body.classList.add(SIDEBAR_HIDDEN_LAYOUT); }
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.docPicker.appendTo(this.sidebar);
|
||||
this.refreshElements();
|
||||
this.addClass('_in');
|
||||
}
|
||||
|
||||
save(options) {
|
||||
if (options == null) { options = {}; }
|
||||
if (!this.saving) {
|
||||
let docs;
|
||||
this.saving = true;
|
||||
|
||||
if (options.import) {
|
||||
docs = app.settings.getDocs();
|
||||
} else {
|
||||
docs = this.docPicker.getSelectedDocs();
|
||||
app.settings.setDocs(docs);
|
||||
}
|
||||
|
||||
this.saveBtn.textContent = 'Saving\u2026';
|
||||
const disabledDocs = new app.collections.Docs((() => {
|
||||
const result = [];
|
||||
for (var doc of Array.from(app.docs.all())) { if (docs.indexOf(doc.slug) === -1) {
|
||||
result.push(doc);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})());
|
||||
disabledDocs.uninstall(function() {
|
||||
app.db.migrate();
|
||||
return app.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.addClass('_dirty');
|
||||
}
|
||||
|
||||
onEnter() {
|
||||
this.save();
|
||||
}
|
||||
|
||||
onSubmit(event) {
|
||||
event.preventDefault();
|
||||
this.save();
|
||||
}
|
||||
|
||||
onImport() {
|
||||
this.addClass('_dirty');
|
||||
this.save({import: true});
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.which !== 1) { return; }
|
||||
if (event.target === this.backBtn) {
|
||||
$.stopEvent(event);
|
||||
app.router.show('/');
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,124 +1,177 @@
|
||||
class app.views.ListFocus extends app.View
|
||||
@activeClass: 'focus'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
@shortcuts:
|
||||
up: 'onUp'
|
||||
down: 'onDown'
|
||||
left: 'onLeft'
|
||||
enter: 'onEnter'
|
||||
superEnter: 'onSuperEnter'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.ListFocus = class ListFocus extends app.View {
|
||||
static initClass() {
|
||||
this.activeClass = 'focus';
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
this.shortcuts = {
|
||||
up: 'onUp',
|
||||
down: 'onDown',
|
||||
left: 'onLeft',
|
||||
enter: 'onEnter',
|
||||
superEnter: 'onSuperEnter',
|
||||
escape: 'blur'
|
||||
|
||||
constructor: (@el) ->
|
||||
super
|
||||
@focusOnNextFrame = $.framify(@focus, @)
|
||||
|
||||
focus: (el, options = {}) ->
|
||||
if el and not el.classList.contains @constructor.activeClass
|
||||
@blur()
|
||||
el.classList.add @constructor.activeClass
|
||||
$.trigger el, 'focus' unless options.silent is true
|
||||
return
|
||||
|
||||
blur: =>
|
||||
if cursor = @getCursor()
|
||||
cursor.classList.remove @constructor.activeClass
|
||||
$.trigger cursor, 'blur'
|
||||
return
|
||||
|
||||
getCursor: ->
|
||||
@findByClass(@constructor.activeClass) or @findByClass(app.views.ListSelect.activeClass)
|
||||
|
||||
findNext: (cursor) ->
|
||||
if next = cursor.nextSibling
|
||||
if next.tagName is 'A'
|
||||
next
|
||||
else if next.tagName is 'SPAN' # pagination link
|
||||
$.click(next)
|
||||
@findNext cursor
|
||||
else if next.tagName is 'DIV' # sub-list
|
||||
if cursor.className.indexOf(' open') >= 0
|
||||
@findFirst(next) or @findNext(next)
|
||||
else
|
||||
@findNext(next)
|
||||
else if next.tagName is 'H6' # title
|
||||
@findNext(next)
|
||||
else if cursor.parentNode isnt @el
|
||||
@findNext cursor.parentNode
|
||||
|
||||
findFirst: (cursor) ->
|
||||
return unless first = cursor.firstChild
|
||||
|
||||
if first.tagName is 'A'
|
||||
first
|
||||
else if first.tagName is 'SPAN' # pagination link
|
||||
$.click(first)
|
||||
@findFirst cursor
|
||||
|
||||
findPrev: (cursor) ->
|
||||
if prev = cursor.previousSibling
|
||||
if prev.tagName is 'A'
|
||||
prev
|
||||
else if prev.tagName is 'SPAN' # pagination link
|
||||
$.click(prev)
|
||||
@findPrev cursor
|
||||
else if prev.tagName is 'DIV' # sub-list
|
||||
if prev.previousSibling.className.indexOf('open') >= 0
|
||||
@findLast(prev) or @findPrev(prev)
|
||||
else
|
||||
@findPrev(prev)
|
||||
else if prev.tagName is 'H6' # title
|
||||
@findPrev(prev)
|
||||
else if cursor.parentNode isnt @el
|
||||
@findPrev cursor.parentNode
|
||||
|
||||
findLast: (cursor) ->
|
||||
return unless last = cursor.lastChild
|
||||
|
||||
if last.tagName is 'A'
|
||||
last
|
||||
else if last.tagName is 'SPAN' or last.tagName is 'H6' # pagination link or title
|
||||
@findPrev last
|
||||
else if last.tagName is 'DIV' # sub-list
|
||||
@findLast last
|
||||
|
||||
onDown: =>
|
||||
if cursor = @getCursor()
|
||||
@focusOnNextFrame @findNext(cursor)
|
||||
else
|
||||
@focusOnNextFrame @findByTag('a')
|
||||
return
|
||||
|
||||
onUp: =>
|
||||
if cursor = @getCursor()
|
||||
@focusOnNextFrame @findPrev(cursor)
|
||||
else
|
||||
@focusOnNextFrame @findLastByTag('a')
|
||||
return
|
||||
|
||||
onLeft: =>
|
||||
cursor = @getCursor()
|
||||
if cursor and not cursor.classList.contains(app.views.ListFold.activeClass) and cursor.parentNode isnt @el
|
||||
prev = cursor.parentNode.previousSibling
|
||||
@focusOnNextFrame cursor.parentNode.previousSibling if prev and prev.classList.contains(app.views.ListFold.targetClass)
|
||||
return
|
||||
|
||||
onEnter: =>
|
||||
if cursor = @getCursor()
|
||||
$.click(cursor)
|
||||
return
|
||||
|
||||
onSuperEnter: =>
|
||||
if cursor = @getCursor()
|
||||
$.popup(cursor)
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1 or event.metaKey or event.ctrlKey
|
||||
target = $.eventTarget(event)
|
||||
if target.tagName is 'A'
|
||||
@focus target, silent: true
|
||||
return
|
||||
};
|
||||
}
|
||||
|
||||
constructor(el) {
|
||||
this.blur = this.blur.bind(this);
|
||||
this.onDown = this.onDown.bind(this);
|
||||
this.onUp = this.onUp.bind(this);
|
||||
this.onLeft = this.onLeft.bind(this);
|
||||
this.onEnter = this.onEnter.bind(this);
|
||||
this.onSuperEnter = this.onSuperEnter.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.el = el;
|
||||
super(...arguments);
|
||||
this.focusOnNextFrame = $.framify(this.focus, this);
|
||||
}
|
||||
|
||||
focus(el, options) {
|
||||
if (options == null) { options = {}; }
|
||||
if (el && !el.classList.contains(this.constructor.activeClass)) {
|
||||
this.blur();
|
||||
el.classList.add(this.constructor.activeClass);
|
||||
if (options.silent !== true) { $.trigger(el, 'focus'); }
|
||||
}
|
||||
}
|
||||
|
||||
blur() {
|
||||
let cursor;
|
||||
if (cursor = this.getCursor()) {
|
||||
cursor.classList.remove(this.constructor.activeClass);
|
||||
$.trigger(cursor, 'blur');
|
||||
}
|
||||
}
|
||||
|
||||
getCursor() {
|
||||
return this.findByClass(this.constructor.activeClass) || this.findByClass(app.views.ListSelect.activeClass);
|
||||
}
|
||||
|
||||
findNext(cursor) {
|
||||
let next;
|
||||
if (next = cursor.nextSibling) {
|
||||
if (next.tagName === 'A') {
|
||||
return next;
|
||||
} else if (next.tagName === 'SPAN') { // pagination link
|
||||
$.click(next);
|
||||
return this.findNext(cursor);
|
||||
} else if (next.tagName === 'DIV') { // sub-list
|
||||
if (cursor.className.indexOf(' open') >= 0) {
|
||||
return this.findFirst(next) || this.findNext(next);
|
||||
} else {
|
||||
return this.findNext(next);
|
||||
}
|
||||
} else if (next.tagName === 'H6') { // title
|
||||
return this.findNext(next);
|
||||
}
|
||||
} else if (cursor.parentNode !== this.el) {
|
||||
return this.findNext(cursor.parentNode);
|
||||
}
|
||||
}
|
||||
|
||||
findFirst(cursor) {
|
||||
let first;
|
||||
if (!(first = cursor.firstChild)) { return; }
|
||||
|
||||
if (first.tagName === 'A') {
|
||||
return first;
|
||||
} else if (first.tagName === 'SPAN') { // pagination link
|
||||
$.click(first);
|
||||
return this.findFirst(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
findPrev(cursor) {
|
||||
let prev;
|
||||
if (prev = cursor.previousSibling) {
|
||||
if (prev.tagName === 'A') {
|
||||
return prev;
|
||||
} else if (prev.tagName === 'SPAN') { // pagination link
|
||||
$.click(prev);
|
||||
return this.findPrev(cursor);
|
||||
} else if (prev.tagName === 'DIV') { // sub-list
|
||||
if (prev.previousSibling.className.indexOf('open') >= 0) {
|
||||
return this.findLast(prev) || this.findPrev(prev);
|
||||
} else {
|
||||
return this.findPrev(prev);
|
||||
}
|
||||
} else if (prev.tagName === 'H6') { // title
|
||||
return this.findPrev(prev);
|
||||
}
|
||||
} else if (cursor.parentNode !== this.el) {
|
||||
return this.findPrev(cursor.parentNode);
|
||||
}
|
||||
}
|
||||
|
||||
findLast(cursor) {
|
||||
let last;
|
||||
if (!(last = cursor.lastChild)) { return; }
|
||||
|
||||
if (last.tagName === 'A') {
|
||||
return last;
|
||||
} else if ((last.tagName === 'SPAN') || (last.tagName === 'H6')) { // pagination link or title
|
||||
return this.findPrev(last);
|
||||
} else if (last.tagName === 'DIV') { // sub-list
|
||||
return this.findLast(last);
|
||||
}
|
||||
}
|
||||
|
||||
onDown() {
|
||||
let cursor;
|
||||
if ((cursor = this.getCursor())) {
|
||||
this.focusOnNextFrame(this.findNext(cursor));
|
||||
} else {
|
||||
this.focusOnNextFrame(this.findByTag('a'));
|
||||
}
|
||||
}
|
||||
|
||||
onUp() {
|
||||
let cursor;
|
||||
if ((cursor = this.getCursor())) {
|
||||
this.focusOnNextFrame(this.findPrev(cursor));
|
||||
} else {
|
||||
this.focusOnNextFrame(this.findLastByTag('a'));
|
||||
}
|
||||
}
|
||||
|
||||
onLeft() {
|
||||
const cursor = this.getCursor();
|
||||
if (cursor && !cursor.classList.contains(app.views.ListFold.activeClass) && (cursor.parentNode !== this.el)) {
|
||||
const prev = cursor.parentNode.previousSibling;
|
||||
if (prev && prev.classList.contains(app.views.ListFold.targetClass)) { this.focusOnNextFrame(cursor.parentNode.previousSibling); }
|
||||
}
|
||||
}
|
||||
|
||||
onEnter() {
|
||||
let cursor;
|
||||
if (cursor = this.getCursor()) {
|
||||
$.click(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
onSuperEnter() {
|
||||
let cursor;
|
||||
if (cursor = this.getCursor()) {
|
||||
$.popup(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
|
||||
const target = $.eventTarget(event);
|
||||
if (target.tagName === 'A') {
|
||||
this.focus(target, {silent: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,71 +1,95 @@
|
||||
class app.views.ListFold extends app.View
|
||||
@targetClass: '_list-dir'
|
||||
@handleClass: '_list-arrow'
|
||||
@activeClass: 'open'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.ListFold = class ListFold extends app.View {
|
||||
static initClass() {
|
||||
this.targetClass = '_list-dir';
|
||||
this.handleClass = '_list-arrow';
|
||||
this.activeClass = 'open';
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
@shortcuts:
|
||||
left: 'onLeft'
|
||||
this.shortcuts = {
|
||||
left: 'onLeft',
|
||||
right: 'onRight'
|
||||
};
|
||||
}
|
||||
|
||||
constructor: (@el) -> super
|
||||
constructor(el) { this.onLeft = this.onLeft.bind(this); this.onRight = this.onRight.bind(this); this.onClick = this.onClick.bind(this); this.el = el; super(...arguments); }
|
||||
|
||||
open: (el) ->
|
||||
if el and not el.classList.contains @constructor.activeClass
|
||||
el.classList.add @constructor.activeClass
|
||||
$.trigger el, 'open'
|
||||
return
|
||||
open(el) {
|
||||
if (el && !el.classList.contains(this.constructor.activeClass)) {
|
||||
el.classList.add(this.constructor.activeClass);
|
||||
$.trigger(el, 'open');
|
||||
}
|
||||
}
|
||||
|
||||
close: (el) ->
|
||||
if el and el.classList.contains @constructor.activeClass
|
||||
el.classList.remove @constructor.activeClass
|
||||
$.trigger el, 'close'
|
||||
return
|
||||
close(el) {
|
||||
if (el && el.classList.contains(this.constructor.activeClass)) {
|
||||
el.classList.remove(this.constructor.activeClass);
|
||||
$.trigger(el, 'close');
|
||||
}
|
||||
}
|
||||
|
||||
toggle: (el) ->
|
||||
if el.classList.contains @constructor.activeClass
|
||||
@close el
|
||||
else
|
||||
@open el
|
||||
return
|
||||
toggle(el) {
|
||||
if (el.classList.contains(this.constructor.activeClass)) {
|
||||
this.close(el);
|
||||
} else {
|
||||
this.open(el);
|
||||
}
|
||||
}
|
||||
|
||||
reset: ->
|
||||
while el = @findByClass @constructor.activeClass
|
||||
@close el
|
||||
return
|
||||
reset() {
|
||||
let el;
|
||||
while ((el = this.findByClass(this.constructor.activeClass))) {
|
||||
this.close(el);
|
||||
}
|
||||
}
|
||||
|
||||
getCursor: ->
|
||||
@findByClass(app.views.ListFocus.activeClass) or @findByClass(app.views.ListSelect.activeClass)
|
||||
getCursor() {
|
||||
return this.findByClass(app.views.ListFocus.activeClass) || this.findByClass(app.views.ListSelect.activeClass);
|
||||
}
|
||||
|
||||
onLeft: =>
|
||||
cursor = @getCursor()
|
||||
if cursor?.classList.contains @constructor.activeClass
|
||||
@close cursor
|
||||
return
|
||||
onLeft() {
|
||||
const cursor = this.getCursor();
|
||||
if (cursor != null ? cursor.classList.contains(this.constructor.activeClass) : undefined) {
|
||||
this.close(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
onRight: =>
|
||||
cursor = @getCursor()
|
||||
if cursor?.classList.contains @constructor.targetClass
|
||||
@open cursor
|
||||
return
|
||||
onRight() {
|
||||
const cursor = this.getCursor();
|
||||
if (cursor != null ? cursor.classList.contains(this.constructor.targetClass) : undefined) {
|
||||
this.open(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1 or event.metaKey or event.ctrlKey
|
||||
return unless event.pageY # ignore fabricated clicks
|
||||
el = $.eventTarget(event)
|
||||
el = el.parentNode if el.parentNode.tagName.toUpperCase() is 'SVG'
|
||||
onClick(event) {
|
||||
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
|
||||
if (!event.pageY) { return; } // ignore fabricated clicks
|
||||
let el = $.eventTarget(event);
|
||||
if (el.parentNode.tagName.toUpperCase() === 'SVG') { el = el.parentNode; }
|
||||
|
||||
if el.classList.contains @constructor.handleClass
|
||||
$.stopEvent(event)
|
||||
@toggle el.parentNode
|
||||
else if el.classList.contains @constructor.targetClass
|
||||
if el.hasAttribute('href')
|
||||
if el.classList.contains(@constructor.activeClass)
|
||||
@close(el) if el.classList.contains(app.views.ListSelect.activeClass)
|
||||
else
|
||||
@open(el)
|
||||
else
|
||||
@toggle(el)
|
||||
return
|
||||
if (el.classList.contains(this.constructor.handleClass)) {
|
||||
$.stopEvent(event);
|
||||
this.toggle(el.parentNode);
|
||||
} else if (el.classList.contains(this.constructor.targetClass)) {
|
||||
if (el.hasAttribute('href')) {
|
||||
if (el.classList.contains(this.constructor.activeClass)) {
|
||||
if (el.classList.contains(app.views.ListSelect.activeClass)) { this.close(el); }
|
||||
} else {
|
||||
this.open(el);
|
||||
}
|
||||
} else {
|
||||
this.toggle(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,43 +1,65 @@
|
||||
class app.views.ListSelect extends app.View
|
||||
@activeClass: 'active'
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
constructor: (@el) -> super
|
||||
|
||||
deactivate: ->
|
||||
@deselect() if super
|
||||
return
|
||||
|
||||
select: (el) ->
|
||||
@deselect()
|
||||
if el
|
||||
el.classList.add @constructor.activeClass
|
||||
$.trigger el, 'select'
|
||||
return
|
||||
|
||||
deselect: ->
|
||||
if selection = @getSelection()
|
||||
selection.classList.remove @constructor.activeClass
|
||||
$.trigger selection, 'deselect'
|
||||
return
|
||||
|
||||
selectByHref: (href) ->
|
||||
unless @getSelection()?.getAttribute('href') is href
|
||||
@select @find("a[href='#{href}']")
|
||||
return
|
||||
|
||||
selectCurrent: ->
|
||||
@selectByHref location.pathname + location.hash
|
||||
return
|
||||
|
||||
getSelection: ->
|
||||
@findByClass @constructor.activeClass
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1 or event.metaKey or event.ctrlKey
|
||||
target = $.eventTarget(event)
|
||||
if target.tagName is 'A'
|
||||
@select target
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.ListSelect = class ListSelect extends app.View {
|
||||
static initClass() {
|
||||
this.activeClass = 'active';
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
constructor(el) { this.onClick = this.onClick.bind(this); this.el = el; super(...arguments); }
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) { this.deselect(); }
|
||||
}
|
||||
|
||||
select(el) {
|
||||
this.deselect();
|
||||
if (el) {
|
||||
el.classList.add(this.constructor.activeClass);
|
||||
$.trigger(el, 'select');
|
||||
}
|
||||
}
|
||||
|
||||
deselect() {
|
||||
let selection;
|
||||
if (selection = this.getSelection()) {
|
||||
selection.classList.remove(this.constructor.activeClass);
|
||||
$.trigger(selection, 'deselect');
|
||||
}
|
||||
}
|
||||
|
||||
selectByHref(href) {
|
||||
if (__guard__(this.getSelection(), x => x.getAttribute('href')) !== href) {
|
||||
this.select(this.find(`a[href='${href}']`));
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrent() {
|
||||
this.selectByHref(location.pathname + location.hash);
|
||||
}
|
||||
|
||||
getSelection() {
|
||||
return this.findByClass(this.constructor.activeClass);
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if ((event.which !== 1) || event.metaKey || event.ctrlKey) { return; }
|
||||
const target = $.eventTarget(event);
|
||||
if (target.tagName === 'A') {
|
||||
this.select(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,90 +1,121 @@
|
||||
class app.views.PaginatedList extends app.View
|
||||
PER_PAGE = app.config.max_results
|
||||
|
||||
constructor: (@data) ->
|
||||
(@constructor.events or= {}).click ?= 'onClick'
|
||||
super
|
||||
|
||||
renderPaginated: ->
|
||||
@page = 0
|
||||
|
||||
if @totalPages() > 1
|
||||
@paginateNext()
|
||||
else
|
||||
@html @renderAll()
|
||||
return
|
||||
|
||||
# render: (dataSlice) -> implemented by subclass
|
||||
|
||||
renderAll: ->
|
||||
@render @data
|
||||
|
||||
renderPage: (page) ->
|
||||
@render @data[((page - 1) * PER_PAGE)...(page * PER_PAGE)]
|
||||
|
||||
renderPageLink: (count) ->
|
||||
@tmpl 'sidebarPageLink', count
|
||||
|
||||
renderPrevLink: (page) ->
|
||||
@renderPageLink (page - 1) * PER_PAGE
|
||||
|
||||
renderNextLink: (page) ->
|
||||
@renderPageLink @data.length - page * PER_PAGE
|
||||
|
||||
totalPages: ->
|
||||
Math.ceil @data.length / PER_PAGE
|
||||
|
||||
paginate: (link) ->
|
||||
$.lockScroll link.nextSibling or link.previousSibling, =>
|
||||
$.batchUpdate @el, =>
|
||||
if link.nextSibling then @paginatePrev link else @paginateNext link
|
||||
return
|
||||
return
|
||||
return
|
||||
|
||||
paginateNext: ->
|
||||
@remove @el.lastChild if @el.lastChild # remove link
|
||||
@hideTopPage() if @page >= 2 # keep previous page into view
|
||||
@page++
|
||||
@append @renderPage(@page)
|
||||
@append @renderNextLink(@page) if @page < @totalPages()
|
||||
return
|
||||
|
||||
paginatePrev: ->
|
||||
@remove @el.firstChild # remove link
|
||||
@hideBottomPage()
|
||||
@page--
|
||||
@prepend @renderPage(@page - 1) # previous page is offset by one
|
||||
@prepend @renderPrevLink(@page - 1) if @page >= 3
|
||||
return
|
||||
|
||||
paginateTo: (object) ->
|
||||
index = @data.indexOf(object)
|
||||
if index >= PER_PAGE
|
||||
@paginateNext() for [0...(index // PER_PAGE)]
|
||||
return
|
||||
|
||||
hideTopPage: ->
|
||||
n = if @page <= 2
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS104: Avoid inline assignments
|
||||
* DS202: Simplify dynamic range loops
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let PER_PAGE = undefined;
|
||||
const Cls = (app.views.PaginatedList = class PaginatedList extends app.View {
|
||||
static initClass() {
|
||||
PER_PAGE = app.config.max_results;
|
||||
}
|
||||
|
||||
constructor(data) {
|
||||
let base;
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.data = data;
|
||||
if (((base = this.constructor.events || (this.constructor.events = {}))).click == null) { base.click = 'onClick'; }
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
renderPaginated() {
|
||||
this.page = 0;
|
||||
|
||||
if (this.totalPages() > 1) {
|
||||
this.paginateNext();
|
||||
} else {
|
||||
this.html(this.renderAll());
|
||||
}
|
||||
}
|
||||
|
||||
// render: (dataSlice) -> implemented by subclass
|
||||
|
||||
renderAll() {
|
||||
return this.render(this.data);
|
||||
}
|
||||
|
||||
renderPage(page) {
|
||||
return this.render(this.data.slice(((page - 1) * PER_PAGE), (page * PER_PAGE)));
|
||||
}
|
||||
|
||||
renderPageLink(count) {
|
||||
return this.tmpl('sidebarPageLink', count);
|
||||
}
|
||||
|
||||
renderPrevLink(page) {
|
||||
return this.renderPageLink((page - 1) * PER_PAGE);
|
||||
}
|
||||
|
||||
renderNextLink(page) {
|
||||
return this.renderPageLink(this.data.length - (page * PER_PAGE));
|
||||
}
|
||||
|
||||
totalPages() {
|
||||
return Math.ceil(this.data.length / PER_PAGE);
|
||||
}
|
||||
|
||||
paginate(link) {
|
||||
$.lockScroll(link.nextSibling || link.previousSibling, () => {
|
||||
$.batchUpdate(this.el, () => {
|
||||
if (link.nextSibling) { this.paginatePrev(link); } else { this.paginateNext(link); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
paginateNext() {
|
||||
if (this.el.lastChild) { this.remove(this.el.lastChild); } // remove link
|
||||
if (this.page >= 2) { this.hideTopPage(); } // keep previous page into view
|
||||
this.page++;
|
||||
this.append(this.renderPage(this.page));
|
||||
if (this.page < this.totalPages()) { this.append(this.renderNextLink(this.page)); }
|
||||
}
|
||||
|
||||
paginatePrev() {
|
||||
this.remove(this.el.firstChild); // remove link
|
||||
this.hideBottomPage();
|
||||
this.page--;
|
||||
this.prepend(this.renderPage(this.page - 1)); // previous page is offset by one
|
||||
if (this.page >= 3) { this.prepend(this.renderPrevLink(this.page - 1)); }
|
||||
}
|
||||
|
||||
paginateTo(object) {
|
||||
const index = this.data.indexOf(object);
|
||||
if (index >= PER_PAGE) {
|
||||
for (let i = 0, end = Math.floor(index / PER_PAGE), asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.paginateNext(); }
|
||||
}
|
||||
}
|
||||
|
||||
hideTopPage() {
|
||||
const n = this.page <= 2 ?
|
||||
PER_PAGE
|
||||
else
|
||||
PER_PAGE + 1 # remove link
|
||||
@remove @el.firstChild for [0...n]
|
||||
@prepend @renderPrevLink(@page)
|
||||
return
|
||||
|
||||
hideBottomPage: ->
|
||||
n = if @page is @totalPages()
|
||||
@data.length % PER_PAGE or PER_PAGE
|
||||
else
|
||||
PER_PAGE + 1 # remove link
|
||||
@remove @el.lastChild for [0...n]
|
||||
@append @renderNextLink(@page - 1)
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
target = $.eventTarget(event)
|
||||
if target.tagName is 'SPAN' # link
|
||||
$.stopEvent(event)
|
||||
@paginate target
|
||||
return
|
||||
:
|
||||
PER_PAGE + 1; // remove link
|
||||
for (let i = 0, end = n, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.remove(this.el.firstChild); }
|
||||
this.prepend(this.renderPrevLink(this.page));
|
||||
}
|
||||
|
||||
hideBottomPage() {
|
||||
const n = this.page === this.totalPages() ?
|
||||
(this.data.length % PER_PAGE) || PER_PAGE
|
||||
:
|
||||
PER_PAGE + 1; // remove link
|
||||
for (let i = 0, end = n, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) { this.remove(this.el.lastChild); }
|
||||
this.append(this.renderNextLink(this.page - 1));
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
const target = $.eventTarget(event);
|
||||
if (target.tagName === 'SPAN') { // link
|
||||
$.stopEvent(event);
|
||||
this.paginate(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
@ -1,34 +1,55 @@
|
||||
#= require views/misc/notif
|
||||
|
||||
class app.views.News extends app.views.Notif
|
||||
@className += ' _notif-news'
|
||||
|
||||
@defautOptions:
|
||||
autoHide: 30000
|
||||
|
||||
init: ->
|
||||
@unreadNews = @getUnreadNews()
|
||||
@show() if @unreadNews.length
|
||||
@markAllAsRead()
|
||||
return
|
||||
|
||||
render: ->
|
||||
@html app.templates.notifNews(@unreadNews)
|
||||
return
|
||||
|
||||
getUnreadNews: ->
|
||||
return [] unless time = @getLastReadTime()
|
||||
|
||||
for news in app.news
|
||||
break if new Date(news[0]).getTime() <= time
|
||||
news
|
||||
|
||||
getLastNewsTime: ->
|
||||
new Date(app.news[0][0]).getTime()
|
||||
|
||||
getLastReadTime: ->
|
||||
app.settings.get 'news'
|
||||
|
||||
markAllAsRead: ->
|
||||
app.settings.set 'news', @getLastNewsTime()
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/misc/notif
|
||||
|
||||
const Cls = (app.views.News = class News extends app.views.Notif {
|
||||
static initClass() {
|
||||
this.className += ' _notif-news';
|
||||
|
||||
this.defautOptions =
|
||||
{autoHide: 30000};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.unreadNews = this.getUnreadNews();
|
||||
if (this.unreadNews.length) { this.show(); }
|
||||
this.markAllAsRead();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.html(app.templates.notifNews(this.unreadNews));
|
||||
}
|
||||
|
||||
getUnreadNews() {
|
||||
let time;
|
||||
if (!(time = this.getLastReadTime())) { return []; }
|
||||
|
||||
return (() => {
|
||||
const result = [];
|
||||
for (var news of Array.from(app.news)) {
|
||||
if (new Date(news[0]).getTime() <= time) { break; }
|
||||
result.push(news);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
getLastNewsTime() {
|
||||
return new Date(app.news[0][0]).getTime();
|
||||
}
|
||||
|
||||
getLastReadTime() {
|
||||
return app.settings.get('news');
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
app.settings.set('news', this.getLastNewsTime());
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,27 +1,38 @@
|
||||
class app.views.Notice extends app.View
|
||||
@className: '_notice'
|
||||
@attributes:
|
||||
role: 'alert'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Notice = class Notice extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_notice';
|
||||
this.attributes =
|
||||
{role: 'alert'};
|
||||
}
|
||||
|
||||
constructor: (@type, @args...) -> super
|
||||
constructor(type, ...rest) { this.type = type; [...this.args] = Array.from(rest); super(...arguments); }
|
||||
|
||||
init: ->
|
||||
@activate()
|
||||
return
|
||||
init() {
|
||||
this.activate();
|
||||
}
|
||||
|
||||
activate: ->
|
||||
@show() if super
|
||||
return
|
||||
activate() {
|
||||
if (super.activate(...arguments)) { this.show(); }
|
||||
}
|
||||
|
||||
deactivate: ->
|
||||
@hide() if super
|
||||
return
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) { this.hide(); }
|
||||
}
|
||||
|
||||
show: ->
|
||||
@html @tmpl("#{@type}Notice", @args...)
|
||||
@prependTo app.el
|
||||
return
|
||||
show() {
|
||||
this.html(this.tmpl(`${this.type}Notice`, ...Array.from(this.args)));
|
||||
this.prependTo(app.el);
|
||||
}
|
||||
|
||||
hide: ->
|
||||
$.remove @el
|
||||
return
|
||||
hide() {
|
||||
$.remove(this.el);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,59 +1,78 @@
|
||||
class app.views.Notif extends app.View
|
||||
@className: '_notif'
|
||||
@activeClass: '_in'
|
||||
@attributes:
|
||||
role: 'alert'
|
||||
|
||||
@defautOptions:
|
||||
autoHide: 15000
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
|
||||
constructor: (@type, @options = {}) ->
|
||||
@options = $.extend {}, @constructor.defautOptions, @options
|
||||
super
|
||||
|
||||
init: ->
|
||||
@show()
|
||||
return
|
||||
|
||||
show: ->
|
||||
if @timeout
|
||||
clearTimeout @timeout
|
||||
@timeout = @delay @hide, @options.autoHide
|
||||
else
|
||||
@render()
|
||||
@position()
|
||||
@activate()
|
||||
@appendTo document.body
|
||||
@el.offsetWidth # force reflow
|
||||
@addClass @constructor.activeClass
|
||||
@timeout = @delay @hide, @options.autoHide if @options.autoHide
|
||||
return
|
||||
|
||||
hide: ->
|
||||
clearTimeout @timeout
|
||||
@timeout = null
|
||||
@detach()
|
||||
return
|
||||
|
||||
render: ->
|
||||
@html @tmpl("notif#{@type}")
|
||||
return
|
||||
|
||||
position: ->
|
||||
notifications = $$ ".#{app.views.Notif.className}"
|
||||
if notifications.length
|
||||
lastNotif = notifications[notifications.length - 1]
|
||||
@el.style.top = lastNotif.offsetTop + lastNotif.offsetHeight + 16 + 'px'
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1
|
||||
target = $.eventTarget(event)
|
||||
return if target.hasAttribute('data-behavior')
|
||||
if target.tagName isnt 'A' or target.classList.contains('_notif-close')
|
||||
$.stopEvent(event)
|
||||
@hide()
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Notif = class Notif extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_notif';
|
||||
this.activeClass = '_in';
|
||||
this.attributes =
|
||||
{role: 'alert'};
|
||||
|
||||
this.defautOptions =
|
||||
{autoHide: 15000};
|
||||
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
constructor(type, options) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.type = type;
|
||||
if (options == null) { options = {}; }
|
||||
this.options = options;
|
||||
this.options = $.extend({}, this.constructor.defautOptions, this.options);
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.show();
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = this.delay(this.hide, this.options.autoHide);
|
||||
} else {
|
||||
this.render();
|
||||
this.position();
|
||||
this.activate();
|
||||
this.appendTo(document.body);
|
||||
this.el.offsetWidth; // force reflow
|
||||
this.addClass(this.constructor.activeClass);
|
||||
if (this.options.autoHide) { this.timeout = this.delay(this.hide, this.options.autoHide); }
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
this.detach();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.html(this.tmpl(`notif${this.type}`));
|
||||
}
|
||||
|
||||
position() {
|
||||
const notifications = $$(`.${app.views.Notif.className}`);
|
||||
if (notifications.length) {
|
||||
const lastNotif = notifications[notifications.length - 1];
|
||||
this.el.style.top = lastNotif.offsetTop + lastNotif.offsetHeight + 16 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.which !== 1) { return; }
|
||||
const target = $.eventTarget(event);
|
||||
if (target.hasAttribute('data-behavior')) { return; }
|
||||
if ((target.tagName !== 'A') || target.classList.contains('_notif-close')) {
|
||||
$.stopEvent(event);
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,11 +1,20 @@
|
||||
#= require views/misc/notif
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/misc/notif
|
||||
|
||||
class app.views.Tip extends app.views.Notif
|
||||
@className: '_notif _notif-tip'
|
||||
const Cls = (app.views.Tip = class Tip extends app.views.Notif {
|
||||
static initClass() {
|
||||
this.className = '_notif _notif-tip';
|
||||
|
||||
@defautOptions:
|
||||
autoHide: false
|
||||
this.defautOptions =
|
||||
{autoHide: false};
|
||||
}
|
||||
|
||||
render: ->
|
||||
@html @tmpl("tip#{@type}")
|
||||
return
|
||||
render() {
|
||||
this.html(this.tmpl(`tip${this.type}`));
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,34 +1,56 @@
|
||||
#= require views/misc/notif
|
||||
|
||||
class app.views.Updates extends app.views.Notif
|
||||
@className += ' _notif-news'
|
||||
|
||||
@defautOptions:
|
||||
autoHide: 30000
|
||||
|
||||
init: ->
|
||||
@lastUpdateTime = @getLastUpdateTime()
|
||||
@updatedDocs = @getUpdatedDocs()
|
||||
@updatedDisabledDocs = @getUpdatedDisabledDocs()
|
||||
@show() if @updatedDocs.length > 0 or @updatedDisabledDocs.length > 0
|
||||
@markAllAsRead()
|
||||
return
|
||||
|
||||
render: ->
|
||||
@html app.templates.notifUpdates(@updatedDocs, @updatedDisabledDocs)
|
||||
return
|
||||
|
||||
getUpdatedDocs: ->
|
||||
return [] unless @lastUpdateTime
|
||||
doc for doc in app.docs.all() when doc.mtime > @lastUpdateTime
|
||||
|
||||
getUpdatedDisabledDocs: ->
|
||||
return [] unless @lastUpdateTime
|
||||
doc for doc in app.disabledDocs.all() when doc.mtime > @lastUpdateTime and app.docs.findBy('slug_without_version', doc.slug_without_version)
|
||||
|
||||
getLastUpdateTime: ->
|
||||
app.settings.get 'version'
|
||||
|
||||
markAllAsRead: ->
|
||||
app.settings.set 'version', if app.config.env is 'production' then app.config.version else Math.floor(Date.now() / 1000)
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS205: Consider reworking code to avoid use of IIFEs
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/misc/notif
|
||||
|
||||
const Cls = (app.views.Updates = class Updates extends app.views.Notif {
|
||||
static initClass() {
|
||||
this.className += ' _notif-news';
|
||||
|
||||
this.defautOptions =
|
||||
{autoHide: 30000};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.lastUpdateTime = this.getLastUpdateTime();
|
||||
this.updatedDocs = this.getUpdatedDocs();
|
||||
this.updatedDisabledDocs = this.getUpdatedDisabledDocs();
|
||||
if ((this.updatedDocs.length > 0) || (this.updatedDisabledDocs.length > 0)) { this.show(); }
|
||||
this.markAllAsRead();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.html(app.templates.notifUpdates(this.updatedDocs, this.updatedDisabledDocs));
|
||||
}
|
||||
|
||||
getUpdatedDocs() {
|
||||
if (!this.lastUpdateTime) { return []; }
|
||||
return Array.from(app.docs.all()).filter((doc) => doc.mtime > this.lastUpdateTime);
|
||||
}
|
||||
|
||||
getUpdatedDisabledDocs() {
|
||||
if (!this.lastUpdateTime) { return []; }
|
||||
return (() => {
|
||||
const result = [];
|
||||
for (var doc of Array.from(app.disabledDocs.all())) { if ((doc.mtime > this.lastUpdateTime) && app.docs.findBy('slug_without_version', doc.slug_without_version)) {
|
||||
result.push(doc);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
}
|
||||
|
||||
getLastUpdateTime() {
|
||||
return app.settings.get('version');
|
||||
}
|
||||
|
||||
markAllAsRead() {
|
||||
app.settings.set('version', app.config.env === 'production' ? app.config.version : Math.floor(Date.now() / 1000));
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,43 +1,61 @@
|
||||
class app.views.BasePage extends app.View
|
||||
constructor: (@el, @entry) -> super
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
app.views.BasePage = class BasePage extends app.View {
|
||||
constructor(el, entry) { this.paintCode = this.paintCode.bind(this); this.el = el; this.entry = entry; super(...arguments); }
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@highlightNodes = []
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
return this.highlightNodes = [];
|
||||
}
|
||||
}
|
||||
|
||||
render: (content, fromCache = false) ->
|
||||
@highlightNodes = []
|
||||
@previousTiming = null
|
||||
@addClass "_#{@entry.doc.type}" unless @constructor.className
|
||||
@html content
|
||||
@highlightCode() unless fromCache
|
||||
@activate()
|
||||
@delay @afterRender if @afterRender
|
||||
if @highlightNodes.length > 0
|
||||
$.requestAnimationFrame => $.requestAnimationFrame(@paintCode)
|
||||
return
|
||||
render(content, fromCache) {
|
||||
if (fromCache == null) { fromCache = false; }
|
||||
this.highlightNodes = [];
|
||||
this.previousTiming = null;
|
||||
if (!this.constructor.className) { this.addClass(`_${this.entry.doc.type}`); }
|
||||
this.html(content);
|
||||
if (!fromCache) { this.highlightCode(); }
|
||||
this.activate();
|
||||
if (this.afterRender) { this.delay(this.afterRender); }
|
||||
if (this.highlightNodes.length > 0) {
|
||||
$.requestAnimationFrame(() => $.requestAnimationFrame(this.paintCode));
|
||||
}
|
||||
}
|
||||
|
||||
highlightCode: ->
|
||||
for el in @findAll('pre[data-language]')
|
||||
language = el.getAttribute('data-language')
|
||||
el.classList.add("language-#{language}")
|
||||
@highlightNodes.push(el)
|
||||
return
|
||||
highlightCode() {
|
||||
for (var el of Array.from(this.findAll('pre[data-language]'))) {
|
||||
var language = el.getAttribute('data-language');
|
||||
el.classList.add(`language-${language}`);
|
||||
this.highlightNodes.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
paintCode: (timing) =>
|
||||
if @previousTiming
|
||||
if Math.round(1000 / (timing - @previousTiming)) > 50 # fps
|
||||
@nodesPerFrame = Math.round(Math.min(@nodesPerFrame * 1.25, 50))
|
||||
else
|
||||
@nodesPerFrame = Math.round(Math.max(@nodesPerFrame * .8, 10))
|
||||
else
|
||||
@nodesPerFrame = 10
|
||||
paintCode(timing) {
|
||||
if (this.previousTiming) {
|
||||
if (Math.round(1000 / (timing - this.previousTiming)) > 50) { // fps
|
||||
this.nodesPerFrame = Math.round(Math.min(this.nodesPerFrame * 1.25, 50));
|
||||
} else {
|
||||
this.nodesPerFrame = Math.round(Math.max(this.nodesPerFrame * .8, 10));
|
||||
}
|
||||
} else {
|
||||
this.nodesPerFrame = 10;
|
||||
}
|
||||
|
||||
for el in @highlightNodes.splice(0, @nodesPerFrame)
|
||||
$.remove(clipEl) if clipEl = el.lastElementChild
|
||||
Prism.highlightElement(el)
|
||||
$.append(el, clipEl) if clipEl
|
||||
for (var el of Array.from(this.highlightNodes.splice(0, this.nodesPerFrame))) {
|
||||
var clipEl;
|
||||
if (clipEl = el.lastElementChild) { $.remove(clipEl); }
|
||||
Prism.highlightElement(el);
|
||||
if (clipEl) { $.append(el, clipEl); }
|
||||
}
|
||||
|
||||
$.requestAnimationFrame(@paintCode) if @highlightNodes.length > 0
|
||||
@previousTiming = timing
|
||||
return
|
||||
if (this.highlightNodes.length > 0) { $.requestAnimationFrame(this.paintCode); }
|
||||
this.previousTiming = timing;
|
||||
}
|
||||
};
|
||||
|
@ -1,16 +1,28 @@
|
||||
class app.views.HiddenPage extends app.View
|
||||
@events:
|
||||
click: 'onClick'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.HiddenPage = class HiddenPage extends app.View {
|
||||
static initClass() {
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
constructor: (@el, @entry) -> super
|
||||
constructor(el, entry) { this.onClick = this.onClick.bind(this); this.el = el; this.entry = entry; super(...arguments); }
|
||||
|
||||
init: ->
|
||||
@addSubview @notice = new app.views.Notice 'disabledDoc'
|
||||
@activate()
|
||||
return
|
||||
init() {
|
||||
this.addSubview(this.notice = new app.views.Notice('disabledDoc'));
|
||||
this.activate();
|
||||
}
|
||||
|
||||
onClick: (event) =>
|
||||
if link = $.closestLink(event.target, @el)
|
||||
$.stopEvent(event)
|
||||
$.popup(link)
|
||||
return
|
||||
onClick(event) {
|
||||
let link;
|
||||
if (link = $.closestLink(event.target, this.el)) {
|
||||
$.stopEvent(event);
|
||||
$.popup(link);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,57 +1,81 @@
|
||||
#= require views/pages/base
|
||||
|
||||
class app.views.JqueryPage extends app.views.BasePage
|
||||
@demoClassName: '_jquery-demo'
|
||||
|
||||
afterRender: ->
|
||||
# Prevent jQuery Mobile's demo iframes from scrolling the page
|
||||
for iframe in @findAllByTag 'iframe'
|
||||
iframe.style.display = 'none'
|
||||
$.on iframe, 'load', @onIframeLoaded
|
||||
|
||||
@runExamples()
|
||||
|
||||
onIframeLoaded: (event) =>
|
||||
event.target.style.display = ''
|
||||
$.off event.target, 'load', @onIframeLoaded
|
||||
return
|
||||
|
||||
runExamples: ->
|
||||
for el in @findAllByClass 'entry-example'
|
||||
try @runExample el catch
|
||||
return
|
||||
|
||||
runExample: (el) ->
|
||||
source = el.getElementsByClassName('syntaxhighlighter')[0]
|
||||
return unless source and source.innerHTML.indexOf('!doctype') isnt -1
|
||||
|
||||
unless iframe = el.getElementsByClassName(@constructor.demoClassName)[0]
|
||||
iframe = document.createElement 'iframe'
|
||||
iframe.className = @constructor.demoClassName
|
||||
iframe.width = '100%'
|
||||
iframe.height = 200
|
||||
el.appendChild(iframe)
|
||||
|
||||
doc = iframe.contentDocument
|
||||
doc.write @fixIframeSource(source.textContent)
|
||||
doc.close()
|
||||
return
|
||||
|
||||
fixIframeSource: (source) ->
|
||||
source = source.replace '"/resources/', '"https://api.jquery.com/resources/' # attr(), keydown()
|
||||
source = source.replace '</head>', """
|
||||
<style>
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/pages/base
|
||||
|
||||
const Cls = (app.views.JqueryPage = class JqueryPage extends app.views.BasePage {
|
||||
constructor(...args) {
|
||||
this.onIframeLoaded = this.onIframeLoaded.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.demoClassName = '_jquery-demo';
|
||||
}
|
||||
|
||||
afterRender() {
|
||||
// Prevent jQuery Mobile's demo iframes from scrolling the page
|
||||
for (var iframe of Array.from(this.findAllByTag('iframe'))) {
|
||||
iframe.style.display = 'none';
|
||||
$.on(iframe, 'load', this.onIframeLoaded);
|
||||
}
|
||||
|
||||
return this.runExamples();
|
||||
}
|
||||
|
||||
onIframeLoaded(event) {
|
||||
event.target.style.display = '';
|
||||
$.off(event.target, 'load', this.onIframeLoaded);
|
||||
}
|
||||
|
||||
runExamples() {
|
||||
for (var el of Array.from(this.findAllByClass('entry-example'))) {
|
||||
try { this.runExample(el); } catch (error) {}
|
||||
}
|
||||
}
|
||||
|
||||
runExample(el) {
|
||||
let iframe;
|
||||
const source = el.getElementsByClassName('syntaxhighlighter')[0];
|
||||
if (!source || (source.innerHTML.indexOf('!doctype') === -1)) { return; }
|
||||
|
||||
if (!(iframe = el.getElementsByClassName(this.constructor.demoClassName)[0])) {
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.className = this.constructor.demoClassName;
|
||||
iframe.width = '100%';
|
||||
iframe.height = 200;
|
||||
el.appendChild(iframe);
|
||||
}
|
||||
|
||||
const doc = iframe.contentDocument;
|
||||
doc.write(this.fixIframeSource(source.textContent));
|
||||
doc.close();
|
||||
}
|
||||
|
||||
fixIframeSource(source) {
|
||||
source = source.replace('"/resources/', '"https://api.jquery.com/resources/'); // attr(), keydown()
|
||||
source = source.replace('</head>', `\
|
||||
<style>
|
||||
html, body { border: 0; margin: 0; padding: 0; }
|
||||
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
|
||||
</style>
|
||||
<script>
|
||||
</style>
|
||||
<script>
|
||||
$.ajaxPrefilter(function(opt, opt2, xhr) {
|
||||
if (opt.url.indexOf('http') !== 0) {
|
||||
xhr.abort();
|
||||
document.body.innerHTML = "<p><strong>This demo cannot run inside DevDocs.</strong></p>";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
"""
|
||||
source.replace /<script>/gi, '<script nonce="devdocs">'
|
||||
</script>
|
||||
</head>\
|
||||
`
|
||||
);
|
||||
return source.replace(/<script>/gi, '<script nonce="devdocs">');
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,15 +1,26 @@
|
||||
#= require views/pages/base
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/pages/base
|
||||
|
||||
class app.views.RdocPage extends app.views.BasePage
|
||||
@events:
|
||||
click: 'onClick'
|
||||
const Cls = (app.views.RdocPage = class RdocPage extends app.views.BasePage {
|
||||
static initClass() {
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
onClick: (event) ->
|
||||
return unless event.target.classList.contains 'method-click-advice'
|
||||
$.stopEvent(event)
|
||||
onClick(event) {
|
||||
if (!event.target.classList.contains('method-click-advice')) { return; }
|
||||
$.stopEvent(event);
|
||||
|
||||
source = $ '.method-source-code', event.target.closest('.method-detail')
|
||||
isShown = source.style.display is 'block'
|
||||
const source = $('.method-source-code', event.target.closest('.method-detail'));
|
||||
const isShown = source.style.display === 'block';
|
||||
|
||||
source.style.display = if isShown then 'none' else 'block'
|
||||
event.target.textContent = if isShown then 'Show source' else 'Hide source'
|
||||
source.style.display = isShown ? 'none' : 'block';
|
||||
return event.target.textContent = isShown ? 'Show source' : 'Hide source';
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,17 +1,34 @@
|
||||
#= require views/pages/base
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/pages/base
|
||||
|
||||
class app.views.SqlitePage extends app.views.BasePage
|
||||
@events:
|
||||
click: 'onClick'
|
||||
const Cls = (app.views.SqlitePage = class SqlitePage extends app.views.BasePage {
|
||||
constructor(...args) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
onClick: (event) =>
|
||||
return unless id = event.target.getAttribute('data-toggle')
|
||||
return unless el = @find("##{id}")
|
||||
$.stopEvent(event)
|
||||
if el.style.display == 'none'
|
||||
el.style.display = 'block'
|
||||
event.target.textContent = 'hide'
|
||||
else
|
||||
el.style.display = 'none'
|
||||
event.target.textContent = 'show'
|
||||
return
|
||||
static initClass() {
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
let el, id;
|
||||
if (!(id = event.target.getAttribute('data-toggle'))) { return; }
|
||||
if (!(el = this.find(`#${id}`))) { return; }
|
||||
$.stopEvent(event);
|
||||
if (el.style.display === 'none') {
|
||||
el.style.display = 'block';
|
||||
event.target.textContent = 'hide';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
event.target.textContent = 'show';
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,14 +1,23 @@
|
||||
#= require views/pages/base
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/pages/base
|
||||
|
||||
class app.views.SupportTablesPage extends app.views.BasePage
|
||||
@events:
|
||||
click: 'onClick'
|
||||
const Cls = (app.views.SupportTablesPage = class SupportTablesPage extends app.views.BasePage {
|
||||
static initClass() {
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
}
|
||||
|
||||
onClick: (event) ->
|
||||
return unless event.target.classList.contains 'show-all'
|
||||
$.stopEvent(event)
|
||||
onClick(event) {
|
||||
if (!event.target.classList.contains('show-all')) { return; }
|
||||
$.stopEvent(event);
|
||||
|
||||
el = event.target
|
||||
el = el.parentNode until el.tagName is 'TABLE'
|
||||
el.classList.add 'show-all'
|
||||
return
|
||||
let el = event.target;
|
||||
while (el.tagName !== 'TABLE') { el = el.parentNode; }
|
||||
el.classList.add('show-all');
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,168 +1,225 @@
|
||||
class app.views.Search extends app.View
|
||||
SEARCH_PARAM = app.config.search_param
|
||||
|
||||
@el: '._search'
|
||||
@activeClass: '_search-active'
|
||||
|
||||
@elements:
|
||||
input: '._search-input'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let SEARCH_PARAM = undefined;
|
||||
let HASH_RGX = undefined;
|
||||
const Cls = (app.views.Search = class Search extends app.View {
|
||||
constructor(...args) {
|
||||
this.focus = this.focus.bind(this);
|
||||
this.autoFocus = this.autoFocus.bind(this);
|
||||
this.onWindowFocus = this.onWindowFocus.bind(this);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onInput = this.onInput.bind(this);
|
||||
this.searchUrl = this.searchUrl.bind(this);
|
||||
this.google = this.google.bind(this);
|
||||
this.stackoverflow = this.stackoverflow.bind(this);
|
||||
this.duckduckgo = this.duckduckgo.bind(this);
|
||||
this.onResults = this.onResults.bind(this);
|
||||
this.onEnd = this.onEnd.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onScopeChange = this.onScopeChange.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
SEARCH_PARAM = app.config.search_param;
|
||||
|
||||
this.el = '._search';
|
||||
this.activeClass = '_search-active';
|
||||
|
||||
this.elements = {
|
||||
input: '._search-input',
|
||||
resetLink: '._search-clear'
|
||||
};
|
||||
|
||||
@events:
|
||||
input: 'onInput'
|
||||
click: 'onClick'
|
||||
this.events = {
|
||||
input: 'onInput',
|
||||
click: 'onClick',
|
||||
submit: 'onSubmit'
|
||||
};
|
||||
|
||||
@shortcuts:
|
||||
typing: 'focus'
|
||||
altG: 'google'
|
||||
altS: 'stackoverflow'
|
||||
this.shortcuts = {
|
||||
typing: 'focus',
|
||||
altG: 'google',
|
||||
altS: 'stackoverflow',
|
||||
altD: 'duckduckgo'
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
|
||||
init: ->
|
||||
@addSubview @scope = new app.views.SearchScope @el
|
||||
|
||||
@searcher = new app.Searcher
|
||||
@searcher
|
||||
.on 'results', @onResults
|
||||
.on 'end', @onEnd
|
||||
|
||||
@scope
|
||||
.on 'change', @onScopeChange
|
||||
|
||||
app.on 'ready', @onReady
|
||||
$.on window, 'hashchange', @searchUrl
|
||||
$.on window, 'focus', @onWindowFocus
|
||||
return
|
||||
|
||||
focus: =>
|
||||
return if document.activeElement is @input
|
||||
return if app.settings.get('noAutofocus')
|
||||
@input.focus()
|
||||
return
|
||||
|
||||
autoFocus: =>
|
||||
return if app.isMobile() or $.isAndroid() or $.isIOS()
|
||||
return if document.activeElement?.tagName is 'INPUT'
|
||||
return if app.settings.get('noAutofocus')
|
||||
@input.focus()
|
||||
return
|
||||
|
||||
onWindowFocus: (event) =>
|
||||
@autoFocus() if event.target is window
|
||||
|
||||
getScopeDoc: ->
|
||||
@scope.getScope() if @scope.isActive()
|
||||
|
||||
reset: (force) ->
|
||||
@scope.reset() if force or not @input.value
|
||||
@el.reset()
|
||||
@onInput()
|
||||
@autoFocus()
|
||||
return
|
||||
|
||||
onReady: =>
|
||||
@value = ''
|
||||
@delay @onInput
|
||||
return
|
||||
|
||||
onInput: =>
|
||||
return if not @value? or # ignore events pre-"ready"
|
||||
@value is @input.value
|
||||
@value = @input.value
|
||||
|
||||
if @value.length
|
||||
@search()
|
||||
else
|
||||
@clear()
|
||||
return
|
||||
|
||||
search: (url = false) ->
|
||||
@addClass @constructor.activeClass
|
||||
@trigger 'searching'
|
||||
|
||||
@hasResults = null
|
||||
@flags = urlSearch: url, initialResults: true
|
||||
@searcher.find @scope.getScope().entries.all(), 'text', @value
|
||||
return
|
||||
|
||||
searchUrl: =>
|
||||
if location.pathname is '/'
|
||||
@scope.searchUrl()
|
||||
else if not app.router.isIndex()
|
||||
return
|
||||
|
||||
return unless value = @extractHashValue()
|
||||
@input.value = @value = value
|
||||
@input.setSelectionRange(value.length, value.length)
|
||||
@search true
|
||||
true
|
||||
|
||||
clear: ->
|
||||
@removeClass @constructor.activeClass
|
||||
@trigger 'clear'
|
||||
return
|
||||
|
||||
externalSearch: (url) ->
|
||||
if value = @value
|
||||
value = "#{@scope.name()} #{value}" if @scope.name()
|
||||
$.popup "#{url}#{encodeURIComponent value}"
|
||||
@reset()
|
||||
return
|
||||
|
||||
google: =>
|
||||
@externalSearch "https://www.google.com/search?q="
|
||||
return
|
||||
|
||||
stackoverflow: =>
|
||||
@externalSearch "https://stackoverflow.com/search?q="
|
||||
return
|
||||
|
||||
duckduckgo: =>
|
||||
@externalSearch "https://duckduckgo.com/?t=devdocs&q="
|
||||
return
|
||||
|
||||
onResults: (results) =>
|
||||
@hasResults = true if results.length
|
||||
@trigger 'results', results, @flags
|
||||
@flags.initialResults = false
|
||||
return
|
||||
|
||||
onEnd: =>
|
||||
@trigger 'noresults' unless @hasResults
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
if event.target is @resetLink
|
||||
$.stopEvent(event)
|
||||
@reset()
|
||||
return
|
||||
|
||||
onSubmit: (event) ->
|
||||
$.stopEvent(event)
|
||||
return
|
||||
|
||||
onScopeChange: =>
|
||||
@value = ''
|
||||
@onInput()
|
||||
return
|
||||
|
||||
afterRoute: (name, context) =>
|
||||
return if app.shortcuts.eventInProgress?.name is 'escape'
|
||||
@reset(true) if not context.init and app.router.isIndex()
|
||||
@delay @searchUrl if context.hash
|
||||
$.requestAnimationFrame @autoFocus
|
||||
return
|
||||
|
||||
extractHashValue: ->
|
||||
if (value = @getHashValue())?
|
||||
app.router.replaceHash()
|
||||
value
|
||||
|
||||
HASH_RGX = new RegExp "^##{SEARCH_PARAM}=(.*)"
|
||||
|
||||
getHashValue: ->
|
||||
try HASH_RGX.exec($.urlDecode location.hash)?[1] catch
|
||||
};
|
||||
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
|
||||
HASH_RGX = new RegExp(`^#${SEARCH_PARAM}=(.*)`);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addSubview(this.scope = new app.views.SearchScope(this.el));
|
||||
|
||||
this.searcher = new app.Searcher;
|
||||
this.searcher
|
||||
.on('results', this.onResults)
|
||||
.on('end', this.onEnd);
|
||||
|
||||
this.scope
|
||||
.on('change', this.onScopeChange);
|
||||
|
||||
app.on('ready', this.onReady);
|
||||
$.on(window, 'hashchange', this.searchUrl);
|
||||
$.on(window, 'focus', this.onWindowFocus);
|
||||
}
|
||||
|
||||
focus() {
|
||||
if (document.activeElement === this.input) { return; }
|
||||
if (app.settings.get('noAutofocus')) { return; }
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
autoFocus() {
|
||||
if (app.isMobile() || $.isAndroid() || $.isIOS()) { return; }
|
||||
if ((document.activeElement != null ? document.activeElement.tagName : undefined) === 'INPUT') { return; }
|
||||
if (app.settings.get('noAutofocus')) { return; }
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
onWindowFocus(event) {
|
||||
if (event.target === window) { return this.autoFocus(); }
|
||||
}
|
||||
|
||||
getScopeDoc() {
|
||||
if (this.scope.isActive()) { return this.scope.getScope(); }
|
||||
}
|
||||
|
||||
reset(force) {
|
||||
if (force || !this.input.value) { this.scope.reset(); }
|
||||
this.el.reset();
|
||||
this.onInput();
|
||||
this.autoFocus();
|
||||
}
|
||||
|
||||
onReady() {
|
||||
this.value = '';
|
||||
this.delay(this.onInput);
|
||||
}
|
||||
|
||||
onInput() {
|
||||
if ((this.value == null) || // ignore events pre-"ready"
|
||||
(this.value === this.input.value)) { return; }
|
||||
this.value = this.input.value;
|
||||
|
||||
if (this.value.length) {
|
||||
this.search();
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
search(url) {
|
||||
if (url == null) { url = false; }
|
||||
this.addClass(this.constructor.activeClass);
|
||||
this.trigger('searching');
|
||||
|
||||
this.hasResults = null;
|
||||
this.flags = {urlSearch: url, initialResults: true};
|
||||
this.searcher.find(this.scope.getScope().entries.all(), 'text', this.value);
|
||||
}
|
||||
|
||||
searchUrl() {
|
||||
let value;
|
||||
if (location.pathname === '/') {
|
||||
this.scope.searchUrl();
|
||||
} else if (!app.router.isIndex()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(value = this.extractHashValue())) { return; }
|
||||
this.input.value = (this.value = value);
|
||||
this.input.setSelectionRange(value.length, value.length);
|
||||
this.search(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.removeClass(this.constructor.activeClass);
|
||||
this.trigger('clear');
|
||||
}
|
||||
|
||||
externalSearch(url) {
|
||||
let value;
|
||||
if (value = this.value) {
|
||||
if (this.scope.name()) { value = `${this.scope.name()} ${value}`; }
|
||||
$.popup(`${url}${encodeURIComponent(value)}`);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
google() {
|
||||
this.externalSearch("https://www.google.com/search?q=");
|
||||
}
|
||||
|
||||
stackoverflow() {
|
||||
this.externalSearch("https://stackoverflow.com/search?q=");
|
||||
}
|
||||
|
||||
duckduckgo() {
|
||||
this.externalSearch("https://duckduckgo.com/?t=devdocs&q=");
|
||||
}
|
||||
|
||||
onResults(results) {
|
||||
if (results.length) { this.hasResults = true; }
|
||||
this.trigger('results', results, this.flags);
|
||||
this.flags.initialResults = false;
|
||||
}
|
||||
|
||||
onEnd() {
|
||||
if (!this.hasResults) { this.trigger('noresults'); }
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.target === this.resetLink) {
|
||||
$.stopEvent(event);
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(event) {
|
||||
$.stopEvent(event);
|
||||
}
|
||||
|
||||
onScopeChange() {
|
||||
this.value = '';
|
||||
this.onInput();
|
||||
}
|
||||
|
||||
afterRoute(name, context) {
|
||||
if ((app.shortcuts.eventInProgress != null ? app.shortcuts.eventInProgress.name : undefined) === 'escape') { return; }
|
||||
if (!context.init && app.router.isIndex()) { this.reset(true); }
|
||||
if (context.hash) { this.delay(this.searchUrl); }
|
||||
$.requestAnimationFrame(this.autoFocus);
|
||||
}
|
||||
|
||||
extractHashValue() {
|
||||
let value;
|
||||
if ((value = this.getHashValue()) != null) {
|
||||
app.router.replaceHash();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
getHashValue() {
|
||||
try { return __guard__(HASH_RGX.exec($.urlDecode(location.hash)), x => x[1]); } catch (error) {}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,135 +1,180 @@
|
||||
class app.views.SearchScope extends app.View
|
||||
SEARCH_PARAM = app.config.search_param
|
||||
|
||||
@elements:
|
||||
input: '._search-input'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
(function() {
|
||||
let SEARCH_PARAM = undefined;
|
||||
let HASH_RGX = undefined;
|
||||
const Cls = (app.views.SearchScope = class SearchScope extends app.View {
|
||||
static initClass() {
|
||||
SEARCH_PARAM = app.config.search_param;
|
||||
|
||||
this.elements = {
|
||||
input: '._search-input',
|
||||
tag: '._search-tag'
|
||||
};
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
keydown: 'onKeydown'
|
||||
this.events = {
|
||||
click: 'onClick',
|
||||
keydown: 'onKeydown',
|
||||
textInput: 'onTextInput'
|
||||
};
|
||||
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
HASH_RGX = new RegExp(`^#${SEARCH_PARAM}=(.+?) .`);
|
||||
}
|
||||
|
||||
constructor: (@el) -> super
|
||||
constructor(el) { this.onResults = this.onResults.bind(this); this.reset = this.reset.bind(this); this.doScopeSearch = this.doScopeSearch.bind(this); this.onClick = this.onClick.bind(this); this.onKeydown = this.onKeydown.bind(this); this.onTextInput = this.onTextInput.bind(this); this.afterRoute = this.afterRoute.bind(this); this.el = el; super(...arguments); }
|
||||
|
||||
init: ->
|
||||
@placeholder = @input.getAttribute 'placeholder'
|
||||
init() {
|
||||
this.placeholder = this.input.getAttribute('placeholder');
|
||||
|
||||
@searcher = new app.SynchronousSearcher
|
||||
fuzzy_min_length: 2
|
||||
this.searcher = new app.SynchronousSearcher({
|
||||
fuzzy_min_length: 2,
|
||||
max_results: 1
|
||||
@searcher.on 'results', @onResults
|
||||
|
||||
return
|
||||
|
||||
getScope: ->
|
||||
@doc or app
|
||||
|
||||
isActive: ->
|
||||
!!@doc
|
||||
|
||||
name: ->
|
||||
@doc?.name
|
||||
|
||||
search: (value, searchDisabled = false) ->
|
||||
return if @doc
|
||||
@searcher.find app.docs.all(), 'text', value
|
||||
@searcher.find app.disabledDocs.all(), 'text', value if not @doc and searchDisabled
|
||||
return
|
||||
|
||||
searchUrl: ->
|
||||
if value = @extractHashValue()
|
||||
@search value, true
|
||||
return
|
||||
|
||||
onResults: (results) =>
|
||||
return unless doc = results[0]
|
||||
if app.docs.contains(doc)
|
||||
@selectDoc(doc)
|
||||
else
|
||||
@redirectToDoc(doc)
|
||||
return
|
||||
|
||||
selectDoc: (doc) ->
|
||||
previousDoc = @doc
|
||||
return if doc is previousDoc
|
||||
@doc = doc
|
||||
|
||||
@tag.textContent = doc.fullName
|
||||
@tag.style.display = 'block'
|
||||
|
||||
@input.removeAttribute 'placeholder'
|
||||
@input.value = @input.value[@input.selectionStart..]
|
||||
@input.style.paddingLeft = @tag.offsetWidth + 10 + 'px'
|
||||
|
||||
$.trigger @input, 'input'
|
||||
@trigger 'change', @doc, previousDoc
|
||||
return
|
||||
|
||||
redirectToDoc: (doc) ->
|
||||
hash = location.hash
|
||||
app.router.replaceHash('')
|
||||
location.assign doc.fullPath() + hash
|
||||
return
|
||||
|
||||
reset: =>
|
||||
return unless @doc
|
||||
previousDoc = @doc
|
||||
@doc = null
|
||||
|
||||
@tag.textContent = ''
|
||||
@tag.style.display = 'none'
|
||||
|
||||
@input.setAttribute 'placeholder', @placeholder
|
||||
@input.style.paddingLeft = ''
|
||||
|
||||
@trigger 'change', null, previousDoc
|
||||
return
|
||||
|
||||
doScopeSearch: (event) =>
|
||||
@search @input.value[0...@input.selectionStart]
|
||||
$.stopEvent(event) if @doc
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
if event.target is @tag
|
||||
@reset()
|
||||
$.stopEvent(event)
|
||||
return
|
||||
|
||||
onKeydown: (event) =>
|
||||
if event.which is 8 # backspace
|
||||
if @doc and @input.selectionEnd is 0
|
||||
@reset()
|
||||
$.stopEvent(event)
|
||||
else if not @doc and @input.value and not $.isChromeForAndroid()
|
||||
return if event.ctrlKey or event.metaKey or event.altKey or event.shiftKey
|
||||
if event.which is 9 or # tab
|
||||
(event.which is 32 and app.isMobile()) # space
|
||||
@doScopeSearch(event)
|
||||
return
|
||||
|
||||
onTextInput: (event) =>
|
||||
return unless $.isChromeForAndroid()
|
||||
if not @doc and @input.value and event.data == ' '
|
||||
@doScopeSearch(event)
|
||||
return
|
||||
|
||||
extractHashValue: ->
|
||||
if value = @getHashValue()
|
||||
newHash = $.urlDecode(location.hash).replace "##{SEARCH_PARAM}=#{value} ", "##{SEARCH_PARAM}="
|
||||
app.router.replaceHash(newHash)
|
||||
value
|
||||
|
||||
HASH_RGX = new RegExp "^##{SEARCH_PARAM}=(.+?) ."
|
||||
|
||||
getHashValue: ->
|
||||
try HASH_RGX.exec($.urlDecode location.hash)?[1] catch
|
||||
|
||||
afterRoute: (name, context) =>
|
||||
if !app.isSingleDoc() and context.init and context.doc
|
||||
@selectDoc(context.doc)
|
||||
return
|
||||
});
|
||||
this.searcher.on('results', this.onResults);
|
||||
|
||||
}
|
||||
|
||||
getScope() {
|
||||
return this.doc || app;
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return !!this.doc;
|
||||
}
|
||||
|
||||
name() {
|
||||
return (this.doc != null ? this.doc.name : undefined);
|
||||
}
|
||||
|
||||
search(value, searchDisabled) {
|
||||
if (searchDisabled == null) { searchDisabled = false; }
|
||||
if (this.doc) { return; }
|
||||
this.searcher.find(app.docs.all(), 'text', value);
|
||||
if (!this.doc && searchDisabled) { this.searcher.find(app.disabledDocs.all(), 'text', value); }
|
||||
}
|
||||
|
||||
searchUrl() {
|
||||
let value;
|
||||
if (value = this.extractHashValue()) {
|
||||
this.search(value, true);
|
||||
}
|
||||
}
|
||||
|
||||
onResults(results) {
|
||||
let doc;
|
||||
if (!(doc = results[0])) { return; }
|
||||
if (app.docs.contains(doc)) {
|
||||
this.selectDoc(doc);
|
||||
} else {
|
||||
this.redirectToDoc(doc);
|
||||
}
|
||||
}
|
||||
|
||||
selectDoc(doc) {
|
||||
const previousDoc = this.doc;
|
||||
if (doc === previousDoc) { return; }
|
||||
this.doc = doc;
|
||||
|
||||
this.tag.textContent = doc.fullName;
|
||||
this.tag.style.display = 'block';
|
||||
|
||||
this.input.removeAttribute('placeholder');
|
||||
this.input.value = this.input.value.slice(this.input.selectionStart);
|
||||
this.input.style.paddingLeft = this.tag.offsetWidth + 10 + 'px';
|
||||
|
||||
$.trigger(this.input, 'input');
|
||||
this.trigger('change', this.doc, previousDoc);
|
||||
}
|
||||
|
||||
redirectToDoc(doc) {
|
||||
const {
|
||||
hash
|
||||
} = location;
|
||||
app.router.replaceHash('');
|
||||
location.assign(doc.fullPath() + hash);
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.doc) { return; }
|
||||
const previousDoc = this.doc;
|
||||
this.doc = null;
|
||||
|
||||
this.tag.textContent = '';
|
||||
this.tag.style.display = 'none';
|
||||
|
||||
this.input.setAttribute('placeholder', this.placeholder);
|
||||
this.input.style.paddingLeft = '';
|
||||
|
||||
this.trigger('change', null, previousDoc);
|
||||
}
|
||||
|
||||
doScopeSearch(event) {
|
||||
this.search(this.input.value.slice(0, this.input.selectionStart));
|
||||
if (this.doc) { $.stopEvent(event); }
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.target === this.tag) {
|
||||
this.reset();
|
||||
$.stopEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown(event) {
|
||||
if (event.which === 8) { // backspace
|
||||
if (this.doc && (this.input.selectionEnd === 0)) {
|
||||
this.reset();
|
||||
$.stopEvent(event);
|
||||
}
|
||||
} else if (!this.doc && this.input.value && !$.isChromeForAndroid()) {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) { return; }
|
||||
if ((event.which === 9) || // tab
|
||||
((event.which === 32) && app.isMobile())) { // space
|
||||
this.doScopeSearch(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTextInput(event) {
|
||||
if (!$.isChromeForAndroid()) { return; }
|
||||
if (!this.doc && this.input.value && (event.data === ' ')) {
|
||||
this.doScopeSearch(event);
|
||||
}
|
||||
}
|
||||
|
||||
extractHashValue() {
|
||||
let value;
|
||||
if (value = this.getHashValue()) {
|
||||
const newHash = $.urlDecode(location.hash).replace(`#${SEARCH_PARAM}=${value} `, `#${SEARCH_PARAM}=`);
|
||||
app.router.replaceHash(newHash);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
getHashValue() {
|
||||
try { return __guard__(HASH_RGX.exec($.urlDecode(location.hash)), x => x[1]); } catch (error) {}
|
||||
}
|
||||
|
||||
afterRoute(name, context) {
|
||||
if (!app.isSingleDoc() && context.init && context.doc) {
|
||||
this.selectDoc(context.doc);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
return Cls;
|
||||
})();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,187 +1,234 @@
|
||||
class app.views.DocList extends app.View
|
||||
@className: '_list'
|
||||
@attributes:
|
||||
role: 'navigation'
|
||||
|
||||
@events:
|
||||
open: 'onOpen'
|
||||
close: 'onClose'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.DocList = class DocList extends app.View {
|
||||
constructor(...args) {
|
||||
this.render = this.render.bind(this);
|
||||
this.onOpen = this.onOpen.bind(this);
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onEnabled = this.onEnabled.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.className = '_list';
|
||||
this.attributes =
|
||||
{role: 'navigation'};
|
||||
|
||||
this.events = {
|
||||
open: 'onOpen',
|
||||
close: 'onClose',
|
||||
click: 'onClick'
|
||||
};
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
|
||||
@elements:
|
||||
disabledTitle: '._list-title'
|
||||
this.elements = {
|
||||
disabledTitle: '._list-title',
|
||||
disabledList: '._disabled-list'
|
||||
|
||||
init: ->
|
||||
@lists = {}
|
||||
|
||||
@addSubview @listFocus = new app.views.ListFocus @el
|
||||
@addSubview @listFold = new app.views.ListFold @el
|
||||
@addSubview @listSelect = new app.views.ListSelect @el
|
||||
|
||||
app.on 'ready', @render
|
||||
return
|
||||
|
||||
activate: ->
|
||||
if super
|
||||
list.activate() for slug, list of @lists
|
||||
@listSelect.selectCurrent()
|
||||
return
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
list.deactivate() for slug, list of @lists
|
||||
return
|
||||
|
||||
render: =>
|
||||
html = ''
|
||||
for doc in app.docs.all()
|
||||
html += @tmpl('sidebarDoc', doc, fullName: app.docs.countAllBy('name', doc.name) > 1)
|
||||
@html html
|
||||
@renderDisabled() unless app.isSingleDoc() or app.disabledDocs.size() is 0
|
||||
return
|
||||
|
||||
renderDisabled: ->
|
||||
@append @tmpl('sidebarDisabled', count: app.disabledDocs.size())
|
||||
@refreshElements()
|
||||
@renderDisabledList()
|
||||
return
|
||||
|
||||
renderDisabledList: ->
|
||||
if app.settings.get('hideDisabled')
|
||||
@removeDisabledList()
|
||||
else
|
||||
@appendDisabledList()
|
||||
return
|
||||
|
||||
appendDisabledList: ->
|
||||
html = ''
|
||||
docs = [].concat(app.disabledDocs.all()...)
|
||||
|
||||
while doc = docs.shift()
|
||||
if doc.version?
|
||||
versions = ''
|
||||
loop
|
||||
versions += @tmpl('sidebarDoc', doc, disabled: true)
|
||||
break if docs[0]?.name isnt doc.name
|
||||
doc = docs.shift()
|
||||
html += @tmpl('sidebarDisabledVersionedDoc', doc, versions)
|
||||
else
|
||||
html += @tmpl('sidebarDoc', doc, disabled: true)
|
||||
|
||||
@append @tmpl('sidebarDisabledList', html)
|
||||
@disabledTitle.classList.add('open-title')
|
||||
@refreshElements()
|
||||
return
|
||||
|
||||
removeDisabledList: ->
|
||||
$.remove @disabledList if @disabledList
|
||||
@disabledTitle.classList.remove('open-title')
|
||||
@refreshElements()
|
||||
return
|
||||
|
||||
reset: (options = {}) ->
|
||||
@listSelect.deselect()
|
||||
@listFocus?.blur()
|
||||
@listFold.reset()
|
||||
@revealCurrent() if options.revealCurrent || app.isSingleDoc()
|
||||
return
|
||||
|
||||
onOpen: (event) =>
|
||||
$.stopEvent(event)
|
||||
doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug')
|
||||
|
||||
if doc and not @lists[doc.slug]
|
||||
@lists[doc.slug] = if doc.types.isEmpty()
|
||||
new app.views.EntryList doc.entries.all()
|
||||
else
|
||||
new app.views.TypeList doc
|
||||
$.after event.target, @lists[doc.slug].el
|
||||
return
|
||||
|
||||
onClose: (event) =>
|
||||
$.stopEvent(event)
|
||||
doc = app.docs.findBy 'slug', event.target.getAttribute('data-slug')
|
||||
|
||||
if doc and @lists[doc.slug]
|
||||
@lists[doc.slug].detach()
|
||||
delete @lists[doc.slug]
|
||||
return
|
||||
|
||||
select: (model) ->
|
||||
@listSelect.selectByHref model?.fullPath()
|
||||
return
|
||||
|
||||
reveal: (model) ->
|
||||
@openDoc model.doc
|
||||
@openType model.getType() if model.type
|
||||
@focus model
|
||||
@paginateTo model
|
||||
@scrollTo model
|
||||
return
|
||||
|
||||
focus: (model) ->
|
||||
@listFocus?.focus @find("a[href='#{model.fullPath()}']")
|
||||
return
|
||||
|
||||
revealCurrent: ->
|
||||
if model = app.router.context.type or app.router.context.entry
|
||||
@reveal model
|
||||
@select model
|
||||
return
|
||||
|
||||
openDoc: (doc) ->
|
||||
@listFold.open @find("[data-slug='#{doc.slug_without_version}']") if app.disabledDocs.contains(doc) and doc.version
|
||||
@listFold.open @find("[data-slug='#{doc.slug}']")
|
||||
return
|
||||
|
||||
closeDoc: (doc) ->
|
||||
@listFold.close @find("[data-slug='#{doc.slug}']")
|
||||
return
|
||||
|
||||
openType: (type) ->
|
||||
@listFold.open @lists[type.doc.slug].find("[data-slug='#{type.slug}']")
|
||||
return
|
||||
|
||||
paginateTo: (model) ->
|
||||
@lists[model.doc.slug]?.paginateTo(model)
|
||||
return
|
||||
|
||||
scrollTo: (model) ->
|
||||
$.scrollTo @find("a[href='#{model.fullPath()}']"), null, 'top', margin: if app.isMobile() then 48 else 0
|
||||
return
|
||||
|
||||
toggleDisabled: ->
|
||||
if @disabledTitle.classList.contains('open-title')
|
||||
@removeDisabledList()
|
||||
app.settings.set 'hideDisabled', true
|
||||
else
|
||||
@appendDisabledList()
|
||||
app.settings.set 'hideDisabled', false
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
target = $.eventTarget(event)
|
||||
if @disabledTitle and $.hasChild(@disabledTitle, target) and target.tagName isnt 'A'
|
||||
$.stopEvent(event)
|
||||
@toggleDisabled()
|
||||
else if slug = target.getAttribute('data-enable')
|
||||
$.stopEvent(event)
|
||||
doc = app.disabledDocs.findBy('slug', slug)
|
||||
app.enableDoc(doc, @onEnabled, @onEnabled) if doc
|
||||
return
|
||||
|
||||
onEnabled: =>
|
||||
@reset()
|
||||
@render()
|
||||
return
|
||||
|
||||
afterRoute: (route, context) =>
|
||||
if context.init
|
||||
@reset revealCurrent: true if @activated
|
||||
else
|
||||
@select context.type or context.entry
|
||||
return
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.lists = {};
|
||||
|
||||
this.addSubview(this.listFocus = new app.views.ListFocus(this.el));
|
||||
this.addSubview(this.listFold = new app.views.ListFold(this.el));
|
||||
this.addSubview(this.listSelect = new app.views.ListSelect(this.el));
|
||||
|
||||
app.on('ready', this.render);
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (super.activate(...arguments)) {
|
||||
for (var slug in this.lists) { var list = this.lists[slug]; list.activate(); }
|
||||
this.listSelect.selectCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
for (var slug in this.lists) { var list = this.lists[slug]; list.deactivate(); }
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let html = '';
|
||||
for (var doc of Array.from(app.docs.all())) {
|
||||
html += this.tmpl('sidebarDoc', doc, {fullName: app.docs.countAllBy('name', doc.name) > 1});
|
||||
}
|
||||
this.html(html);
|
||||
if (!app.isSingleDoc() && (app.disabledDocs.size() !== 0)) { this.renderDisabled(); }
|
||||
}
|
||||
|
||||
renderDisabled() {
|
||||
this.append(this.tmpl('sidebarDisabled', {count: app.disabledDocs.size()}));
|
||||
this.refreshElements();
|
||||
this.renderDisabledList();
|
||||
}
|
||||
|
||||
renderDisabledList() {
|
||||
if (app.settings.get('hideDisabled')) {
|
||||
this.removeDisabledList();
|
||||
} else {
|
||||
this.appendDisabledList();
|
||||
}
|
||||
}
|
||||
|
||||
appendDisabledList() {
|
||||
let doc;
|
||||
let html = '';
|
||||
const docs = [].concat(...Array.from(app.disabledDocs.all() || []));
|
||||
|
||||
while ((doc = docs.shift())) {
|
||||
if (doc.version != null) {
|
||||
var versions = '';
|
||||
while (true) {
|
||||
versions += this.tmpl('sidebarDoc', doc, {disabled: true});
|
||||
if ((docs[0] != null ? docs[0].name : undefined) !== doc.name) { break; }
|
||||
doc = docs.shift();
|
||||
}
|
||||
html += this.tmpl('sidebarDisabledVersionedDoc', doc, versions);
|
||||
} else {
|
||||
html += this.tmpl('sidebarDoc', doc, {disabled: true});
|
||||
}
|
||||
}
|
||||
|
||||
this.append(this.tmpl('sidebarDisabledList', html));
|
||||
this.disabledTitle.classList.add('open-title');
|
||||
this.refreshElements();
|
||||
}
|
||||
|
||||
removeDisabledList() {
|
||||
if (this.disabledList) { $.remove(this.disabledList); }
|
||||
this.disabledTitle.classList.remove('open-title');
|
||||
this.refreshElements();
|
||||
}
|
||||
|
||||
reset(options) {
|
||||
if (options == null) { options = {}; }
|
||||
this.listSelect.deselect();
|
||||
if (this.listFocus != null) {
|
||||
this.listFocus.blur();
|
||||
}
|
||||
this.listFold.reset();
|
||||
if (options.revealCurrent || app.isSingleDoc()) { this.revealCurrent(); }
|
||||
}
|
||||
|
||||
onOpen(event) {
|
||||
$.stopEvent(event);
|
||||
const doc = app.docs.findBy('slug', event.target.getAttribute('data-slug'));
|
||||
|
||||
if (doc && !this.lists[doc.slug]) {
|
||||
this.lists[doc.slug] = doc.types.isEmpty() ?
|
||||
new app.views.EntryList(doc.entries.all())
|
||||
:
|
||||
new app.views.TypeList(doc);
|
||||
$.after(event.target, this.lists[doc.slug].el);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
$.stopEvent(event);
|
||||
const doc = app.docs.findBy('slug', event.target.getAttribute('data-slug'));
|
||||
|
||||
if (doc && this.lists[doc.slug]) {
|
||||
this.lists[doc.slug].detach();
|
||||
delete this.lists[doc.slug];
|
||||
}
|
||||
}
|
||||
|
||||
select(model) {
|
||||
this.listSelect.selectByHref(model != null ? model.fullPath() : undefined);
|
||||
}
|
||||
|
||||
reveal(model) {
|
||||
this.openDoc(model.doc);
|
||||
if (model.type) { this.openType(model.getType()); }
|
||||
this.focus(model);
|
||||
this.paginateTo(model);
|
||||
this.scrollTo(model);
|
||||
}
|
||||
|
||||
focus(model) {
|
||||
if (this.listFocus != null) {
|
||||
this.listFocus.focus(this.find(`a[href='${model.fullPath()}']`));
|
||||
}
|
||||
}
|
||||
|
||||
revealCurrent() {
|
||||
let model;
|
||||
if (model = app.router.context.type || app.router.context.entry) {
|
||||
this.reveal(model);
|
||||
this.select(model);
|
||||
}
|
||||
}
|
||||
|
||||
openDoc(doc) {
|
||||
if (app.disabledDocs.contains(doc) && doc.version) { this.listFold.open(this.find(`[data-slug='${doc.slug_without_version}']`)); }
|
||||
this.listFold.open(this.find(`[data-slug='${doc.slug}']`));
|
||||
}
|
||||
|
||||
closeDoc(doc) {
|
||||
this.listFold.close(this.find(`[data-slug='${doc.slug}']`));
|
||||
}
|
||||
|
||||
openType(type) {
|
||||
this.listFold.open(this.lists[type.doc.slug].find(`[data-slug='${type.slug}']`));
|
||||
}
|
||||
|
||||
paginateTo(model) {
|
||||
if (this.lists[model.doc.slug] != null) {
|
||||
this.lists[model.doc.slug].paginateTo(model);
|
||||
}
|
||||
}
|
||||
|
||||
scrollTo(model) {
|
||||
$.scrollTo(this.find(`a[href='${model.fullPath()}']`), null, 'top', {margin: app.isMobile() ? 48 : 0});
|
||||
}
|
||||
|
||||
toggleDisabled() {
|
||||
if (this.disabledTitle.classList.contains('open-title')) {
|
||||
this.removeDisabledList();
|
||||
app.settings.set('hideDisabled', true);
|
||||
} else {
|
||||
this.appendDisabledList();
|
||||
app.settings.set('hideDisabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
let slug;
|
||||
const target = $.eventTarget(event);
|
||||
if (this.disabledTitle && $.hasChild(this.disabledTitle, target) && (target.tagName !== 'A')) {
|
||||
$.stopEvent(event);
|
||||
this.toggleDisabled();
|
||||
} else if (slug = target.getAttribute('data-enable')) {
|
||||
$.stopEvent(event);
|
||||
const doc = app.disabledDocs.findBy('slug', slug);
|
||||
if (doc) { app.enableDoc(doc, this.onEnabled, this.onEnabled); }
|
||||
}
|
||||
}
|
||||
|
||||
onEnabled() {
|
||||
this.reset();
|
||||
this.render();
|
||||
}
|
||||
|
||||
afterRoute(route, context) {
|
||||
if (context.init) {
|
||||
if (this.activated) { this.reset({revealCurrent: true}); }
|
||||
} else {
|
||||
this.select(context.type || context.entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,88 +1,130 @@
|
||||
class app.views.DocPicker extends app.View
|
||||
@className: '_list _list-picker'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.DocPicker = class DocPicker extends app.View {
|
||||
constructor(...args) {
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onDOMFocus = this.onDOMFocus.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
@events:
|
||||
mousedown: 'onMouseDown'
|
||||
static initClass() {
|
||||
this.className = '_list _list-picker';
|
||||
|
||||
this.events = {
|
||||
mousedown: 'onMouseDown',
|
||||
mouseup: 'onMouseUp'
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addSubview(this.listFold = new app.views.ListFold(this.el));
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (super.activate(...arguments)) {
|
||||
this.render();
|
||||
$.on(this.el, 'focus', this.onDOMFocus, true);
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
$.off(this.el, 'focus', this.onDOMFocus, true);
|
||||
this.focusEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let doc;
|
||||
let html = this.tmpl('docPickerHeader');
|
||||
let docs = app.docs.all().concat(...Array.from(app.disabledDocs.all() || []));
|
||||
|
||||
while ((doc = docs.shift())) {
|
||||
if (doc.version != null) {
|
||||
var versions;
|
||||
[docs, versions] = Array.from(this.extractVersions(docs, doc));
|
||||
html += this.tmpl('sidebarVersionedDoc', doc, this.renderVersions(versions), {open: app.docs.contains(doc)});
|
||||
} else {
|
||||
html += this.tmpl('sidebarLabel', doc, {checked: app.docs.contains(doc)});
|
||||
}
|
||||
}
|
||||
|
||||
this.html(html + this.tmpl('docPickerNote'));
|
||||
|
||||
$.requestAnimationFrame(() => __guard__(this.findByTag('input'), x => x.focus()));
|
||||
}
|
||||
|
||||
renderVersions(docs) {
|
||||
let html = '';
|
||||
for (var doc of Array.from(docs)) { html += this.tmpl('sidebarLabel', doc, {checked: app.docs.contains(doc)}); }
|
||||
return html;
|
||||
}
|
||||
|
||||
extractVersions(originalDocs, version) {
|
||||
const docs = [];
|
||||
const versions = [version];
|
||||
for (var doc of Array.from(originalDocs)) {
|
||||
(doc.name === version.name ? versions : docs).push(doc);
|
||||
}
|
||||
return [docs, versions];
|
||||
}
|
||||
|
||||
empty() {
|
||||
this.resetClass();
|
||||
super.empty(...arguments);
|
||||
}
|
||||
|
||||
getSelectedDocs() {
|
||||
return Array.from(this.findAllByTag('input')).filter((input) => (input != null ? input.checked : undefined)).map((input) =>
|
||||
input.name);
|
||||
}
|
||||
|
||||
onMouseDown() {
|
||||
this.mouseDown = Date.now();
|
||||
}
|
||||
|
||||
onMouseUp() {
|
||||
this.mouseUp = Date.now();
|
||||
}
|
||||
|
||||
onDOMFocus(event) {
|
||||
const {
|
||||
target
|
||||
} = event;
|
||||
if (target.tagName === 'INPUT') {
|
||||
if ((!this.mouseDown || !(Date.now() < (this.mouseDown + 100))) && (!this.mouseUp || !(Date.now() < (this.mouseUp + 100)))) {
|
||||
$.scrollTo(target.parentNode, null, 'continuous');
|
||||
}
|
||||
} else if (target.classList.contains(app.views.ListFold.targetClass)) {
|
||||
target.blur();
|
||||
if (!this.mouseDown || !(Date.now() < (this.mouseDown + 100))) {
|
||||
if (this.focusEl === $('input', target.nextElementSibling)) {
|
||||
if (target.classList.contains(app.views.ListFold.activeClass)) { this.listFold.close(target); }
|
||||
let prev = target.previousElementSibling;
|
||||
while ((prev.tagName !== 'LABEL') && !prev.classList.contains(app.views.ListFold.targetClass)) { prev = prev.previousElementSibling; }
|
||||
if (prev.classList.contains(app.views.ListFold.activeClass)) { prev = $.makeArray($$('input', prev.nextElementSibling)).pop(); }
|
||||
this.delay(() => prev.focus());
|
||||
} else {
|
||||
if (!target.classList.contains(app.views.ListFold.activeClass)) { this.listFold.open(target); }
|
||||
this.delay(() => $('input', target.nextElementSibling).focus());
|
||||
}
|
||||
}
|
||||
}
|
||||
this.focusEl = target;
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
init: ->
|
||||
@addSubview @listFold = new app.views.ListFold(@el)
|
||||
return
|
||||
|
||||
activate: ->
|
||||
if super
|
||||
@render()
|
||||
$.on @el, 'focus', @onDOMFocus, true
|
||||
return
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
$.off @el, 'focus', @onDOMFocus, true
|
||||
@focusEl = null
|
||||
return
|
||||
|
||||
render: ->
|
||||
html = @tmpl('docPickerHeader')
|
||||
docs = app.docs.all().concat(app.disabledDocs.all()...)
|
||||
|
||||
while doc = docs.shift()
|
||||
if doc.version?
|
||||
[docs, versions] = @extractVersions(docs, doc)
|
||||
html += @tmpl('sidebarVersionedDoc', doc, @renderVersions(versions), open: app.docs.contains(doc))
|
||||
else
|
||||
html += @tmpl('sidebarLabel', doc, checked: app.docs.contains(doc))
|
||||
|
||||
@html html + @tmpl('docPickerNote')
|
||||
|
||||
$.requestAnimationFrame => @findByTag('input')?.focus()
|
||||
return
|
||||
|
||||
renderVersions: (docs) ->
|
||||
html = ''
|
||||
html += @tmpl('sidebarLabel', doc, checked: app.docs.contains(doc)) for doc in docs
|
||||
html
|
||||
|
||||
extractVersions: (originalDocs, version) ->
|
||||
docs = []
|
||||
versions = [version]
|
||||
for doc in originalDocs
|
||||
(if doc.name is version.name then versions else docs).push(doc)
|
||||
[docs, versions]
|
||||
|
||||
empty: ->
|
||||
@resetClass()
|
||||
super
|
||||
return
|
||||
|
||||
getSelectedDocs: ->
|
||||
for input in @findAllByTag 'input' when input?.checked
|
||||
input.name
|
||||
|
||||
onMouseDown: =>
|
||||
@mouseDown = Date.now()
|
||||
return
|
||||
|
||||
onMouseUp: =>
|
||||
@mouseUp = Date.now()
|
||||
return
|
||||
|
||||
onDOMFocus: (event) =>
|
||||
target = event.target
|
||||
if target.tagName is 'INPUT'
|
||||
unless (@mouseDown and Date.now() < @mouseDown + 100) or (@mouseUp and Date.now() < @mouseUp + 100)
|
||||
$.scrollTo target.parentNode, null, 'continuous'
|
||||
else if target.classList.contains(app.views.ListFold.targetClass)
|
||||
target.blur()
|
||||
unless @mouseDown and Date.now() < @mouseDown + 100
|
||||
if @focusEl is $('input', target.nextElementSibling)
|
||||
@listFold.close(target) if target.classList.contains(app.views.ListFold.activeClass)
|
||||
prev = target.previousElementSibling
|
||||
prev = prev.previousElementSibling until prev.tagName is 'LABEL' or prev.classList.contains(app.views.ListFold.targetClass)
|
||||
prev = $.makeArray($$('input', prev.nextElementSibling)).pop() if prev.classList.contains(app.views.ListFold.activeClass)
|
||||
@delay -> prev.focus()
|
||||
else
|
||||
@listFold.open(target) unless target.classList.contains(app.views.ListFold.activeClass)
|
||||
@delay -> $('input', target.nextElementSibling).focus()
|
||||
@focusEl = target
|
||||
return
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,15 +1,27 @@
|
||||
#= require views/list/paginated_list
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
//= require views/list/paginated_list
|
||||
|
||||
class app.views.EntryList extends app.views.PaginatedList
|
||||
@tagName: 'div'
|
||||
@className: '_list _list-sub'
|
||||
const Cls = (app.views.EntryList = class EntryList extends app.views.PaginatedList {
|
||||
static initClass() {
|
||||
this.tagName = 'div';
|
||||
this.className = '_list _list-sub';
|
||||
}
|
||||
|
||||
constructor: (@entries) -> super
|
||||
constructor(entries) { this.entries = entries; super(...arguments); }
|
||||
|
||||
init: ->
|
||||
@renderPaginated()
|
||||
@activate()
|
||||
return
|
||||
init() {
|
||||
this.renderPaginated();
|
||||
this.activate();
|
||||
}
|
||||
|
||||
render: (entries) ->
|
||||
@tmpl 'sidebarEntry', entries
|
||||
render(entries) {
|
||||
return this.tmpl('sidebarEntry', entries);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,68 +1,93 @@
|
||||
class app.views.Results extends app.View
|
||||
@className: '_list'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Results = class Results extends app.View {
|
||||
static initClass() {
|
||||
this.className = '_list';
|
||||
|
||||
@events:
|
||||
click: 'onClick'
|
||||
this.events =
|
||||
{click: 'onClick'};
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
}
|
||||
|
||||
constructor: (@sidebar, @search) -> super
|
||||
constructor(sidebar, search) { this.onResults = this.onResults.bind(this); this.onNoResults = this.onNoResults.bind(this); this.onClear = this.onClear.bind(this); this.afterRoute = this.afterRoute.bind(this); this.onClick = this.onClick.bind(this); this.sidebar = sidebar; this.search = search; super(...arguments); }
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
@empty()
|
||||
return
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
this.empty();
|
||||
}
|
||||
}
|
||||
|
||||
init: ->
|
||||
@addSubview @listFocus = new app.views.ListFocus @el
|
||||
@addSubview @listSelect = new app.views.ListSelect @el
|
||||
init() {
|
||||
this.addSubview(this.listFocus = new app.views.ListFocus(this.el));
|
||||
this.addSubview(this.listSelect = new app.views.ListSelect(this.el));
|
||||
|
||||
@search
|
||||
.on 'results', @onResults
|
||||
.on 'noresults', @onNoResults
|
||||
.on 'clear', @onClear
|
||||
return
|
||||
this.search
|
||||
.on('results', this.onResults)
|
||||
.on('noresults', this.onNoResults)
|
||||
.on('clear', this.onClear);
|
||||
}
|
||||
|
||||
onResults: (entries, flags) =>
|
||||
@listFocus?.blur() if flags.initialResults
|
||||
@empty() if flags.initialResults
|
||||
@append @tmpl('sidebarResult', entries)
|
||||
onResults(entries, flags) {
|
||||
if (flags.initialResults) { if (this.listFocus != null) {
|
||||
this.listFocus.blur();
|
||||
} }
|
||||
if (flags.initialResults) { this.empty(); }
|
||||
this.append(this.tmpl('sidebarResult', entries));
|
||||
|
||||
if flags.initialResults
|
||||
if flags.urlSearch then @openFirst() else @focusFirst()
|
||||
return
|
||||
if (flags.initialResults) {
|
||||
if (flags.urlSearch) { this.openFirst(); } else { this.focusFirst(); }
|
||||
}
|
||||
}
|
||||
|
||||
onNoResults: =>
|
||||
@html @tmpl('sidebarNoResults')
|
||||
return
|
||||
onNoResults() {
|
||||
this.html(this.tmpl('sidebarNoResults'));
|
||||
}
|
||||
|
||||
onClear: =>
|
||||
@empty()
|
||||
return
|
||||
onClear() {
|
||||
this.empty();
|
||||
}
|
||||
|
||||
focusFirst: ->
|
||||
@listFocus?.focusOnNextFrame @el.firstElementChild unless app.isMobile()
|
||||
return
|
||||
focusFirst() {
|
||||
if (!app.isMobile()) { if (this.listFocus != null) {
|
||||
this.listFocus.focusOnNextFrame(this.el.firstElementChild);
|
||||
} }
|
||||
}
|
||||
|
||||
openFirst: ->
|
||||
@el.firstElementChild?.click()
|
||||
return
|
||||
openFirst() {
|
||||
if (this.el.firstElementChild != null) {
|
||||
this.el.firstElementChild.click();
|
||||
}
|
||||
}
|
||||
|
||||
onDocEnabled: (doc) ->
|
||||
app.router.show(doc.fullPath())
|
||||
@sidebar.onDocEnabled()
|
||||
onDocEnabled(doc) {
|
||||
app.router.show(doc.fullPath());
|
||||
return this.sidebar.onDocEnabled();
|
||||
}
|
||||
|
||||
afterRoute: (route, context) =>
|
||||
if route is 'entry'
|
||||
@listSelect.selectByHref context.entry.fullPath()
|
||||
else
|
||||
@listSelect.deselect()
|
||||
return
|
||||
afterRoute(route, context) {
|
||||
if (route === 'entry') {
|
||||
this.listSelect.selectByHref(context.entry.fullPath());
|
||||
} else {
|
||||
this.listSelect.deselect();
|
||||
}
|
||||
}
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1
|
||||
if slug = $.eventTarget(event).getAttribute('data-enable')
|
||||
$.stopEvent(event)
|
||||
doc = app.disabledDocs.findBy('slug', slug)
|
||||
app.enableDoc(doc, @onDocEnabled.bind(@, doc), $.noop) if doc
|
||||
onClick(event) {
|
||||
let slug;
|
||||
if (event.which !== 1) { return; }
|
||||
if (slug = $.eventTarget(event).getAttribute('data-enable')) {
|
||||
$.stopEvent(event);
|
||||
const doc = app.disabledDocs.findBy('slug', slug);
|
||||
if (doc) { return app.enableDoc(doc, this.onDocEnabled.bind(this, doc), $.noop); }
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
@ -1,164 +1,217 @@
|
||||
class app.views.Sidebar extends app.View
|
||||
@el: '._sidebar'
|
||||
|
||||
@events:
|
||||
focus: 'onFocus'
|
||||
select: 'onSelect'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.Sidebar = class Sidebar extends app.View {
|
||||
constructor(...args) {
|
||||
this.resetHoverOnMouseMove = this.resetHoverOnMouseMove.bind(this);
|
||||
this.resetHover = this.resetHover.bind(this);
|
||||
this.showResults = this.showResults.bind(this);
|
||||
this.onReady = this.onReady.bind(this);
|
||||
this.onScopeChange = this.onScopeChange.bind(this);
|
||||
this.onSearching = this.onSearching.bind(this);
|
||||
this.onSearchClear = this.onSearchClear.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onSelect = this.onSelect.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onAltR = this.onAltR.bind(this);
|
||||
this.onEscape = this.onEscape.bind(this);
|
||||
this.afterRoute = this.afterRoute.bind(this);
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static initClass() {
|
||||
this.el = '._sidebar';
|
||||
|
||||
this.events = {
|
||||
focus: 'onFocus',
|
||||
select: 'onSelect',
|
||||
click: 'onClick'
|
||||
};
|
||||
|
||||
@routes:
|
||||
after: 'afterRoute'
|
||||
this.routes =
|
||||
{after: 'afterRoute'};
|
||||
|
||||
@shortcuts:
|
||||
altR: 'onAltR'
|
||||
this.shortcuts = {
|
||||
altR: 'onAltR',
|
||||
escape: 'onEscape'
|
||||
};
|
||||
}
|
||||
|
||||
init: ->
|
||||
@addSubview @hover = new app.views.SidebarHover @el unless app.isMobile()
|
||||
@addSubview @search = new app.views.Search
|
||||
init() {
|
||||
if (!app.isMobile()) { this.addSubview(this.hover = new app.views.SidebarHover(this.el)); }
|
||||
this.addSubview(this.search = new app.views.Search);
|
||||
|
||||
@search
|
||||
.on 'searching', @onSearching
|
||||
.on 'clear', @onSearchClear
|
||||
this.search
|
||||
.on('searching', this.onSearching)
|
||||
.on('clear', this.onSearchClear)
|
||||
.scope
|
||||
.on 'change', @onScopeChange
|
||||
|
||||
@results = new app.views.Results @, @search
|
||||
@docList = new app.views.DocList
|
||||
|
||||
app.on 'ready', @onReady
|
||||
|
||||
$.on document.documentElement, 'mouseleave', => @hide()
|
||||
$.on document.documentElement, 'mouseenter', => @resetDisplay(forceNoHover: false)
|
||||
return
|
||||
|
||||
hide: ->
|
||||
@removeClass 'show'
|
||||
return
|
||||
|
||||
display: ->
|
||||
@addClass 'show'
|
||||
return
|
||||
|
||||
resetDisplay: (options = {}) ->
|
||||
return unless @hasClass 'show'
|
||||
@removeClass 'show'
|
||||
|
||||
unless options.forceNoHover is false or @hasClass 'no-hover'
|
||||
@addClass 'no-hover'
|
||||
$.on window, 'mousemove', @resetHoverOnMouseMove
|
||||
return
|
||||
|
||||
resetHoverOnMouseMove: =>
|
||||
$.off window, 'mousemove', @resetHoverOnMouseMove
|
||||
$.requestAnimationFrame @resetHover
|
||||
|
||||
resetHover: =>
|
||||
@removeClass 'no-hover'
|
||||
|
||||
showView: (view) ->
|
||||
unless @view is view
|
||||
@hover?.hide()
|
||||
@saveScrollPosition()
|
||||
@view?.deactivate()
|
||||
@view = view
|
||||
@render()
|
||||
@view.activate()
|
||||
@restoreScrollPosition()
|
||||
return
|
||||
|
||||
render: ->
|
||||
@html @view
|
||||
return
|
||||
|
||||
showDocList: ->
|
||||
@showView @docList
|
||||
return
|
||||
|
||||
showResults: =>
|
||||
@display()
|
||||
@showView @results
|
||||
return
|
||||
|
||||
reset: ->
|
||||
@display()
|
||||
@showDocList()
|
||||
@docList.reset()
|
||||
@search.reset()
|
||||
return
|
||||
|
||||
onReady: =>
|
||||
@view = @docList
|
||||
@render()
|
||||
@view.activate()
|
||||
return
|
||||
|
||||
onScopeChange: (newDoc, previousDoc) =>
|
||||
@docList.closeDoc(previousDoc) if previousDoc
|
||||
if newDoc then @docList.reveal(newDoc.toEntry()) else @scrollToTop()
|
||||
return
|
||||
|
||||
saveScrollPosition: ->
|
||||
if @view is @docList
|
||||
@scrollTop = @el.scrollTop
|
||||
return
|
||||
|
||||
restoreScrollPosition: ->
|
||||
if @view is @docList and @scrollTop
|
||||
@el.scrollTop = @scrollTop
|
||||
@scrollTop = null
|
||||
else
|
||||
@scrollToTop()
|
||||
return
|
||||
|
||||
scrollToTop: ->
|
||||
@el.scrollTop = 0
|
||||
return
|
||||
|
||||
onSearching: =>
|
||||
@showResults()
|
||||
return
|
||||
|
||||
onSearchClear: =>
|
||||
@resetDisplay()
|
||||
@showDocList()
|
||||
return
|
||||
|
||||
onFocus: (event) =>
|
||||
@display()
|
||||
$.scrollTo event.target, @el, 'continuous', bottomGap: 2 unless event.target is @el
|
||||
return
|
||||
|
||||
onSelect: =>
|
||||
@resetDisplay()
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
return if event.which isnt 1
|
||||
if $.eventTarget(event).hasAttribute? 'data-reset-list'
|
||||
$.stopEvent(event)
|
||||
@onAltR()
|
||||
return
|
||||
|
||||
onAltR: =>
|
||||
@reset()
|
||||
@docList.reset(revealCurrent: true)
|
||||
@display()
|
||||
return
|
||||
|
||||
onEscape: =>
|
||||
@reset()
|
||||
@resetDisplay()
|
||||
if doc = @search.getScopeDoc() then @docList.reveal(doc.toEntry()) else @scrollToTop()
|
||||
return
|
||||
|
||||
onDocEnabled: ->
|
||||
@docList.onEnabled()
|
||||
@reset()
|
||||
return
|
||||
|
||||
afterRoute: (name, context) =>
|
||||
return if app.shortcuts.eventInProgress?.name is 'escape'
|
||||
@reset() if not context.init and app.router.isIndex()
|
||||
@resetDisplay()
|
||||
return
|
||||
.on('change', this.onScopeChange);
|
||||
|
||||
this.results = new app.views.Results(this, this.search);
|
||||
this.docList = new app.views.DocList;
|
||||
|
||||
app.on('ready', this.onReady);
|
||||
|
||||
$.on(document.documentElement, 'mouseleave', () => this.hide());
|
||||
$.on(document.documentElement, 'mouseenter', () => this.resetDisplay({forceNoHover: false}));
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.removeClass('show');
|
||||
}
|
||||
|
||||
display() {
|
||||
this.addClass('show');
|
||||
}
|
||||
|
||||
resetDisplay(options) {
|
||||
if (options == null) { options = {}; }
|
||||
if (!this.hasClass('show')) { return; }
|
||||
this.removeClass('show');
|
||||
|
||||
if ((options.forceNoHover !== false) && !this.hasClass('no-hover')) {
|
||||
this.addClass('no-hover');
|
||||
$.on(window, 'mousemove', this.resetHoverOnMouseMove);
|
||||
}
|
||||
}
|
||||
|
||||
resetHoverOnMouseMove() {
|
||||
$.off(window, 'mousemove', this.resetHoverOnMouseMove);
|
||||
return $.requestAnimationFrame(this.resetHover);
|
||||
}
|
||||
|
||||
resetHover() {
|
||||
return this.removeClass('no-hover');
|
||||
}
|
||||
|
||||
showView(view) {
|
||||
if (this.view !== view) {
|
||||
if (this.hover != null) {
|
||||
this.hover.hide();
|
||||
}
|
||||
this.saveScrollPosition();
|
||||
if (this.view != null) {
|
||||
this.view.deactivate();
|
||||
}
|
||||
this.view = view;
|
||||
this.render();
|
||||
this.view.activate();
|
||||
this.restoreScrollPosition();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.html(this.view);
|
||||
}
|
||||
|
||||
showDocList() {
|
||||
this.showView(this.docList);
|
||||
}
|
||||
|
||||
showResults() {
|
||||
this.display();
|
||||
this.showView(this.results);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.display();
|
||||
this.showDocList();
|
||||
this.docList.reset();
|
||||
this.search.reset();
|
||||
}
|
||||
|
||||
onReady() {
|
||||
this.view = this.docList;
|
||||
this.render();
|
||||
this.view.activate();
|
||||
}
|
||||
|
||||
onScopeChange(newDoc, previousDoc) {
|
||||
if (previousDoc) { this.docList.closeDoc(previousDoc); }
|
||||
if (newDoc) { this.docList.reveal(newDoc.toEntry()); } else { this.scrollToTop(); }
|
||||
}
|
||||
|
||||
saveScrollPosition() {
|
||||
if (this.view === this.docList) {
|
||||
this.scrollTop = this.el.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
restoreScrollPosition() {
|
||||
if ((this.view === this.docList) && this.scrollTop) {
|
||||
this.el.scrollTop = this.scrollTop;
|
||||
this.scrollTop = null;
|
||||
} else {
|
||||
this.scrollToTop();
|
||||
}
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.el.scrollTop = 0;
|
||||
}
|
||||
|
||||
onSearching() {
|
||||
this.showResults();
|
||||
}
|
||||
|
||||
onSearchClear() {
|
||||
this.resetDisplay();
|
||||
this.showDocList();
|
||||
}
|
||||
|
||||
onFocus(event) {
|
||||
this.display();
|
||||
if (event.target !== this.el) { $.scrollTo(event.target, this.el, 'continuous', {bottomGap: 2}); }
|
||||
}
|
||||
|
||||
onSelect() {
|
||||
this.resetDisplay();
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.which !== 1) { return; }
|
||||
if (__guardMethod__($.eventTarget(event), 'hasAttribute', o => o.hasAttribute('data-reset-list'))) {
|
||||
$.stopEvent(event);
|
||||
this.onAltR();
|
||||
}
|
||||
}
|
||||
|
||||
onAltR() {
|
||||
this.reset();
|
||||
this.docList.reset({revealCurrent: true});
|
||||
this.display();
|
||||
}
|
||||
|
||||
onEscape() {
|
||||
let doc;
|
||||
this.reset();
|
||||
this.resetDisplay();
|
||||
if ((doc = this.search.getScopeDoc())) { this.docList.reveal(doc.toEntry()); } else { this.scrollToTop(); }
|
||||
}
|
||||
|
||||
onDocEnabled() {
|
||||
this.docList.onEnabled();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
afterRoute(name, context) {
|
||||
if ((app.shortcuts.eventInProgress != null ? app.shortcuts.eventInProgress.name : undefined) === 'escape') { return; }
|
||||
if (!context.init && app.router.isIndex()) { this.reset(); }
|
||||
this.resetDisplay();
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guardMethod__(obj, methodName, transform) {
|
||||
if (typeof obj !== 'undefined' && obj !== null && typeof obj[methodName] === 'function') {
|
||||
return transform(obj, methodName);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -1,100 +1,143 @@
|
||||
class app.views.SidebarHover extends app.View
|
||||
@itemClass: '_list-hover'
|
||||
|
||||
@events:
|
||||
focus: 'onFocus'
|
||||
blur: 'onBlur'
|
||||
mouseover: 'onMouseover'
|
||||
mouseout: 'onMouseout'
|
||||
scroll: 'onScroll'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.SidebarHover = class SidebarHover extends app.View {
|
||||
static initClass() {
|
||||
this.itemClass = '_list-hover';
|
||||
|
||||
this.events = {
|
||||
focus: 'onFocus',
|
||||
blur: 'onBlur',
|
||||
mouseover: 'onMouseover',
|
||||
mouseout: 'onMouseout',
|
||||
scroll: 'onScroll',
|
||||
click: 'onClick'
|
||||
|
||||
@routes:
|
||||
after: 'onRoute'
|
||||
|
||||
constructor: (@el) ->
|
||||
unless isPointerEventsSupported()
|
||||
delete @constructor.events.mouseover
|
||||
super
|
||||
|
||||
show: (el) ->
|
||||
unless el is @cursor
|
||||
@hide()
|
||||
if @isTarget(el) and @isTruncated(el.lastElementChild or el)
|
||||
@cursor = el
|
||||
@clone = @makeClone @cursor
|
||||
$.append document.body, @clone
|
||||
@offsetTop ?= @el.offsetTop
|
||||
@position()
|
||||
return
|
||||
|
||||
hide: ->
|
||||
if @cursor
|
||||
$.remove @clone
|
||||
@cursor = @clone = null
|
||||
return
|
||||
|
||||
position: =>
|
||||
if @cursor
|
||||
rect = $.rect(@cursor)
|
||||
if rect.top >= @offsetTop
|
||||
@clone.style.top = rect.top + 'px'
|
||||
@clone.style.left = rect.left + 'px'
|
||||
else
|
||||
@hide()
|
||||
return
|
||||
|
||||
makeClone: (el) ->
|
||||
clone = el.cloneNode(true)
|
||||
clone.classList.add 'clone'
|
||||
clone
|
||||
|
||||
isTarget: (el) ->
|
||||
el?.classList?.contains @constructor.itemClass
|
||||
|
||||
isSelected: (el) ->
|
||||
el.classList.contains 'active'
|
||||
|
||||
isTruncated: (el) ->
|
||||
el.scrollWidth > el.offsetWidth
|
||||
|
||||
onFocus: (event) =>
|
||||
@focusTime = Date.now()
|
||||
@show event.target
|
||||
return
|
||||
|
||||
onBlur: =>
|
||||
@hide()
|
||||
return
|
||||
|
||||
onMouseover: (event) =>
|
||||
if @isTarget(event.target) and not @isSelected(event.target) and @mouseActivated()
|
||||
@show event.target
|
||||
return
|
||||
|
||||
onMouseout: (event) =>
|
||||
if @isTarget(event.target) and @mouseActivated()
|
||||
@hide()
|
||||
return
|
||||
|
||||
mouseActivated: ->
|
||||
# Skip mouse events caused by focus events scrolling the sidebar.
|
||||
not @focusTime or Date.now() - @focusTime > 500
|
||||
|
||||
onScroll: =>
|
||||
@position()
|
||||
return
|
||||
|
||||
onClick: (event) =>
|
||||
if event.target is @clone
|
||||
$.click @cursor
|
||||
return
|
||||
|
||||
onRoute: =>
|
||||
@hide()
|
||||
return
|
||||
|
||||
isPointerEventsSupported = ->
|
||||
el = document.createElement 'div'
|
||||
el.style.cssText = 'pointer-events: auto'
|
||||
el.style.pointerEvents is 'auto'
|
||||
};
|
||||
|
||||
this.routes =
|
||||
{after: 'onRoute'};
|
||||
}
|
||||
|
||||
constructor(el) {
|
||||
this.position = this.position.bind(this);
|
||||
this.onFocus = this.onFocus.bind(this);
|
||||
this.onBlur = this.onBlur.bind(this);
|
||||
this.onMouseover = this.onMouseover.bind(this);
|
||||
this.onMouseout = this.onMouseout.bind(this);
|
||||
this.onScroll = this.onScroll.bind(this);
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onRoute = this.onRoute.bind(this);
|
||||
this.el = el;
|
||||
if (!isPointerEventsSupported()) {
|
||||
delete this.constructor.events.mouseover;
|
||||
}
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
show(el) {
|
||||
if (el !== this.cursor) {
|
||||
this.hide();
|
||||
if (this.isTarget(el) && this.isTruncated(el.lastElementChild || el)) {
|
||||
this.cursor = el;
|
||||
this.clone = this.makeClone(this.cursor);
|
||||
$.append(document.body, this.clone);
|
||||
if (this.offsetTop == null) { this.offsetTop = this.el.offsetTop; }
|
||||
this.position();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.cursor) {
|
||||
$.remove(this.clone);
|
||||
this.cursor = (this.clone = null);
|
||||
}
|
||||
}
|
||||
|
||||
position() {
|
||||
if (this.cursor) {
|
||||
const rect = $.rect(this.cursor);
|
||||
if (rect.top >= this.offsetTop) {
|
||||
this.clone.style.top = rect.top + 'px';
|
||||
this.clone.style.left = rect.left + 'px';
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
makeClone(el) {
|
||||
const clone = el.cloneNode(true);
|
||||
clone.classList.add('clone');
|
||||
return clone;
|
||||
}
|
||||
|
||||
isTarget(el) {
|
||||
return __guard__(el != null ? el.classList : undefined, x => x.contains(this.constructor.itemClass));
|
||||
}
|
||||
|
||||
isSelected(el) {
|
||||
return el.classList.contains('active');
|
||||
}
|
||||
|
||||
isTruncated(el) {
|
||||
return el.scrollWidth > el.offsetWidth;
|
||||
}
|
||||
|
||||
onFocus(event) {
|
||||
this.focusTime = Date.now();
|
||||
this.show(event.target);
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
onMouseover(event) {
|
||||
if (this.isTarget(event.target) && !this.isSelected(event.target) && this.mouseActivated()) {
|
||||
this.show(event.target);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseout(event) {
|
||||
if (this.isTarget(event.target) && this.mouseActivated()) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
mouseActivated() {
|
||||
// Skip mouse events caused by focus events scrolling the sidebar.
|
||||
return !this.focusTime || ((Date.now() - this.focusTime) > 500);
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
this.position();
|
||||
}
|
||||
|
||||
onClick(event) {
|
||||
if (event.target === this.clone) {
|
||||
$.click(this.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
onRoute() {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
var isPointerEventsSupported = function() {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'pointer-events: auto';
|
||||
return el.style.pointerEvents === 'auto';
|
||||
};
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,53 +1,77 @@
|
||||
class app.views.TypeList extends app.View
|
||||
@tagName: 'div'
|
||||
@className: '_list _list-sub'
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS002: Fix invalid constructor
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS103: Rewrite code to no longer use __guard__, or convert again using --optional-chaining
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.views.TypeList = class TypeList extends app.View {
|
||||
static initClass() {
|
||||
this.tagName = 'div';
|
||||
this.className = '_list _list-sub';
|
||||
|
||||
@events:
|
||||
open: 'onOpen'
|
||||
this.events = {
|
||||
open: 'onOpen',
|
||||
close: 'onClose'
|
||||
};
|
||||
}
|
||||
|
||||
constructor: (@doc) -> super
|
||||
|
||||
init: ->
|
||||
@lists = {}
|
||||
@render()
|
||||
@activate()
|
||||
return
|
||||
|
||||
activate: ->
|
||||
if super
|
||||
list.activate() for slug, list of @lists
|
||||
return
|
||||
|
||||
deactivate: ->
|
||||
if super
|
||||
list.deactivate() for slug, list of @lists
|
||||
return
|
||||
|
||||
render: ->
|
||||
html = ''
|
||||
html += @tmpl('sidebarType', group) for group in @doc.types.groups()
|
||||
@html(html)
|
||||
|
||||
onOpen: (event) =>
|
||||
$.stopEvent(event)
|
||||
type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug')
|
||||
|
||||
if type and not @lists[type.slug]
|
||||
@lists[type.slug] = new app.views.EntryList(type.entries())
|
||||
$.after event.target, @lists[type.slug].el
|
||||
return
|
||||
|
||||
onClose: (event) =>
|
||||
$.stopEvent(event)
|
||||
type = @doc.types.findBy 'slug', event.target.getAttribute('data-slug')
|
||||
|
||||
if type and @lists[type.slug]
|
||||
@lists[type.slug].detach()
|
||||
delete @lists[type.slug]
|
||||
return
|
||||
|
||||
paginateTo: (model) ->
|
||||
if model.type
|
||||
@lists[model.getType().slug]?.paginateTo(model)
|
||||
return
|
||||
constructor(doc) { this.onOpen = this.onOpen.bind(this); this.onClose = this.onClose.bind(this); this.doc = doc; super(...arguments); }
|
||||
|
||||
init() {
|
||||
this.lists = {};
|
||||
this.render();
|
||||
this.activate();
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (super.activate(...arguments)) {
|
||||
for (var slug in this.lists) { var list = this.lists[slug]; list.activate(); }
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (super.deactivate(...arguments)) {
|
||||
for (var slug in this.lists) { var list = this.lists[slug]; list.deactivate(); }
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let html = '';
|
||||
for (var group of Array.from(this.doc.types.groups())) { html += this.tmpl('sidebarType', group); }
|
||||
return this.html(html);
|
||||
}
|
||||
|
||||
onOpen(event) {
|
||||
$.stopEvent(event);
|
||||
const type = this.doc.types.findBy('slug', event.target.getAttribute('data-slug'));
|
||||
|
||||
if (type && !this.lists[type.slug]) {
|
||||
this.lists[type.slug] = new app.views.EntryList(type.entries());
|
||||
$.after(event.target, this.lists[type.slug].el);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(event) {
|
||||
$.stopEvent(event);
|
||||
const type = this.doc.types.findBy('slug', event.target.getAttribute('data-slug'));
|
||||
|
||||
if (type && this.lists[type.slug]) {
|
||||
this.lists[type.slug].detach();
|
||||
delete this.lists[type.slug];
|
||||
}
|
||||
}
|
||||
|
||||
paginateTo(model) {
|
||||
if (model.type) {
|
||||
__guard__(this.lists[model.getType().slug], x => x.paginateTo(model));
|
||||
}
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
||||
function __guard__(value, transform) {
|
||||
return (typeof value !== 'undefined' && value !== null) ? transform(value) : undefined;
|
||||
}
|
@ -1,172 +1,214 @@
|
||||
class app.View
|
||||
$.extend @prototype, Events
|
||||
|
||||
constructor: ->
|
||||
@setupElement()
|
||||
@originalClassName = @el.className if @el.className
|
||||
@resetClass() if @constructor.className
|
||||
@refreshElements()
|
||||
@init?()
|
||||
@refreshElements()
|
||||
|
||||
setupElement: ->
|
||||
@el ?= if typeof @constructor.el is 'string'
|
||||
$ @constructor.el
|
||||
else if @constructor.el
|
||||
@constructor.el
|
||||
else
|
||||
document.createElement @constructor.tagName or 'div'
|
||||
|
||||
if @constructor.attributes
|
||||
for key, value of @constructor.attributes
|
||||
@el.setAttribute(key, value)
|
||||
return
|
||||
|
||||
refreshElements: ->
|
||||
if @constructor.elements
|
||||
@[name] = @find selector for name, selector of @constructor.elements
|
||||
return
|
||||
|
||||
addClass: (name) ->
|
||||
@el.classList.add(name)
|
||||
return
|
||||
|
||||
removeClass: (name) ->
|
||||
@el.classList.remove(name)
|
||||
return
|
||||
|
||||
toggleClass: (name) ->
|
||||
@el.classList.toggle(name)
|
||||
return
|
||||
|
||||
hasClass: (name) ->
|
||||
@el.classList.contains(name)
|
||||
|
||||
resetClass: ->
|
||||
@el.className = @originalClassName or ''
|
||||
if @constructor.className
|
||||
@addClass name for name in @constructor.className.split ' '
|
||||
return
|
||||
|
||||
find: (selector) ->
|
||||
$ selector, @el
|
||||
|
||||
findAll: (selector) ->
|
||||
$$ selector, @el
|
||||
|
||||
findByClass: (name) ->
|
||||
@findAllByClass(name)[0]
|
||||
|
||||
findLastByClass: (name) ->
|
||||
all = @findAllByClass(name)[0]
|
||||
all[all.length - 1]
|
||||
|
||||
findAllByClass: (name) ->
|
||||
@el.getElementsByClassName(name)
|
||||
|
||||
findByTag: (tag) ->
|
||||
@findAllByTag(tag)[0]
|
||||
|
||||
findLastByTag: (tag) ->
|
||||
all = @findAllByTag(tag)
|
||||
all[all.length - 1]
|
||||
|
||||
findAllByTag: (tag) ->
|
||||
@el.getElementsByTagName(tag)
|
||||
|
||||
append: (value) ->
|
||||
$.append @el, value.el or value
|
||||
return
|
||||
|
||||
appendTo: (value) ->
|
||||
$.append value.el or value, @el
|
||||
return
|
||||
|
||||
prepend: (value) ->
|
||||
$.prepend @el, value.el or value
|
||||
return
|
||||
|
||||
prependTo: (value) ->
|
||||
$.prepend value.el or value, @el
|
||||
return
|
||||
|
||||
before: (value) ->
|
||||
$.before @el, value.el or value
|
||||
return
|
||||
|
||||
after: (value) ->
|
||||
$.after @el, value.el or value
|
||||
return
|
||||
|
||||
remove: (value) ->
|
||||
$.remove value.el or value
|
||||
return
|
||||
|
||||
empty: ->
|
||||
$.empty @el
|
||||
@refreshElements()
|
||||
return
|
||||
|
||||
html: (value) ->
|
||||
@empty()
|
||||
@append value
|
||||
return
|
||||
|
||||
tmpl: (args...) ->
|
||||
app.templates.render(args...)
|
||||
|
||||
delay: (fn, args...) ->
|
||||
delay = if typeof args[args.length - 1] is 'number' then args.pop() else 0
|
||||
setTimeout fn.bind(@, args...), delay
|
||||
|
||||
onDOM: (event, callback) ->
|
||||
$.on @el, event, callback
|
||||
return
|
||||
|
||||
offDOM: (event, callback) ->
|
||||
$.off @el, event, callback
|
||||
return
|
||||
|
||||
bindEvents: ->
|
||||
if @constructor.events
|
||||
@onDOM name, @[method] for name, method of @constructor.events
|
||||
|
||||
if @constructor.routes
|
||||
app.router.on name, @[method] for name, method of @constructor.routes
|
||||
|
||||
if @constructor.shortcuts
|
||||
app.shortcuts.on name, @[method] for name, method of @constructor.shortcuts
|
||||
return
|
||||
|
||||
unbindEvents: ->
|
||||
if @constructor.events
|
||||
@offDOM name, @[method] for name, method of @constructor.events
|
||||
|
||||
if @constructor.routes
|
||||
app.router.off name, @[method] for name, method of @constructor.routes
|
||||
|
||||
if @constructor.shortcuts
|
||||
app.shortcuts.off name, @[method] for name, method of @constructor.shortcuts
|
||||
return
|
||||
|
||||
addSubview: (view) ->
|
||||
(@subviews or= []).push(view)
|
||||
|
||||
activate: ->
|
||||
return if @activated
|
||||
@bindEvents()
|
||||
view.activate() for view in @subviews if @subviews
|
||||
@activated = true
|
||||
true
|
||||
|
||||
deactivate: ->
|
||||
return unless @activated
|
||||
@unbindEvents()
|
||||
view.deactivate() for view in @subviews if @subviews
|
||||
@activated = false
|
||||
true
|
||||
|
||||
detach: ->
|
||||
@deactivate()
|
||||
$.remove @el
|
||||
return
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS101: Remove unnecessary use of Array.from
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md
|
||||
*/
|
||||
const Cls = (app.View = class View {
|
||||
static initClass() {
|
||||
$.extend(this.prototype, Events);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.setupElement();
|
||||
if (this.el.className) { this.originalClassName = this.el.className; }
|
||||
if (this.constructor.className) { this.resetClass(); }
|
||||
this.refreshElements();
|
||||
if (typeof this.init === 'function') {
|
||||
this.init();
|
||||
}
|
||||
this.refreshElements();
|
||||
}
|
||||
|
||||
setupElement() {
|
||||
if (this.el == null) { this.el = typeof this.constructor.el === 'string' ?
|
||||
$(this.constructor.el)
|
||||
: this.constructor.el ?
|
||||
this.constructor.el
|
||||
:
|
||||
document.createElement(this.constructor.tagName || 'div'); }
|
||||
|
||||
if (this.constructor.attributes) {
|
||||
for (var key in this.constructor.attributes) {
|
||||
var value = this.constructor.attributes[key];
|
||||
this.el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refreshElements() {
|
||||
if (this.constructor.elements) {
|
||||
for (var name in this.constructor.elements) { var selector = this.constructor.elements[name]; this[name] = this.find(selector); }
|
||||
}
|
||||
}
|
||||
|
||||
addClass(name) {
|
||||
this.el.classList.add(name);
|
||||
}
|
||||
|
||||
removeClass(name) {
|
||||
this.el.classList.remove(name);
|
||||
}
|
||||
|
||||
toggleClass(name) {
|
||||
this.el.classList.toggle(name);
|
||||
}
|
||||
|
||||
hasClass(name) {
|
||||
return this.el.classList.contains(name);
|
||||
}
|
||||
|
||||
resetClass() {
|
||||
this.el.className = this.originalClassName || '';
|
||||
if (this.constructor.className) {
|
||||
for (var name of Array.from(this.constructor.className.split(' '))) { this.addClass(name); }
|
||||
}
|
||||
}
|
||||
|
||||
find(selector) {
|
||||
return $(selector, this.el);
|
||||
}
|
||||
|
||||
findAll(selector) {
|
||||
return $$(selector, this.el);
|
||||
}
|
||||
|
||||
findByClass(name) {
|
||||
return this.findAllByClass(name)[0];
|
||||
}
|
||||
|
||||
findLastByClass(name) {
|
||||
const all = this.findAllByClass(name)[0];
|
||||
return all[all.length - 1];
|
||||
}
|
||||
|
||||
findAllByClass(name) {
|
||||
return this.el.getElementsByClassName(name);
|
||||
}
|
||||
|
||||
findByTag(tag) {
|
||||
return this.findAllByTag(tag)[0];
|
||||
}
|
||||
|
||||
findLastByTag(tag) {
|
||||
const all = this.findAllByTag(tag);
|
||||
return all[all.length - 1];
|
||||
}
|
||||
|
||||
findAllByTag(tag) {
|
||||
return this.el.getElementsByTagName(tag);
|
||||
}
|
||||
|
||||
append(value) {
|
||||
$.append(this.el, value.el || value);
|
||||
}
|
||||
|
||||
appendTo(value) {
|
||||
$.append(value.el || value, this.el);
|
||||
}
|
||||
|
||||
prepend(value) {
|
||||
$.prepend(this.el, value.el || value);
|
||||
}
|
||||
|
||||
prependTo(value) {
|
||||
$.prepend(value.el || value, this.el);
|
||||
}
|
||||
|
||||
before(value) {
|
||||
$.before(this.el, value.el || value);
|
||||
}
|
||||
|
||||
after(value) {
|
||||
$.after(this.el, value.el || value);
|
||||
}
|
||||
|
||||
remove(value) {
|
||||
$.remove(value.el || value);
|
||||
}
|
||||
|
||||
empty() {
|
||||
$.empty(this.el);
|
||||
this.refreshElements();
|
||||
}
|
||||
|
||||
html(value) {
|
||||
this.empty();
|
||||
this.append(value);
|
||||
}
|
||||
|
||||
tmpl(...args) {
|
||||
return app.templates.render(...Array.from(args || []));
|
||||
}
|
||||
|
||||
delay(fn, ...args) {
|
||||
const delay = typeof args[args.length - 1] === 'number' ? args.pop() : 0;
|
||||
return setTimeout(fn.bind(this, ...Array.from(args)), delay);
|
||||
}
|
||||
|
||||
onDOM(event, callback) {
|
||||
$.on(this.el, event, callback);
|
||||
}
|
||||
|
||||
offDOM(event, callback) {
|
||||
$.off(this.el, event, callback);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
let method, name;
|
||||
if (this.constructor.events) {
|
||||
for (name in this.constructor.events) { method = this.constructor.events[name]; this.onDOM(name, this[method]); }
|
||||
}
|
||||
|
||||
if (this.constructor.routes) {
|
||||
for (name in this.constructor.routes) { method = this.constructor.routes[name]; app.router.on(name, this[method]); }
|
||||
}
|
||||
|
||||
if (this.constructor.shortcuts) {
|
||||
for (name in this.constructor.shortcuts) { method = this.constructor.shortcuts[name]; app.shortcuts.on(name, this[method]); }
|
||||
}
|
||||
}
|
||||
|
||||
unbindEvents() {
|
||||
let method, name;
|
||||
if (this.constructor.events) {
|
||||
for (name in this.constructor.events) { method = this.constructor.events[name]; this.offDOM(name, this[method]); }
|
||||
}
|
||||
|
||||
if (this.constructor.routes) {
|
||||
for (name in this.constructor.routes) { method = this.constructor.routes[name]; app.router.off(name, this[method]); }
|
||||
}
|
||||
|
||||
if (this.constructor.shortcuts) {
|
||||
for (name in this.constructor.shortcuts) { method = this.constructor.shortcuts[name]; app.shortcuts.off(name, this[method]); }
|
||||
}
|
||||
}
|
||||
|
||||
addSubview(view) {
|
||||
return (this.subviews || (this.subviews = [])).push(view);
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (this.activated) { return; }
|
||||
this.bindEvents();
|
||||
if (this.subviews) { for (var view of Array.from(this.subviews)) { view.activate(); } }
|
||||
this.activated = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (!this.activated) { return; }
|
||||
this.unbindEvents();
|
||||
if (this.subviews) { for (var view of Array.from(this.subviews)) { view.deactivate(); } }
|
||||
this.activated = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.deactivate();
|
||||
$.remove(this.el);
|
||||
}
|
||||
});
|
||||
Cls.initClass();
|
||||
|
Loading…
Reference in new issue