From 6cc430ffc41ed1eaee031858059cb5ec359da939 Mon Sep 17 00:00:00 2001 From: decaffeinate Date: Sat, 6 Jan 2024 11:46:56 +0100 Subject: [PATCH 01/92] decaffeinate: Rename app.coffee and 75 other files from .coffee to .js --- assets/javascripts/app/{app.coffee => app.js} | 0 assets/javascripts/app/{db.coffee => db.js} | 0 assets/javascripts/app/{router.coffee => router.js} | 0 assets/javascripts/app/{searcher.coffee => searcher.js} | 0 assets/javascripts/app/{serviceworker.coffee => serviceworker.js} | 0 assets/javascripts/app/{settings.coffee => settings.js} | 0 assets/javascripts/app/{shortcuts.coffee => shortcuts.js} | 0 .../javascripts/app/{update_checker.coffee => update_checker.js} | 0 assets/javascripts/{application.js.coffee => application.js.js} | 0 .../javascripts/collections/{collection.coffee => collection.js} | 0 assets/javascripts/collections/{docs.coffee => docs.js} | 0 assets/javascripts/collections/{entries.coffee => entries.js} | 0 assets/javascripts/collections/{types.coffee => types.js} | 0 assets/javascripts/{debug.js.coffee => debug.js.js} | 0 assets/javascripts/lib/{ajax.coffee => ajax.js} | 0 assets/javascripts/lib/{cookies_store.coffee => cookies_store.js} | 0 assets/javascripts/lib/{events.coffee => events.js} | 0 assets/javascripts/lib/{favicon.coffee => favicon.js} | 0 assets/javascripts/lib/{license.coffee => license.js} | 0 .../lib/{local_storage_store.coffee => local_storage_store.js} | 0 assets/javascripts/lib/{page.coffee => page.js} | 0 assets/javascripts/lib/{util.coffee => util.js} | 0 assets/javascripts/models/{doc.coffee => doc.js} | 0 assets/javascripts/models/{entry.coffee => entry.js} | 0 assets/javascripts/models/{model.coffee => model.js} | 0 assets/javascripts/models/{type.coffee => type.js} | 0 assets/javascripts/templates/{base.coffee => base.js} | 0 assets/javascripts/templates/{error_tmpl.coffee => error_tmpl.js} | 0 .../javascripts/templates/{notice_tmpl.coffee => notice_tmpl.js} | 0 assets/javascripts/templates/{notif_tmpl.coffee => notif_tmpl.js} | 0 .../templates/pages/{about_tmpl.coffee => about_tmpl.js} | 0 .../templates/pages/{help_tmpl.coffee => help_tmpl.js} | 0 .../templates/pages/{offline_tmpl.coffee => offline_tmpl.js} | 0 .../templates/pages/{settings_tmpl.coffee => settings_tmpl.js} | 0 .../templates/pages/{type_tmpl.coffee => type_tmpl.js} | 0 assets/javascripts/templates/{path_tmpl.coffee => path_tmpl.js} | 0 .../templates/{sidebar_tmpl.coffee => sidebar_tmpl.js} | 0 assets/javascripts/templates/{tip_tmpl.coffee => tip_tmpl.js} | 0 assets/javascripts/views/content/{content.coffee => content.js} | 0 .../views/content/{entry_page.coffee => entry_page.js} | 0 .../views/content/{offline_page.coffee => offline_page.js} | 0 .../javascripts/views/content/{root_page.coffee => root_page.js} | 0 .../views/content/{settings_page.coffee => settings_page.js} | 0 .../views/content/{static_page.coffee => static_page.js} | 0 .../javascripts/views/content/{type_page.coffee => type_page.js} | 0 assets/javascripts/views/layout/{document.coffee => document.js} | 0 assets/javascripts/views/layout/{menu.coffee => menu.js} | 0 assets/javascripts/views/layout/{mobile.coffee => mobile.js} | 0 assets/javascripts/views/layout/{path.coffee => path.js} | 0 assets/javascripts/views/layout/{resizer.coffee => resizer.js} | 0 assets/javascripts/views/layout/{settings.coffee => settings.js} | 0 .../javascripts/views/list/{list_focus.coffee => list_focus.js} | 0 assets/javascripts/views/list/{list_fold.coffee => list_fold.js} | 0 .../javascripts/views/list/{list_select.coffee => list_select.js} | 0 .../views/list/{paginated_list.coffee => paginated_list.js} | 0 assets/javascripts/views/misc/{news.coffee => news.js} | 0 assets/javascripts/views/misc/{notice.coffee => notice.js} | 0 assets/javascripts/views/misc/{notif.coffee => notif.js} | 0 assets/javascripts/views/misc/{tip.coffee => tip.js} | 0 assets/javascripts/views/misc/{updates.coffee => updates.js} | 0 assets/javascripts/views/pages/{base.coffee => base.js} | 0 assets/javascripts/views/pages/{hidden.coffee => hidden.js} | 0 assets/javascripts/views/pages/{jquery.coffee => jquery.js} | 0 assets/javascripts/views/pages/{rdoc.coffee => rdoc.js} | 0 assets/javascripts/views/pages/{sqlite.coffee => sqlite.js} | 0 .../views/pages/{support_tables.coffee => support_tables.js} | 0 assets/javascripts/views/search/{search.coffee => search.js} | 0 .../views/search/{search_scope.coffee => search_scope.js} | 0 assets/javascripts/views/sidebar/{doc_list.coffee => doc_list.js} | 0 .../views/sidebar/{doc_picker.coffee => doc_picker.js} | 0 .../views/sidebar/{entry_list.coffee => entry_list.js} | 0 assets/javascripts/views/sidebar/{results.coffee => results.js} | 0 assets/javascripts/views/sidebar/{sidebar.coffee => sidebar.js} | 0 .../views/sidebar/{sidebar_hover.coffee => sidebar_hover.js} | 0 .../javascripts/views/sidebar/{type_list.coffee => type_list.js} | 0 assets/javascripts/views/{view.coffee => view.js} | 0 76 files changed, 0 insertions(+), 0 deletions(-) rename assets/javascripts/app/{app.coffee => app.js} (100%) rename assets/javascripts/app/{db.coffee => db.js} (100%) rename assets/javascripts/app/{router.coffee => router.js} (100%) rename assets/javascripts/app/{searcher.coffee => searcher.js} (100%) rename assets/javascripts/app/{serviceworker.coffee => serviceworker.js} (100%) rename assets/javascripts/app/{settings.coffee => settings.js} (100%) rename assets/javascripts/app/{shortcuts.coffee => shortcuts.js} (100%) rename assets/javascripts/app/{update_checker.coffee => update_checker.js} (100%) rename assets/javascripts/{application.js.coffee => application.js.js} (100%) rename assets/javascripts/collections/{collection.coffee => collection.js} (100%) rename assets/javascripts/collections/{docs.coffee => docs.js} (100%) rename assets/javascripts/collections/{entries.coffee => entries.js} (100%) rename assets/javascripts/collections/{types.coffee => types.js} (100%) rename assets/javascripts/{debug.js.coffee => debug.js.js} (100%) rename assets/javascripts/lib/{ajax.coffee => ajax.js} (100%) rename assets/javascripts/lib/{cookies_store.coffee => cookies_store.js} (100%) rename assets/javascripts/lib/{events.coffee => events.js} (100%) rename assets/javascripts/lib/{favicon.coffee => favicon.js} (100%) rename assets/javascripts/lib/{license.coffee => license.js} (100%) rename assets/javascripts/lib/{local_storage_store.coffee => local_storage_store.js} (100%) rename assets/javascripts/lib/{page.coffee => page.js} (100%) rename assets/javascripts/lib/{util.coffee => util.js} (100%) rename assets/javascripts/models/{doc.coffee => doc.js} (100%) rename assets/javascripts/models/{entry.coffee => entry.js} (100%) rename assets/javascripts/models/{model.coffee => model.js} (100%) rename assets/javascripts/models/{type.coffee => type.js} (100%) rename assets/javascripts/templates/{base.coffee => base.js} (100%) rename assets/javascripts/templates/{error_tmpl.coffee => error_tmpl.js} (100%) rename assets/javascripts/templates/{notice_tmpl.coffee => notice_tmpl.js} (100%) rename assets/javascripts/templates/{notif_tmpl.coffee => notif_tmpl.js} (100%) rename assets/javascripts/templates/pages/{about_tmpl.coffee => about_tmpl.js} (100%) rename assets/javascripts/templates/pages/{help_tmpl.coffee => help_tmpl.js} (100%) rename assets/javascripts/templates/pages/{offline_tmpl.coffee => offline_tmpl.js} (100%) rename assets/javascripts/templates/pages/{settings_tmpl.coffee => settings_tmpl.js} (100%) rename assets/javascripts/templates/pages/{type_tmpl.coffee => type_tmpl.js} (100%) rename assets/javascripts/templates/{path_tmpl.coffee => path_tmpl.js} (100%) rename assets/javascripts/templates/{sidebar_tmpl.coffee => sidebar_tmpl.js} (100%) rename assets/javascripts/templates/{tip_tmpl.coffee => tip_tmpl.js} (100%) rename assets/javascripts/views/content/{content.coffee => content.js} (100%) rename assets/javascripts/views/content/{entry_page.coffee => entry_page.js} (100%) rename assets/javascripts/views/content/{offline_page.coffee => offline_page.js} (100%) rename assets/javascripts/views/content/{root_page.coffee => root_page.js} (100%) rename assets/javascripts/views/content/{settings_page.coffee => settings_page.js} (100%) rename assets/javascripts/views/content/{static_page.coffee => static_page.js} (100%) rename assets/javascripts/views/content/{type_page.coffee => type_page.js} (100%) rename assets/javascripts/views/layout/{document.coffee => document.js} (100%) rename assets/javascripts/views/layout/{menu.coffee => menu.js} (100%) rename assets/javascripts/views/layout/{mobile.coffee => mobile.js} (100%) rename assets/javascripts/views/layout/{path.coffee => path.js} (100%) rename assets/javascripts/views/layout/{resizer.coffee => resizer.js} (100%) rename assets/javascripts/views/layout/{settings.coffee => settings.js} (100%) rename assets/javascripts/views/list/{list_focus.coffee => list_focus.js} (100%) rename assets/javascripts/views/list/{list_fold.coffee => list_fold.js} (100%) rename assets/javascripts/views/list/{list_select.coffee => list_select.js} (100%) rename assets/javascripts/views/list/{paginated_list.coffee => paginated_list.js} (100%) rename assets/javascripts/views/misc/{news.coffee => news.js} (100%) rename assets/javascripts/views/misc/{notice.coffee => notice.js} (100%) rename assets/javascripts/views/misc/{notif.coffee => notif.js} (100%) rename assets/javascripts/views/misc/{tip.coffee => tip.js} (100%) rename assets/javascripts/views/misc/{updates.coffee => updates.js} (100%) rename assets/javascripts/views/pages/{base.coffee => base.js} (100%) rename assets/javascripts/views/pages/{hidden.coffee => hidden.js} (100%) rename assets/javascripts/views/pages/{jquery.coffee => jquery.js} (100%) rename assets/javascripts/views/pages/{rdoc.coffee => rdoc.js} (100%) rename assets/javascripts/views/pages/{sqlite.coffee => sqlite.js} (100%) rename assets/javascripts/views/pages/{support_tables.coffee => support_tables.js} (100%) rename assets/javascripts/views/search/{search.coffee => search.js} (100%) rename assets/javascripts/views/search/{search_scope.coffee => search_scope.js} (100%) rename assets/javascripts/views/sidebar/{doc_list.coffee => doc_list.js} (100%) rename assets/javascripts/views/sidebar/{doc_picker.coffee => doc_picker.js} (100%) rename assets/javascripts/views/sidebar/{entry_list.coffee => entry_list.js} (100%) rename assets/javascripts/views/sidebar/{results.coffee => results.js} (100%) rename assets/javascripts/views/sidebar/{sidebar.coffee => sidebar.js} (100%) rename assets/javascripts/views/sidebar/{sidebar_hover.coffee => sidebar_hover.js} (100%) rename assets/javascripts/views/sidebar/{type_list.coffee => type_list.js} (100%) rename assets/javascripts/views/{view.coffee => view.js} (100%) diff --git a/assets/javascripts/app/app.coffee b/assets/javascripts/app/app.js similarity index 100% rename from assets/javascripts/app/app.coffee rename to assets/javascripts/app/app.js diff --git a/assets/javascripts/app/db.coffee b/assets/javascripts/app/db.js similarity index 100% rename from assets/javascripts/app/db.coffee rename to assets/javascripts/app/db.js diff --git a/assets/javascripts/app/router.coffee b/assets/javascripts/app/router.js similarity index 100% rename from assets/javascripts/app/router.coffee rename to assets/javascripts/app/router.js diff --git a/assets/javascripts/app/searcher.coffee b/assets/javascripts/app/searcher.js similarity index 100% rename from assets/javascripts/app/searcher.coffee rename to assets/javascripts/app/searcher.js diff --git a/assets/javascripts/app/serviceworker.coffee b/assets/javascripts/app/serviceworker.js similarity index 100% rename from assets/javascripts/app/serviceworker.coffee rename to assets/javascripts/app/serviceworker.js diff --git a/assets/javascripts/app/settings.coffee b/assets/javascripts/app/settings.js similarity index 100% rename from assets/javascripts/app/settings.coffee rename to assets/javascripts/app/settings.js diff --git a/assets/javascripts/app/shortcuts.coffee b/assets/javascripts/app/shortcuts.js similarity index 100% rename from assets/javascripts/app/shortcuts.coffee rename to assets/javascripts/app/shortcuts.js diff --git a/assets/javascripts/app/update_checker.coffee b/assets/javascripts/app/update_checker.js similarity index 100% rename from assets/javascripts/app/update_checker.coffee rename to assets/javascripts/app/update_checker.js diff --git a/assets/javascripts/application.js.coffee b/assets/javascripts/application.js.js similarity index 100% rename from assets/javascripts/application.js.coffee rename to assets/javascripts/application.js.js diff --git a/assets/javascripts/collections/collection.coffee b/assets/javascripts/collections/collection.js similarity index 100% rename from assets/javascripts/collections/collection.coffee rename to assets/javascripts/collections/collection.js diff --git a/assets/javascripts/collections/docs.coffee b/assets/javascripts/collections/docs.js similarity index 100% rename from assets/javascripts/collections/docs.coffee rename to assets/javascripts/collections/docs.js diff --git a/assets/javascripts/collections/entries.coffee b/assets/javascripts/collections/entries.js similarity index 100% rename from assets/javascripts/collections/entries.coffee rename to assets/javascripts/collections/entries.js diff --git a/assets/javascripts/collections/types.coffee b/assets/javascripts/collections/types.js similarity index 100% rename from assets/javascripts/collections/types.coffee rename to assets/javascripts/collections/types.js diff --git a/assets/javascripts/debug.js.coffee b/assets/javascripts/debug.js.js similarity index 100% rename from assets/javascripts/debug.js.coffee rename to assets/javascripts/debug.js.js diff --git a/assets/javascripts/lib/ajax.coffee b/assets/javascripts/lib/ajax.js similarity index 100% rename from assets/javascripts/lib/ajax.coffee rename to assets/javascripts/lib/ajax.js diff --git a/assets/javascripts/lib/cookies_store.coffee b/assets/javascripts/lib/cookies_store.js similarity index 100% rename from assets/javascripts/lib/cookies_store.coffee rename to assets/javascripts/lib/cookies_store.js diff --git a/assets/javascripts/lib/events.coffee b/assets/javascripts/lib/events.js similarity index 100% rename from assets/javascripts/lib/events.coffee rename to assets/javascripts/lib/events.js diff --git a/assets/javascripts/lib/favicon.coffee b/assets/javascripts/lib/favicon.js similarity index 100% rename from assets/javascripts/lib/favicon.coffee rename to assets/javascripts/lib/favicon.js diff --git a/assets/javascripts/lib/license.coffee b/assets/javascripts/lib/license.js similarity index 100% rename from assets/javascripts/lib/license.coffee rename to assets/javascripts/lib/license.js diff --git a/assets/javascripts/lib/local_storage_store.coffee b/assets/javascripts/lib/local_storage_store.js similarity index 100% rename from assets/javascripts/lib/local_storage_store.coffee rename to assets/javascripts/lib/local_storage_store.js diff --git a/assets/javascripts/lib/page.coffee b/assets/javascripts/lib/page.js similarity index 100% rename from assets/javascripts/lib/page.coffee rename to assets/javascripts/lib/page.js diff --git a/assets/javascripts/lib/util.coffee b/assets/javascripts/lib/util.js similarity index 100% rename from assets/javascripts/lib/util.coffee rename to assets/javascripts/lib/util.js diff --git a/assets/javascripts/models/doc.coffee b/assets/javascripts/models/doc.js similarity index 100% rename from assets/javascripts/models/doc.coffee rename to assets/javascripts/models/doc.js diff --git a/assets/javascripts/models/entry.coffee b/assets/javascripts/models/entry.js similarity index 100% rename from assets/javascripts/models/entry.coffee rename to assets/javascripts/models/entry.js diff --git a/assets/javascripts/models/model.coffee b/assets/javascripts/models/model.js similarity index 100% rename from assets/javascripts/models/model.coffee rename to assets/javascripts/models/model.js diff --git a/assets/javascripts/models/type.coffee b/assets/javascripts/models/type.js similarity index 100% rename from assets/javascripts/models/type.coffee rename to assets/javascripts/models/type.js diff --git a/assets/javascripts/templates/base.coffee b/assets/javascripts/templates/base.js similarity index 100% rename from assets/javascripts/templates/base.coffee rename to assets/javascripts/templates/base.js diff --git a/assets/javascripts/templates/error_tmpl.coffee b/assets/javascripts/templates/error_tmpl.js similarity index 100% rename from assets/javascripts/templates/error_tmpl.coffee rename to assets/javascripts/templates/error_tmpl.js diff --git a/assets/javascripts/templates/notice_tmpl.coffee b/assets/javascripts/templates/notice_tmpl.js similarity index 100% rename from assets/javascripts/templates/notice_tmpl.coffee rename to assets/javascripts/templates/notice_tmpl.js diff --git a/assets/javascripts/templates/notif_tmpl.coffee b/assets/javascripts/templates/notif_tmpl.js similarity index 100% rename from assets/javascripts/templates/notif_tmpl.coffee rename to assets/javascripts/templates/notif_tmpl.js diff --git a/assets/javascripts/templates/pages/about_tmpl.coffee b/assets/javascripts/templates/pages/about_tmpl.js similarity index 100% rename from assets/javascripts/templates/pages/about_tmpl.coffee rename to assets/javascripts/templates/pages/about_tmpl.js diff --git a/assets/javascripts/templates/pages/help_tmpl.coffee b/assets/javascripts/templates/pages/help_tmpl.js similarity index 100% rename from assets/javascripts/templates/pages/help_tmpl.coffee rename to assets/javascripts/templates/pages/help_tmpl.js diff --git a/assets/javascripts/templates/pages/offline_tmpl.coffee b/assets/javascripts/templates/pages/offline_tmpl.js similarity index 100% rename from assets/javascripts/templates/pages/offline_tmpl.coffee rename to assets/javascripts/templates/pages/offline_tmpl.js diff --git a/assets/javascripts/templates/pages/settings_tmpl.coffee b/assets/javascripts/templates/pages/settings_tmpl.js similarity index 100% rename from assets/javascripts/templates/pages/settings_tmpl.coffee rename to assets/javascripts/templates/pages/settings_tmpl.js diff --git a/assets/javascripts/templates/pages/type_tmpl.coffee b/assets/javascripts/templates/pages/type_tmpl.js similarity index 100% rename from assets/javascripts/templates/pages/type_tmpl.coffee rename to assets/javascripts/templates/pages/type_tmpl.js diff --git a/assets/javascripts/templates/path_tmpl.coffee b/assets/javascripts/templates/path_tmpl.js similarity index 100% rename from assets/javascripts/templates/path_tmpl.coffee rename to assets/javascripts/templates/path_tmpl.js diff --git a/assets/javascripts/templates/sidebar_tmpl.coffee b/assets/javascripts/templates/sidebar_tmpl.js similarity index 100% rename from assets/javascripts/templates/sidebar_tmpl.coffee rename to assets/javascripts/templates/sidebar_tmpl.js diff --git a/assets/javascripts/templates/tip_tmpl.coffee b/assets/javascripts/templates/tip_tmpl.js similarity index 100% rename from assets/javascripts/templates/tip_tmpl.coffee rename to assets/javascripts/templates/tip_tmpl.js diff --git a/assets/javascripts/views/content/content.coffee b/assets/javascripts/views/content/content.js similarity index 100% rename from assets/javascripts/views/content/content.coffee rename to assets/javascripts/views/content/content.js diff --git a/assets/javascripts/views/content/entry_page.coffee b/assets/javascripts/views/content/entry_page.js similarity index 100% rename from assets/javascripts/views/content/entry_page.coffee rename to assets/javascripts/views/content/entry_page.js diff --git a/assets/javascripts/views/content/offline_page.coffee b/assets/javascripts/views/content/offline_page.js similarity index 100% rename from assets/javascripts/views/content/offline_page.coffee rename to assets/javascripts/views/content/offline_page.js diff --git a/assets/javascripts/views/content/root_page.coffee b/assets/javascripts/views/content/root_page.js similarity index 100% rename from assets/javascripts/views/content/root_page.coffee rename to assets/javascripts/views/content/root_page.js diff --git a/assets/javascripts/views/content/settings_page.coffee b/assets/javascripts/views/content/settings_page.js similarity index 100% rename from assets/javascripts/views/content/settings_page.coffee rename to assets/javascripts/views/content/settings_page.js diff --git a/assets/javascripts/views/content/static_page.coffee b/assets/javascripts/views/content/static_page.js similarity index 100% rename from assets/javascripts/views/content/static_page.coffee rename to assets/javascripts/views/content/static_page.js diff --git a/assets/javascripts/views/content/type_page.coffee b/assets/javascripts/views/content/type_page.js similarity index 100% rename from assets/javascripts/views/content/type_page.coffee rename to assets/javascripts/views/content/type_page.js diff --git a/assets/javascripts/views/layout/document.coffee b/assets/javascripts/views/layout/document.js similarity index 100% rename from assets/javascripts/views/layout/document.coffee rename to assets/javascripts/views/layout/document.js diff --git a/assets/javascripts/views/layout/menu.coffee b/assets/javascripts/views/layout/menu.js similarity index 100% rename from assets/javascripts/views/layout/menu.coffee rename to assets/javascripts/views/layout/menu.js diff --git a/assets/javascripts/views/layout/mobile.coffee b/assets/javascripts/views/layout/mobile.js similarity index 100% rename from assets/javascripts/views/layout/mobile.coffee rename to assets/javascripts/views/layout/mobile.js diff --git a/assets/javascripts/views/layout/path.coffee b/assets/javascripts/views/layout/path.js similarity index 100% rename from assets/javascripts/views/layout/path.coffee rename to assets/javascripts/views/layout/path.js diff --git a/assets/javascripts/views/layout/resizer.coffee b/assets/javascripts/views/layout/resizer.js similarity index 100% rename from assets/javascripts/views/layout/resizer.coffee rename to assets/javascripts/views/layout/resizer.js diff --git a/assets/javascripts/views/layout/settings.coffee b/assets/javascripts/views/layout/settings.js similarity index 100% rename from assets/javascripts/views/layout/settings.coffee rename to assets/javascripts/views/layout/settings.js diff --git a/assets/javascripts/views/list/list_focus.coffee b/assets/javascripts/views/list/list_focus.js similarity index 100% rename from assets/javascripts/views/list/list_focus.coffee rename to assets/javascripts/views/list/list_focus.js diff --git a/assets/javascripts/views/list/list_fold.coffee b/assets/javascripts/views/list/list_fold.js similarity index 100% rename from assets/javascripts/views/list/list_fold.coffee rename to assets/javascripts/views/list/list_fold.js diff --git a/assets/javascripts/views/list/list_select.coffee b/assets/javascripts/views/list/list_select.js similarity index 100% rename from assets/javascripts/views/list/list_select.coffee rename to assets/javascripts/views/list/list_select.js diff --git a/assets/javascripts/views/list/paginated_list.coffee b/assets/javascripts/views/list/paginated_list.js similarity index 100% rename from assets/javascripts/views/list/paginated_list.coffee rename to assets/javascripts/views/list/paginated_list.js diff --git a/assets/javascripts/views/misc/news.coffee b/assets/javascripts/views/misc/news.js similarity index 100% rename from assets/javascripts/views/misc/news.coffee rename to assets/javascripts/views/misc/news.js diff --git a/assets/javascripts/views/misc/notice.coffee b/assets/javascripts/views/misc/notice.js similarity index 100% rename from assets/javascripts/views/misc/notice.coffee rename to assets/javascripts/views/misc/notice.js diff --git a/assets/javascripts/views/misc/notif.coffee b/assets/javascripts/views/misc/notif.js similarity index 100% rename from assets/javascripts/views/misc/notif.coffee rename to assets/javascripts/views/misc/notif.js diff --git a/assets/javascripts/views/misc/tip.coffee b/assets/javascripts/views/misc/tip.js similarity index 100% rename from assets/javascripts/views/misc/tip.coffee rename to assets/javascripts/views/misc/tip.js diff --git a/assets/javascripts/views/misc/updates.coffee b/assets/javascripts/views/misc/updates.js similarity index 100% rename from assets/javascripts/views/misc/updates.coffee rename to assets/javascripts/views/misc/updates.js diff --git a/assets/javascripts/views/pages/base.coffee b/assets/javascripts/views/pages/base.js similarity index 100% rename from assets/javascripts/views/pages/base.coffee rename to assets/javascripts/views/pages/base.js diff --git a/assets/javascripts/views/pages/hidden.coffee b/assets/javascripts/views/pages/hidden.js similarity index 100% rename from assets/javascripts/views/pages/hidden.coffee rename to assets/javascripts/views/pages/hidden.js diff --git a/assets/javascripts/views/pages/jquery.coffee b/assets/javascripts/views/pages/jquery.js similarity index 100% rename from assets/javascripts/views/pages/jquery.coffee rename to assets/javascripts/views/pages/jquery.js diff --git a/assets/javascripts/views/pages/rdoc.coffee b/assets/javascripts/views/pages/rdoc.js similarity index 100% rename from assets/javascripts/views/pages/rdoc.coffee rename to assets/javascripts/views/pages/rdoc.js diff --git a/assets/javascripts/views/pages/sqlite.coffee b/assets/javascripts/views/pages/sqlite.js similarity index 100% rename from assets/javascripts/views/pages/sqlite.coffee rename to assets/javascripts/views/pages/sqlite.js diff --git a/assets/javascripts/views/pages/support_tables.coffee b/assets/javascripts/views/pages/support_tables.js similarity index 100% rename from assets/javascripts/views/pages/support_tables.coffee rename to assets/javascripts/views/pages/support_tables.js diff --git a/assets/javascripts/views/search/search.coffee b/assets/javascripts/views/search/search.js similarity index 100% rename from assets/javascripts/views/search/search.coffee rename to assets/javascripts/views/search/search.js diff --git a/assets/javascripts/views/search/search_scope.coffee b/assets/javascripts/views/search/search_scope.js similarity index 100% rename from assets/javascripts/views/search/search_scope.coffee rename to assets/javascripts/views/search/search_scope.js diff --git a/assets/javascripts/views/sidebar/doc_list.coffee b/assets/javascripts/views/sidebar/doc_list.js similarity index 100% rename from assets/javascripts/views/sidebar/doc_list.coffee rename to assets/javascripts/views/sidebar/doc_list.js diff --git a/assets/javascripts/views/sidebar/doc_picker.coffee b/assets/javascripts/views/sidebar/doc_picker.js similarity index 100% rename from assets/javascripts/views/sidebar/doc_picker.coffee rename to assets/javascripts/views/sidebar/doc_picker.js diff --git a/assets/javascripts/views/sidebar/entry_list.coffee b/assets/javascripts/views/sidebar/entry_list.js similarity index 100% rename from assets/javascripts/views/sidebar/entry_list.coffee rename to assets/javascripts/views/sidebar/entry_list.js diff --git a/assets/javascripts/views/sidebar/results.coffee b/assets/javascripts/views/sidebar/results.js similarity index 100% rename from assets/javascripts/views/sidebar/results.coffee rename to assets/javascripts/views/sidebar/results.js diff --git a/assets/javascripts/views/sidebar/sidebar.coffee b/assets/javascripts/views/sidebar/sidebar.js similarity index 100% rename from assets/javascripts/views/sidebar/sidebar.coffee rename to assets/javascripts/views/sidebar/sidebar.js diff --git a/assets/javascripts/views/sidebar/sidebar_hover.coffee b/assets/javascripts/views/sidebar/sidebar_hover.js similarity index 100% rename from assets/javascripts/views/sidebar/sidebar_hover.coffee rename to assets/javascripts/views/sidebar/sidebar_hover.js diff --git a/assets/javascripts/views/sidebar/type_list.coffee b/assets/javascripts/views/sidebar/type_list.js similarity index 100% rename from assets/javascripts/views/sidebar/type_list.coffee rename to assets/javascripts/views/sidebar/type_list.js diff --git a/assets/javascripts/views/view.coffee b/assets/javascripts/views/view.js similarity index 100% rename from assets/javascripts/views/view.coffee rename to assets/javascripts/views/view.js From e4fbca722bab61f27b9f6512d8981ae3915eff5b Mon Sep 17 00:00:00 2001 From: decaffeinate Date: Sat, 6 Jan 2024 11:47:09 +0100 Subject: [PATCH 02/92] decaffeinate: Convert app.coffee and 75 other files to JS --- assets/javascripts/app/app.js | 635 +++++++------ assets/javascripts/app/db.js | 868 +++++++++-------- assets/javascripts/app/router.js | 351 ++++--- assets/javascripts/app/searcher.js | 665 +++++++------ assets/javascripts/app/serviceworker.js | 105 ++- assets/javascripts/app/settings.js | 389 ++++---- assets/javascripts/app/shortcuts.js | 452 +++++---- assets/javascripts/app/update_checker.js | 79 +- assets/javascripts/application.js.js | 49 +- assets/javascripts/collections/collection.js | 130 +-- assets/javascripts/collections/docs.js | 184 ++-- assets/javascripts/collections/entries.js | 13 +- assets/javascripts/collections/types.js | 56 +- assets/javascripts/debug.js.js | 195 ++-- assets/javascripts/lib/ajax.js | 256 ++--- assets/javascripts/lib/cookies_store.js | 94 +- assets/javascripts/lib/events.js | 73 +- assets/javascripts/lib/favicon.js | 165 ++-- assets/javascripts/lib/license.js | 4 +- assets/javascripts/lib/local_storage_store.js | 50 +- assets/javascripts/lib/page.js | 495 +++++----- assets/javascripts/lib/util.js | 886 ++++++++++-------- assets/javascripts/models/doc.js | 315 ++++--- assets/javascripts/models/entry.js | 179 ++-- assets/javascripts/models/model.js | 8 +- assets/javascripts/models/type.js | 32 +- assets/javascripts/templates/base.js | 28 +- assets/javascripts/templates/error_tmpl.js | 142 +-- assets/javascripts/templates/notice_tmpl.js | 19 +- assets/javascripts/templates/notif_tmpl.js | 127 +-- .../javascripts/templates/pages/about_tmpl.js | 181 ++-- .../javascripts/templates/pages/help_tmpl.js | 334 ++++--- .../templates/pages/offline_tmpl.js | 149 +-- .../templates/pages/settings_tmpl.js | 143 +-- .../javascripts/templates/pages/type_tmpl.js | 13 +- assets/javascripts/templates/path_tmpl.js | 18 +- assets/javascripts/templates/sidebar_tmpl.js | 119 +-- assets/javascripts/templates/tip_tmpl.js | 25 +- assets/javascripts/views/content/content.js | 448 +++++---- .../javascripts/views/content/entry_page.js | 391 ++++---- .../javascripts/views/content/offline_page.js | 190 ++-- assets/javascripts/views/content/root_page.js | 98 +- .../views/content/settings_page.js | 267 +++--- .../javascripts/views/content/static_page.js | 57 +- assets/javascripts/views/content/type_page.js | 45 +- assets/javascripts/views/layout/document.js | 194 ++-- assets/javascripts/views/layout/menu.js | 54 +- assets/javascripts/views/layout/mobile.js | 346 ++++--- assets/javascripts/views/layout/path.js | 93 +- assets/javascripts/views/layout/resizer.js | 110 ++- assets/javascripts/views/layout/settings.js | 210 +++-- assets/javascripts/views/list/list_focus.js | 301 +++--- assets/javascripts/views/list/list_fold.js | 146 +-- assets/javascripts/views/list/list_select.js | 108 ++- .../javascripts/views/list/paginated_list.js | 211 +++-- assets/javascripts/views/misc/news.js | 89 +- assets/javascripts/views/misc/notice.js | 53 +- assets/javascripts/views/misc/notif.js | 123 ++- assets/javascripts/views/misc/tip.js | 27 +- assets/javascripts/views/misc/updates.js | 90 +- assets/javascripts/views/pages/base.js | 92 +- assets/javascripts/views/pages/hidden.js | 38 +- assets/javascripts/views/pages/jquery.js | 138 +-- assets/javascripts/views/pages/rdoc.js | 33 +- assets/javascripts/views/pages/sqlite.js | 47 +- .../javascripts/views/pages/support_tables.js | 31 +- assets/javascripts/views/search/search.js | 393 ++++---- .../javascripts/views/search/search_scope.js | 315 ++++--- assets/javascripts/views/sidebar/doc_list.js | 421 +++++---- .../javascripts/views/sidebar/doc_picker.js | 218 +++-- .../javascripts/views/sidebar/entry_list.js | 34 +- assets/javascripts/views/sidebar/results.js | 135 +-- assets/javascripts/views/sidebar/sidebar.js | 379 ++++---- .../views/sidebar/sidebar_hover.js | 243 +++-- assets/javascripts/views/sidebar/type_list.js | 130 +-- assets/javascripts/views/view.js | 386 ++++---- 76 files changed, 8404 insertions(+), 6306 deletions(-) diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index b55e552c..b79b9e18 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -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; + } +} \ No newline at end of file diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 28e4b0ea..ab848964 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -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 - 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') +/* + * 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) { + 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; +})(); diff --git a/assets/javascripts/app/router.js b/assets/javascripts/app/router.js index ba25148a..8cd0607e 100644 --- a/assets/javascripts/app/router.js +++ b/assets/javascripts/app/router.js @@ -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' ] - ['*', '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') +/* + * 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 (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; +} \ No newline at end of file diff --git a/assets/javascripts/app/searcher.js b/assets/javascripts/app/searcher.js index 79f6a304..27555a16 100644 --- a/assets/javascripts/app/searcher.js +++ b/assets/javascripts/app/searcher.js @@ -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 - 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 - .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() +/* + * 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'; + } + + 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); + } + + 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(); + } +}; diff --git a/assets/javascripts/app/serviceworker.js b/assets/javascripts/app/serviceworker.js index 40235566..388bdcbf 100644 --- a/assets/javascripts/app/serviceworker.js +++ b/assets/javascripts/app/serviceworker.js @@ -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(); diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 74e32a65..aed1135f 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -1,170 +1,219 @@ -class app.Settings - PREFERENCE_KEYS = [ - 'hideDisabled' - 'hideIntro' - 'manualUpdate' - 'fastScroll' - 'arrowScroll' - 'analyticsConsent' - 'docs' - 'dark' # legacy - 'theme' - 'layout' - 'size' - 'tips' - 'noAutofocus' - 'autoInstall' - 'spaceScroll' - 'spaceTimeout' - ] - - INTERNAL_KEYS = [ - 'count' - 'schema' - 'version' - 'news' - ] - - 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 - 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 +/* + * 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', + 'spaceTimeout' + ]; + + INTERNAL_KEYS = [ + 'count', + 'schema', + 'version', + 'news' + ]; + + this.prototype.LAYOUTS = [ + '_max-width', + '_sidebar-hidden', + '_native-scrollbars', + '_text-justify-hyphenate' + ]; + + this.defaults = { + count: 0, + hideDisabled: false, + hideIntro: false, + news: 0, + manualUpdate: false, + schema: 1, + analyticsConsent: false, + theme: 'auto', + spaceScroll: 1, + spaceTimeout: 0.5 + }; + } + + 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; +} \ No newline at end of file diff --git a/assets/javascripts/app/shortcuts.js b/assets/javascripts/app/shortcuts.js index 28ddf0b8..5ae46708 100644 --- a/assets/javascripts/app/shortcuts.js +++ b/assets/javascripts/app/shortcuts.js @@ -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; +} \ No newline at end of file diff --git a/assets/javascripts/app/update_checker.js b/assets/javascripts/app/update_checker.js index 3558d6bc..09538adc 100644 --- a/assets/javascripts/app/update_checker.js +++ b/assets/javascripts/app/update_checker.js @@ -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(); + } + } +}; diff --git a/assets/javascripts/application.js.js b/assets/javascripts/application.js.js index 6bf87f1c..c06b434d 100644 --- a/assets/javascripts/application.js.js +++ b/assets/javascripts/application.js.js @@ -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); diff --git a/assets/javascripts/collections/collection.js b/assets/javascripts/collections/collection.js index b902a498..2b857c73 100644 --- a/assets/javascripts/collections/collection.js +++ b/assets/javascripts/collections/collection.js @@ -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; + } +}; diff --git a/assets/javascripts/collections/docs.js b/assets/javascripts/collections/docs.js index d76e0f07..99ef3274 100644 --- a/assets/javascripts/collections/docs.js +++ b/assets/javascripts/collections/docs.js @@ -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'; + + NORMALIZE_VERSION_RGX = /\.(\d)$/; + NORMALIZE_VERSION_SUB = '.0$1'; + + // Load models concurrently. + // It's not pretty but I didn't want to import a promise library only for this. + CONCURRENCY = 3; + } - findBySlug: (slug) -> - @findBy('slug', slug) or @findBy('slug_without_version', slug) + 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; - 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 + var next = () => { + if (i < this.models.length) { + this.models[i].load(next, fail, options); + } else if (i === ((this.models.length + CONCURRENCY) - 1)) { + onComplete(); + } + i++; + }; - # 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 + var fail = function(...args) { + if (onError) { + onError(...Array.from(args || [])); + onError = null; + } + next(); + }; - next = => - if i < @models.length - @models[i].load(next, fail, options) - else if i is @models.length + CONCURRENCY - 1 - onComplete() - i++ - return + for (let j = 0, end = CONCURRENCY, asc = 0 <= end; asc ? j < end : j > end; asc ? j++ : j--) { next(); } + } - fail = (args...) -> - if onError - onError(args...) - onError = null - next() - return + clearCache() { + for (var doc of Array.from(this.models)) { doc.clearCache(); } + } - next() for [0...CONCURRENCY] - return + uninstall(callback) { + let i = 0; + var next = () => { + if (i < this.models.length) { + this.models[i++].uninstall(next, next); + } else { + callback(); + } + }; + next(); + } - clearCache: -> - doc.clearCache() for doc in @models - 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); + }); + } - uninstall: (callback) -> - i = 0 - next = => - if i < @models.length - @models[i++].uninstall(next, next) - else - callback() - return - next() - 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); + }); + } - getInstallStatuses: (callback) -> - app.db.versions @models, (statuses) -> - if statuses - for key, value of statuses - statuses[key] = installed: !!value, mtime: value - callback(statuses) - return - return - - 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 - - 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; +})(); diff --git a/assets/javascripts/collections/entries.js b/assets/javascripts/collections/entries.js index f978b68b..9a1b6dd8 100644 --- a/assets/javascripts/collections/entries.js +++ b/assets/javascripts/collections/entries.js @@ -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(); diff --git a/assets/javascripts/collections/types.js b/assets/javascripts/collections/types.js index 8e76eeab..2d34f241 100644 --- a/assets/javascripts/collections/types.js +++ b/assets/javascripts/collections/types.js @@ -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'; + + GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i; + APPENDIX_RGX = /appendix/i; + } - groups: -> - result = [] - for type in @models - (result[@_groupFor(type)] ||= []).push(type) - result.filter (e) -> e.length > 0 + 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); + } - GUIDES_RGX = /(^|\()(guides?|tutorials?|reference|book|getting\ started|manual|examples)($|[\):])/i - APPENDIX_RGX = /appendix/i - - _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; +})(); diff --git a/assets/javascripts/debug.js.js b/assets/javascripts/debug.js.js index 032d93ac..3273ac06 100644 --- a/assets/javascripts/debug.js.js +++ b/assets/javascripts/debug.js.js @@ -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); } } + } + } + } +}; diff --git a/assets/javascripts/lib/ajax.js b/assets/javascripts/lib/ajax.js index 4138ce7b..8019f9fd 100644 --- a/assets/javascripts/lib/ajax.js +++ b/assets/javascripts/lib/ajax.js @@ -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) {} +}; diff --git a/assets/javascripts/lib/cookies_store.js b/assets/javascripts/lib/cookies_store.js index eaf1bd4f..a1a8f034 100644 --- a/assets/javascripts/lib/cookies_store.js +++ b/assets/javascripts/lib/cookies_store.js @@ -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 +/* + * 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+$/; + } - INT = /^\d+$/ + static onBlocked() {} - @onBlocked: -> + get(key) { + let value = Cookies.get(key); + if ((value != null) && INT.test(value)) { value = parseInt(value, 10); } + return value; + } - get: (key) -> - value = Cookies.get(key) - value = parseInt(value, 10) if value? and INT.test(value) - value + set(key, value) { + if (value === false) { + this.del(key); + return; + } - set: (key, value) -> - if value == false - @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)); } + } - 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); + } - del: (key) -> - Cookies.expire(key) - return + reset() { + try { + for (var cookie of Array.from(document.cookie.split(/;\s?/))) { + Cookies.expire(cookie.split('=')[0]); + } + return; + } catch (error) {} + } - 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 + 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; +})(); diff --git a/assets/javascripts/lib/events.js b/assets/javascripts/lib/events.js index 05936076..9e007e9c 100644 --- a/assets/javascripts/lib/events.js +++ b/assets/javascripts/lib/events.js @@ -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; + } +}; diff --git a/assets/javascripts/lib/favicon.js b/assets/javascripts/lib/favicon.js index 428eae45..726fb15a 100644 --- a/assets/javascripts/lib/favicon.js +++ b/assets/javascripts/lib/favicon.js @@ -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; + } +}; diff --git a/assets/javascripts/lib/license.js b/assets/javascripts/lib/license.js index c397b93b..9292ef7e 100644 --- a/assets/javascripts/lib/license.js +++ b/assets/javascripts/lib/license.js @@ -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/ -### +*/ diff --git a/assets/javascripts/lib/local_storage_store.js b/assets/javascripts/lib/local_storage_store.js index f4438c86..07bc4870 100644 --- a/assets/javascripts/lib/local_storage_store.js +++ b/assets/javascripts/lib/local_storage_store.js @@ -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) {} + } +}; diff --git a/assets/javascripts/lib/page.js b/assets/javascripts/lib/page.js index 5ad89b32..c2475042 100644 --- a/assets/javascripts/lib/page.js +++ b/assets/javascripts/lib/page.js @@ -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 -### - -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); + } + } +}; diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index 001b13de..28f73692 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -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; +} \ No newline at end of file diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index c51e13fa..8ca5b014 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -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); + } +}; diff --git a/assets/javascripts/models/entry.js b/assets/javascripts/models/entry.js index 2d07c159..99b0b525 100644 --- a/assets/javascripts/models/entry.js +++ b/assets/javascripts/models/entry.js @@ -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() { + + 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; + }; + + 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 - constructor: -> - super - @text = applyAliases(app.Searcher.normalizeString(@name)) + constructor() { + super(...arguments); + this.text = applyAliases(app.Searcher.normalizeString(this.name)); + } - 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 + 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); + } - fullPath: -> - @doc.fullPath if @isIndex() then '' else @path + fullPath() { + return this.doc.fullPath(this.isIndex() ? '' : this.path); + } - dbPath: -> - @path.replace /#.*/, '' + dbPath() { + return this.path.replace(/#.*/, ''); + } - filePath: -> - @doc.fullPath @_filePath() + filePath() { + return this.doc.fullPath(this._filePath()); + } - fileUrl: -> - @doc.fileUrl @_filePath() + fileUrl() { + return this.doc.fileUrl(this._filePath()); + } - _filePath: -> - result = @path.replace /#.*/, '' - result += '.html' unless result[-5..-1] is '.html' - result + _filePath() { + let result = this.path.replace(/#.*/, ''); + if (result.slice(-5) !== '.html') { result += '.html'; } + return result; + } - isIndex: -> - @path is 'index' + isIndex() { + return this.path === 'index'; + } - getType: -> - @doc.types.findBy 'name', @type + getType() { + return this.doc.types.findBy('name', this.type); + } - loadFile: (onSuccess, onError) -> - app.db.load(@, onSuccess, onError) - - 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 - - @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; +})(); diff --git a/assets/javascripts/models/model.js b/assets/javascripts/models/model.js index 7f157f7c..405032b6 100644 --- a/assets/javascripts/models/model.js +++ b/assets/javascripts/models/model.js @@ -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; } + } +}; diff --git a/assets/javascripts/models/type.js b/assets/javascripts/models/type.js index 6351ad16..29c30ac9 100644 --- a/assets/javascripts/models/type.js +++ b/assets/javascripts/models/type.js @@ -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() + }); + } +}; diff --git a/assets/javascripts/templates/base.js b/assets/javascripts/templates/base.js index 841d1e0b..efcf7a1b 100644 --- a/assets/javascripts/templates/base.js +++ b/assets/javascripts/templates/base.js @@ -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; + } +}; diff --git a/assets/javascripts/templates/error_tmpl.js b/assets/javascripts/templates/error_tmpl.js index 9cca1f9d..c5b8ac62 100644 --- a/assets/javascripts/templates/error_tmpl.js +++ b/assets/javascripts/templates/error_tmpl.js @@ -1,73 +1,85 @@ -error = (title, text = '', links = '') -> - text = """

#{text}

""" if text - links = """""" if links - """

#{title}

#{text}#{links}
""" +/* + * 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 + * Full docs: https://github.com/decaffeinate/decaffeinate/blob/main/docs/suggestions.md + */ +const error = function(title, text, links) { + if (text == null) { text = ''; } + if (links == null) { links = ''; } + if (text) { text = `

${text}

`; } + if (links) { links = ``; } + return `

${title}

${text}${links}
`; +}; -back = 'Go back' +const back = 'Go back'; -app.templates.notFoundPage = -> - error """ Page not found. """, - """ It may be missing from the source documentation or this could be a bug. """, - back +app.templates.notFoundPage = () => error(" Page not found. ", + " It may be missing from the source documentation or this could be a bug. ", + back); -app.templates.pageLoadError = -> - error """ The page failed to load. """, - """ It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
- If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """, - """ #{back} · Reload - · Retry """ +app.templates.pageLoadError = () => error(" The page failed to load. ", + ` It may be missing from the server (try reloading the app) or you could be offline (try installing the documentation for offline usage when online again).
+If you're online and you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. `, + ` ${back} · ReloadRetry ` +); -app.templates.bootError = -> - error """ The app failed to load. """, - """ Check your Internet connection and try reloading.
- If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. """ +app.templates.bootError = () => error(" The app failed to load. ", + ` Check your Internet connection and try reloading.
+If you keep seeing this, you're likely behind a proxy or firewall that blocks cross-domain requests. ` +); -app.templates.offlineError = (reason, exception) -> - if reason is 'cookie_blocked' - return error """ Cookies must be enabled to use offline mode. """ +app.templates.offlineError = function(reason, exception) { + if (reason === 'cookie_blocked') { + return error(" Cookies must be enabled to use offline mode. "); + } - reason = switch reason - when 'not_supported' - """ DevDocs requires IndexedDB to cache documentations for offline access.
- Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. """ - when 'buggy' - """ DevDocs requires IndexedDB to cache documentations for offline access.
- Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. """ - when 'private_mode' - """ Your browser appears to be running in private mode.
- This prevents DevDocs from caching documentations for offline access.""" - when 'exception' - """ An error occurred when trying to open the IndexedDB database:
- #{exception.name}: #{exception.message} """ - when 'cant_open' - """ An error occurred when trying to open the IndexedDB database:
- #{exception.name}: #{exception.message}
- This could be because you're browsing in private mode or have disallowed offline storage on the domain. """ - when 'version' - """ The IndexedDB database was modified with a newer version of the app.
- Reload the page to use offline mode. """ - when 'empty' - """ The IndexedDB database appears to be corrupted. Try resetting the app. """ + reason = (() => { switch (reason) { + case 'not_supported': + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser either doesn't support IndexedDB or doesn't make it available. `; + case 'buggy': + return ` DevDocs requires IndexedDB to cache documentations for offline access.
+Unfortunately your browser's implementation of IndexedDB contains bugs that prevent DevDocs from using it. `; + case 'private_mode': + return ` Your browser appears to be running in private mode.
+This prevents DevDocs from caching documentations for offline access.`; + case 'exception': + return ` An error occurred when trying to open the IndexedDB database:
+${exception.name}: ${exception.message} `; + case 'cant_open': + return ` An error occurred when trying to open the IndexedDB database:
+${exception.name}: ${exception.message}
+This could be because you're browsing in private mode or have disallowed offline storage on the domain. `; + case 'version': + return ` The IndexedDB database was modified with a newer version of the app.
+Reload the page to use offline mode. `; + case 'empty': + return " The IndexedDB database appears to be corrupted. Try resetting the app. "; + } })(); - error 'Offline mode is unavailable.', reason + return error('Offline mode is unavailable.', reason); +}; -app.templates.unsupportedBrowser = """ -
-

Your browser is unsupported, sorry.

-

DevDocs is an API documentation browser which supports the following browsers: -

    -
  • Recent versions of Firefox, Chrome, or Opera -
  • Safari 11.1+ -
  • Edge 17+ -
  • iOS 11.3+ -
-

- If you're unable to upgrade, we apologize. - We decided to prioritize speed and new features over support for older browsers. -

- Note: if you're already using one of the browsers above, check your settings and add-ons. - The app uses feature detection, not user agent sniffing. -

- — @DevDocs -

-""" +app.templates.unsupportedBrowser = `\ +
+

Your browser is unsupported, sorry.

+

DevDocs is an API documentation browser which supports the following browsers: +

    +
  • Recent versions of Firefox, Chrome, or Opera +
  • Safari 11.1+ +
  • Edge 17+ +
  • iOS 11.3+ +
+

+ If you're unable to upgrade, we apologize. + We decided to prioritize speed and new features over support for older browsers. +

+ Note: if you're already using one of the browsers above, check your settings and add-ons. + The app uses feature detection, not user agent sniffing. +

+ — @DevDocs +

\ +`; diff --git a/assets/javascripts/templates/notice_tmpl.js b/assets/javascripts/templates/notice_tmpl.js index 10cc534e..2d64ecd2 100644 --- a/assets/javascripts/templates/notice_tmpl.js +++ b/assets/javascripts/templates/notice_tmpl.js @@ -1,9 +1,14 @@ -notice = (text) -> """

#{text}

""" +/* + * 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 => `

${text}

`; -app.templates.singleDocNotice = (doc) -> - notice """ You're browsing the #{doc.fullName} documentation. To browse all docs, go to - #{app.config.production_host} (or press esc). """ +app.templates.singleDocNotice = doc => notice(` You're browsing the ${doc.fullName} documentation. To browse all docs, go to +${app.config.production_host} (or press esc). ` +); -app.templates.disabledDocNotice = -> - notice """ This documentation is disabled. - To enable it, go to Preferences. """ +app.templates.disabledDocNotice = () => notice(` This documentation is disabled. +To enable it, go to Preferences. ` +); diff --git a/assets/javascripts/templates/notif_tmpl.js b/assets/javascripts/templates/notif_tmpl.js index 0821036e..f58808c6 100644 --- a/assets/javascripts/templates/notif_tmpl.js +++ b/assets/javascripts/templates/notif_tmpl.js @@ -1,76 +1,81 @@ -notif = (title, html) -> - html = html.replace /#{title} - #{html} - - +
+ + +
-
- - - - - - - - #{docs} -
DocumentationSizeStatusAction
-
-

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. -

Questions & Answers

-
-
How does this work? -
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
- The app also uses Service Workers and localStorage to cache the assets and index files. -
Can I close the tab/browser? -
#{canICloseTheTab()} -
What if I don't update a documentation? -
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. -
I found a bug, where do I report it? -
In the issue tracker. Thanks! -
How do I uninstall/reset the app? -
Click here. -
Why aren't all documentations listed above? -
You have to enable them first. -
-""" +
+ + + + + + + + ${docs} +
DocumentationSizeStatusAction
+
+

Note: your browser may delete DevDocs's offline data if your computer is running low on disk space and you haven't used the app in a while. Load this page before going offline to make sure the data is still there. +

Questions & Answers

+
+
How does this work? +
Each page is cached as a key-value pair in IndexedDB (downloaded from a single file).
+ The app also uses Service Workers and localStorage to cache the assets and index files. +
Can I close the tab/browser? +
${canICloseTheTab()} +
What if I don't update a documentation? +
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically. +
I found a bug, where do I report it? +
In the issue tracker. Thanks! +
How do I uninstall/reset the app? +
Click here. +
Why aren't all documentations listed above? +
You have to enable them first. +
\ +`; -canICloseTheTab = -> - if app.ServiceWorker.isEnabled() - """ Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). """ - else - reason = "aren't available in your browser (or are disabled)" +var canICloseTheTab = function() { + if (app.ServiceWorker.isEnabled()) { + return " Yes! Even offline, you can open a new tab, go to devdocs.io, and everything will work as if you were online (provided you installed all the documentations you want to use beforehand). "; + } else { + let reason = "aren't available in your browser (or are disabled)"; - if app.config.env != 'production' - reason = "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)" + if (app.config.env !== 'production') { + reason = "are disabled in your development instance of DevDocs (enable them by setting the ENABLE_SERVICE_WORKER environment variable to true)"; + } - """ No. Service Workers #{reason}, so loading devdocs.io offline won't work.
- The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). """ + return ` No. Service Workers ${reason}, so loading devdocs.io offline won't work.
+The current tab will continue to function even when you go offline (provided you installed all the documentations beforehand). `; + } +}; -app.templates.offlineDoc = (doc, status) -> - outdated = doc.isOutdated(status) +app.templates.offlineDoc = function(doc, status) { + const outdated = doc.isOutdated(status); - html = """ - - #{doc.fullName} - #{Math.ceil(doc.db_size / 100000) / 10} MB - """ + let html = `\ + + ${doc.fullName} + ${Math.ceil(doc.db_size / 100000) / 10} MB\ +`; - html += if !(status and status.installed) - """ - - - - """ - else if outdated - """ - Outdated - - - """ - else - """ - Up‑to‑date - - """ + html += !(status && status.installed) ? + `\ +- +\ +` + : outdated ? + `\ +Outdated + - \ +` + : + `\ +Up‑to‑date +\ +`; - html + '' + return html + ''; +}; diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index 048afa1a..26c1eb97 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -1,81 +1,86 @@ -themeOption = ({ label, value }, settings) -> """ - -""" +/* + * 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) => `\ +\ +`; -app.templates.settingsPage = (settings) -> """ -

Preferences

+app.templates.settingsPage = settings => `\ +

Preferences

-
-

Theme:

-
- #{if settings.autoSupported - themeOption label: "Automatic Matches system setting", value: "auto", settings - else +
+

Theme:

+
+ ${settings.autoSupported ? + themeOption({label: "Automatic Matches system setting", value: "auto"}, settings) + : ""} - #{themeOption label: "Light", value: "default", settings} - #{themeOption label: "Dark", value: "dark", settings} -
+ ${themeOption({label: "Light", value: "default"}, settings)} + ${themeOption({label: "Dark", value: "dark"}, settings)}
+
-
-

General:

+
+

General:

-
- - - - - - -
+
+ + + + + +
+
-
-

Scrolling:

+
+

Scrolling:

-
- - - - - -
+
+ + + + +
+
-

- - +

+ + -

- -""" +

+ \ +`; diff --git a/assets/javascripts/templates/pages/type_tmpl.js b/assets/javascripts/templates/pages/type_tmpl.js index c419a6a8..c1308842 100644 --- a/assets/javascripts/templates/pages/type_tmpl.js +++ b/assets/javascripts/templates/pages/type_tmpl.js @@ -1,6 +1,9 @@ -app.templates.typePage = (type) -> - """

#{type.doc.fullName} / #{type.name}

-
    #{app.templates.render 'typePageEntry', type.entries()}
""" +/* + * 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 => `

${type.doc.fullName} / ${type.name}

+
    ${app.templates.render('typePageEntry', type.entries())}
`; -app.templates.typePageEntry = (entry) -> - """
  • #{$.escape entry.name}
  • """ +app.templates.typePageEntry = entry => `
  • ${$.escape(entry.name)}
  • `; diff --git a/assets/javascripts/templates/path_tmpl.js b/assets/javascripts/templates/path_tmpl.js index f28925c9..d0344542 100644 --- a/assets/javascripts/templates/path_tmpl.js +++ b/assets/javascripts/templates/path_tmpl.js @@ -1,7 +1,13 @@ -arrow = """""" +/* + * 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 = ""; -app.templates.path = (doc, type, entry) -> - html = """#{doc.fullName}""" - html += """#{arrow}#{type.name}""" if type - html += """#{arrow}#{$.escape entry.name}""" if entry - html +app.templates.path = function(doc, type, entry) { + let html = `${doc.fullName}`; + if (type) { html += `${arrow}${type.name}`; } + if (entry) { html += `${arrow}${$.escape(entry.name)}`; } + return html; +}; diff --git a/assets/javascripts/templates/sidebar_tmpl.js b/assets/javascripts/templates/sidebar_tmpl.js index 46797e56..2f9c7ce3 100644 --- a/assets/javascripts/templates/sidebar_tmpl.js +++ b/assets/javascripts/templates/sidebar_tmpl.js @@ -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 = """""" +const arrow = ""; -templates.sidebarDoc = (doc, options = {}) -> - link = """""" - if options.disabled - link += """Enable""" - else - link += arrow - link += """#{doc.release}""" if doc.release - link += """#{doc.name}""" - link += " #{doc.version}" if options.fullName or options.disabled and doc.version - link + "" +templates.sidebarDoc = function(doc, options) { + if (options == null) { options = {}; } + let link = ``; + if (options.disabled) { + link += `Enable`; + } else { + link += arrow; + } + if (doc.release) { link += `${doc.release}`; } + link += `${doc.name}`; + if (options.fullName || (options.disabled && doc.version)) { link += ` ${doc.version}`; } + return link + ""; +}; -templates.sidebarType = (type) -> - """#{arrow}#{type.count}#{$.escape type.name}""" +templates.sidebarType = type => `${arrow}${type.count}${$.escape(type.name)}`; -templates.sidebarEntry = (entry) -> - """#{$.escape entry.name}""" +templates.sidebarEntry = entry => `${$.escape(entry.name)}`; -templates.sidebarResult = (entry) -> - addons = if entry.isIndex() and app.disabledDocs.contains(entry.doc) - """Enable""" - else - """""" - addons += """#{entry.doc.short_version}""" if entry.doc.version and not entry.isIndex() - """#{addons}#{$.escape entry.name}""" +templates.sidebarResult = function(entry) { + let addons = entry.isIndex() && app.disabledDocs.contains(entry.doc) ? + `Enable` + : + ""; + if (entry.doc.version && !entry.isIndex()) { addons += `${entry.doc.short_version}`; } + return `${addons}${$.escape(entry.name)}`; +}; -templates.sidebarNoResults = -> - html = """
    No results.
    """ - html += """ -
    Note: documentations must be enabled to appear in the search.
    - """ unless app.isSingleDoc() or app.disabledDocs.isEmpty() - html +templates.sidebarNoResults = function() { + let html = "
    No results.
    "; + if (!app.isSingleDoc() && !app.disabledDocs.isEmpty()) { html += `\ +
    Note: documentations must be enabled to appear in the search.
    \ +`; } + return html; +}; -templates.sidebarPageLink = (count) -> - """Show more\u2026 (#{count})""" +templates.sidebarPageLink = count => `Show more\u2026 (${count})`; -templates.sidebarLabel = (doc, options = {}) -> - label = """""" +templates.sidebarLabel = function(doc, options) { + if (options == null) { options = {}; } + let label = "`; +}; -templates.sidebarVersionedDoc = (doc, versions, options = {}) -> - html = """
    #{arrow}#{doc.name}
    #{versions}
    """ +templates.sidebarVersionedDoc = function(doc, versions, options) { + if (options == null) { options = {}; } + let html = `
    ${arrow}${doc.name}
    ${versions}
    `; +}; -templates.sidebarDisabled = (options) -> - """
    #{arrow}Disabled (#{options.count}) Customize
    """ +templates.sidebarDisabled = options => `
    ${arrow}Disabled (${options.count}) Customize
    `; -templates.sidebarDisabledList = (html) -> - """
    #{html}
    """ +templates.sidebarDisabledList = html => `
    ${html}
    `; -templates.sidebarDisabledVersionedDoc = (doc, versions) -> - """#{arrow}#{doc.name}
    #{versions}
    """ +templates.sidebarDisabledVersionedDoc = (doc, versions) => `${arrow}${doc.name}
    ${versions}
    `; -templates.docPickerHeader = """
    Documentation Enable
    """ +templates.docPickerHeader = "
    Documentation Enable
    "; -templates.docPickerNote = """ -
    Tip: for faster and better search results, select only the docs you need.
    - Vote for new documentation - """ +templates.docPickerNote = `\ +
    Tip: for faster and better search results, select only the docs you need.
    +Vote for new documentation\ +`; diff --git a/assets/javascripts/templates/tip_tmpl.js b/assets/javascripts/templates/tip_tmpl.js index 55979fa4..665ac327 100644 --- a/assets/javascripts/templates/tip_tmpl.js +++ b/assets/javascripts/templates/tip_tmpl.js @@ -1,10 +1,15 @@ -app.templates.tipKeyNav = () -> """ -

    - ProTip - (click to dismiss) -

    - Hit #{if app.settings.get('arrowScroll') then 'shift +' else ''} to navigate the sidebar.
    - Hit space / shift space#{if app.settings.get('arrowScroll') then ' or ↓/↑' else ', alt ↓/↑ or shift ↓/↑'} to scroll the page. -

    - See all keyboard shortcuts -""" +/* + * 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 = () => `\ +

    + ProTip + (click to dismiss) +

    + Hit ${app.settings.get('arrowScroll') ? 'shift +' : ''} to navigate the sidebar.
    + Hit space / shift space${app.settings.get('arrowScroll') ? ' or ↓/↑' : ', alt ↓/↑ or shift ↓/↑'} to scroll the page. +

    + See all keyboard shortcuts\ +`; diff --git a/assets/javascripts/views/content/content.js b/assets/javascripts/views/content/content.js index 4e01733e..7f502995 100644 --- a/assets/javascripts/views/content/content.js +++ b/assets/javascripts/views/content/content.js @@ -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' - altF: 'onAltF' - - @routes: - before: 'beforeRoute' - after: 'afterRoute' - - init: -> - @scrollEl = if app.isMobile() +/* + * 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' + }; + + this.routes = { + before: 'beforeRoute', + after: 'afterRoute' + }; + } + + 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; +} \ No newline at end of file diff --git a/assets/javascripts/views/content/entry_page.js b/assets/javascripts/views/content/entry_page.js index dff00887..bf9852d2 100644 --- a/assets/javascripts/views/content/entry_page.js +++ b/assets/javascripts/views/content/entry_page.js @@ -1,166 +1,225 @@ -class app.views.EntryPage extends app.View - @className: '_page' - @errorClass: '_page-error' - - @events: - click: 'onClick' - - @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 = '' - @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, """""" - return - - LINKS = - home: 'Homepage' - code: 'Source code' - - prepareContent: (content) -> - return content unless @entry.isIndex() and @entry.doc.links - - links = for link, url of @entry.doc.links - """#{LINKS[link]}""" - - """

    #{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 +/* + * 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' + }; + + this.routes = + {before: 'beforeRoute'}; + + 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 = ''; + 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, ``); + } + + 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(`${LINKS[link]}`); + } + return result; + })(); + + return `${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; +})(); diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js index 2f0d615f..4cac6f20 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -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' - change: 'onChange' + static initClass() { + this.className = '_static'; + + this.events = { + click: 'onClick', + change: 'onChange' + }; + } - deactivate: -> - if super - @empty() - return + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + } + } - render: -> - if app.cookieBlocked - @html @tmpl('offlineError', 'cookie_blocked') - return + render() { + if (app.cookieBlocked) { + this.html(this.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 + 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) -> - app.templates.render('offlineDoc', doc, status) + renderDoc(doc, status) { + return app.templates.render('offlineDoc', doc, status); + } - getTitle: -> - 'Offline' + getTitle() { + return '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 + 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) -> - el = el.parentNode until slug = el.getAttribute('data-slug') - app.docs.findBy('slug', slug) + docByEl(el) { + let slug; + while (!(slug = el.getAttribute('data-slug'))) { el = el.parentNode; } + return app.docs.findBy('slug', slug); + } - docEl: (doc) -> - @find("[data-slug='#{doc.slug}']") + docEl(doc) { + return this.find(`[data-slug='${doc.slug}']`); + } - onRoute: (context) -> - @render() - return + onRoute(context) { + this.render(); + } - 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 + 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) -> - 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 + 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) -> - return unless @activated - if el = @docEl(doc) - el.lastElementChild.textContent = 'Error' - return + onInstallError(doc) { + let el; + if (!this.activated) { return; } + if (el = this.docEl(doc)) { + el.lastElementChild.textContent = 'Error'; + } + } - 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 + 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}%)`); + } + } - 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(); diff --git a/assets/javascripts/views/content/root_page.js b/assets/javascripts/views/content/root_page.js index b48a1df3..36934123 100644 --- a/assets/javascripts/views/content/root_page.js +++ b/assets/javascripts/views/content/root_page.js @@ -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(); diff --git a/assets/javascripts/views/content/settings_page.js b/assets/javascripts/views/content/settings_page.js index 7a7b6246..5174368b 100644 --- a/assets/javascripts/views/content/settings_page.js +++ b/assets/javascripts/views/content/settings_page.js @@ -1,116 +1,151 @@ -class app.views.SettingsPage extends app.View - @className: '_static' - - @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 +/* + * 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() { + 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(); diff --git a/assets/javascripts/views/content/static_page.js b/assets/javascripts/views/content/static_page.js index d7bee725..a998dbf7 100644 --- a/assets/javascripts/views/content/static_page.js +++ b/assets/javascripts/views/content/static_page.js @@ -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'; + + this.titles = { + about: 'About', + news: 'News', + help: 'User Guide', + notFound: '404' + }; + } - @titles: - about: 'About' - news: 'News' - help: 'User Guide' - notFound: '404' + deactivate() { + if (super.deactivate(...arguments)) { + this.empty(); + this.page = null; + } + } - deactivate: -> - if super - @empty() - @page = null - return + render(page) { + this.page = page; + this.html(this.tmpl(`${this.page}Page`)); + } - render: (page) -> - @page = page - @html @tmpl("#{@page}Page") - return + getTitle() { + return this.constructor.titles[this.page]; + } - getTitle: -> - @constructor.titles[@page] - - onRoute: (context) -> - @render context.page or 'notFound' - return + onRoute(context) { + this.render(context.page || 'notFound'); + } +}); +Cls.initClass(); diff --git a/assets/javascripts/views/content/type_page.js b/assets/javascripts/views/content/type_page.js index ef360c14..f54ee25a 100644 --- a/assets/javascripts/views/content/type_page.js +++ b/assets/javascripts/views/content/type_page.js @@ -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(); diff --git a/assets/javascripts/views/layout/document.js b/assets/javascripts/views/layout/document.js index a10d0b3c..6b0dd57d 100644 --- a/assets/javascripts/views/layout/document.js +++ b/assets/javascripts/views/layout/document.js @@ -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' - 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() +/* + * 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' + }; + + 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(); diff --git a/assets/javascripts/views/layout/menu.js b/assets/javascripts/views/layout/menu.js index e2282176..4254d115 100644 --- a/assets/javascripts/views/layout/menu.js +++ b/assets/javascripts/views/layout/menu.js @@ -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'; + + this.events = + {click: 'onClick'}; + } - init: -> - $.on document.body, 'click', @onGlobalClick - return + init() { + $.on(document.body, 'click', this.onGlobalClick); + } - onClick: (event) -> - target = $.eventTarget(event) - target.blur() if target.tagName is 'A' - return + onClick(event) { + const target = $.eventTarget(event); + if (target.tagName === 'A') { target.blur(); } + } - 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 + 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(); diff --git a/assets/javascripts/views/layout/mobile.js b/assets/javascripts/views/layout/mobile.js index 1fd5e285..bc336f59 100644 --- a/assets/javascripts/views/layout/mobile.js +++ b/assets/javascripts/views/layout/mobile.js @@ -1,155 +1,195 @@ -class app.views.Mobile extends app.View - @className: '_mobile' - - @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 +/* + * 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' + }; + + 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(); diff --git a/assets/javascripts/views/layout/path.js b/assets/javascripts/views/layout/path.js index fb34afda..fa9eb92e 100644 --- a/assets/javascripts/views/layout/path.js +++ b/assets/javascripts/views/layout/path.js @@ -1,43 +1,64 @@ -class app.views.Path extends app.View - @className: '_path' - @attributes: - role: 'complementary' +/* + * 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); + } - @events: - click: 'onClick' + static initClass() { + this.className = '_path'; + this.attributes = + {role: 'complementary'}; + + this.events = + {click: 'onClick'}; + + this.routes = + {after: 'afterRoute'}; + } - @routes: - after: 'afterRoute' + render(...args) { + this.html(this.tmpl('path', ...Array.from(args))); + this.show(); + } - render: (args...) -> - @html @tmpl 'path', args... - @show() - return + show() { + if (!this.el.parentNode) { this.prependTo(app.el); } + } - show: -> - @prependTo app.el unless @el.parentNode - return + hide() { + if (this.el.parentNode) { $.remove(this.el); } + } - hide: -> - $.remove @el if @el.parentNode - return + onClick(event) { + let link; + if (link = $.closestLink(event.target, this.el)) { this.clicked = true; } + } - onClick: (event) => - @clicked = true if link = $.closestLink event.target, @el - return + 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(); + } - 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 + if (this.clicked) { + this.clicked = null; + app.document.sidebar.reset(); + } + } +}); +Cls.initClass(); diff --git a/assets/javascripts/views/layout/resizer.js b/assets/javascripts/views/layout/resizer.js index 5584bfbe..44f3bdb1 100644 --- a/assets/javascripts/views/layout/resizer.js +++ b/assets/javascripts/views/layout/resizer.js @@ -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' - dragend: 'onDragEnd' + static initClass() { + this.className = '_resizer'; + + this.events = { + dragstart: 'onDragStart', + dragend: 'onDragEnd' + }; + + MIN = 260; + MAX = 600; + } - @isSupported: -> - 'ondragstart' of document.createElement('div') and !app.isMobile() + static isSupported() { + return 'ondragstart' in document.createElement('div') && !app.isMobile(); + } - init: -> - @el.setAttribute('draggable', 'true') - @appendTo $('._app') - return + init() { + this.el.setAttribute('draggable', 'true'); + this.appendTo($('._app')); + } - MIN = 260 - MAX = 600 + 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); } + } - 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', this.onDrag); + } - onDragStart: (event) => - event.dataTransfer.effectAllowed = 'link' - event.dataTransfer.setData('Text', '') - $.on(window, 'dragover', @onDrag) - return + 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); + } - 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; +})(); diff --git a/assets/javascripts/views/layout/settings.js b/assets/javascripts/views/layout/settings.js index 6941b9cd..57d0b061 100644 --- a/assets/javascripts/views/layout/settings.js +++ b/assets/javascripts/views/layout/settings.js @@ -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"]' - backBtn: 'button[data-back]' - - @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 +/* + * 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]' + }; + + this.events = { + import: 'onImport', + change: 'onChange', + submit: 'onSubmit', + click: 'onClick' + }; + + 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; +})(); diff --git a/assets/javascripts/views/list/list_focus.js b/assets/javascripts/views/list/list_focus.js index 80881019..72e251fb 100644 --- a/assets/javascripts/views/list/list_focus.js +++ b/assets/javascripts/views/list/list_focus.js @@ -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' - 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 +/* + * 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) { + 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(); diff --git a/assets/javascripts/views/list/list_fold.js b/assets/javascripts/views/list/list_fold.js index da6f1d5e..828f90fd 100644 --- a/assets/javascripts/views/list/list_fold.js +++ b/assets/javascripts/views/list/list_fold.js @@ -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'; + + this.events = + {click: 'onClick'}; + + this.shortcuts = { + left: 'onLeft', + right: 'onRight' + }; + } - @events: - click: 'onClick' + 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); } - @shortcuts: - left: 'onLeft' - right: 'onRight' + open(el) { + if (el && !el.classList.contains(this.constructor.activeClass)) { + el.classList.add(this.constructor.activeClass); + $.trigger(el, 'open'); + } + } - constructor: (@el) -> super + close(el) { + if (el && el.classList.contains(this.constructor.activeClass)) { + el.classList.remove(this.constructor.activeClass); + $.trigger(el, 'close'); + } + } - open: (el) -> - if el and not el.classList.contains @constructor.activeClass - el.classList.add @constructor.activeClass - $.trigger el, 'open' - return + toggle(el) { + if (el.classList.contains(this.constructor.activeClass)) { + this.close(el); + } else { + this.open(el); + } + } - close: (el) -> - if el and el.classList.contains @constructor.activeClass - el.classList.remove @constructor.activeClass - $.trigger el, 'close' - return + reset() { + let el; + while ((el = this.findByClass(this.constructor.activeClass))) { + this.close(el); + } + } - toggle: (el) -> - if el.classList.contains @constructor.activeClass - @close el - else - @open el - return + getCursor() { + return this.findByClass(app.views.ListFocus.activeClass) || this.findByClass(app.views.ListSelect.activeClass); + } - reset: -> - while el = @findByClass @constructor.activeClass - @close el - return + onLeft() { + const cursor = this.getCursor(); + if (cursor != null ? cursor.classList.contains(this.constructor.activeClass) : undefined) { + this.close(cursor); + } + } - getCursor: -> - @findByClass(app.views.ListFocus.activeClass) or @findByClass(app.views.ListSelect.activeClass) + onRight() { + const cursor = this.getCursor(); + if (cursor != null ? cursor.classList.contains(this.constructor.targetClass) : undefined) { + this.open(cursor); + } + } - onLeft: => - cursor = @getCursor() - if cursor?.classList.contains @constructor.activeClass - @close cursor - return + 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; } - onRight: => - cursor = @getCursor() - if cursor?.classList.contains @constructor.targetClass - @open cursor - return - - 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' - - 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(); diff --git a/assets/javascripts/views/list/list_select.js b/assets/javascripts/views/list/list_select.js index fe06b70b..6721726e 100644 --- a/assets/javascripts/views/list/list_select.js +++ b/assets/javascripts/views/list/list_select.js @@ -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; +} \ No newline at end of file diff --git a/assets/javascripts/views/list/paginated_list.js b/assets/javascripts/views/list/paginated_list.js index 0c6c4385..10e2ead7 100644 --- a/assets/javascripts/views/list/paginated_list.js +++ b/assets/javascripts/views/list/paginated_list.js @@ -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 - 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 +/* + * 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 + : + 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; +})(); diff --git a/assets/javascripts/views/misc/news.js b/assets/javascripts/views/misc/news.js index a39fbb15..2aec8553 100644 --- a/assets/javascripts/views/misc/news.js +++ b/assets/javascripts/views/misc/news.js @@ -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(); diff --git a/assets/javascripts/views/misc/notice.js b/assets/javascripts/views/misc/notice.js index 2007930e..e3b66a60 100644 --- a/assets/javascripts/views/misc/notice.js +++ b/assets/javascripts/views/misc/notice.js @@ -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(); diff --git a/assets/javascripts/views/misc/notif.js b/assets/javascripts/views/misc/notif.js index dcf2a051..48b46a94 100644 --- a/assets/javascripts/views/misc/notif.js +++ b/assets/javascripts/views/misc/notif.js @@ -1,59 +1,78 @@ -class app.views.Notif extends app.View - @className: '_notif' - @activeClass: '_in' - @attributes: - role: 'alert' +/* + * 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'}; + } - @defautOptions: - autoHide: 15000 + 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); + } - @events: - click: 'onClick' + init() { + this.show(); + } - constructor: (@type, @options = {}) -> - @options = $.extend {}, @constructor.defautOptions, @options - super + 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); } + } + } - init: -> - @show() - return + hide() { + clearTimeout(this.timeout); + this.timeout = null; + this.detach(); + } - 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 + render() { + this.html(this.tmpl(`notif${this.type}`)); + } - hide: -> - clearTimeout @timeout - @timeout = null - @detach() - return + 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'; + } + } - 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 + 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(); diff --git a/assets/javascripts/views/misc/tip.js b/assets/javascripts/views/misc/tip.js index 6fec52a2..9c2dd0b4 100644 --- a/assets/javascripts/views/misc/tip.js +++ b/assets/javascripts/views/misc/tip.js @@ -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'; + + this.defautOptions = + {autoHide: false}; + } - @defautOptions: - autoHide: false - - render: -> - @html @tmpl("tip#{@type}") - return + render() { + this.html(this.tmpl(`tip${this.type}`)); + } +}); +Cls.initClass(); diff --git a/assets/javascripts/views/misc/updates.js b/assets/javascripts/views/misc/updates.js index 8b91ccfd..c41f88da 100644 --- a/assets/javascripts/views/misc/updates.js +++ b/assets/javascripts/views/misc/updates.js @@ -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(); diff --git a/assets/javascripts/views/pages/base.js b/assets/javascripts/views/pages/base.js index e1c0b6a4..a36a9d3a 100644 --- a/assets/javascripts/views/pages/base.js +++ b/assets/javascripts/views/pages/base.js @@ -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; + } +}; diff --git a/assets/javascripts/views/pages/hidden.js b/assets/javascripts/views/pages/hidden.js index f17080a0..d6e236c7 100644 --- a/assets/javascripts/views/pages/hidden.js +++ b/assets/javascripts/views/pages/hidden.js @@ -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(); diff --git a/assets/javascripts/views/pages/jquery.js b/assets/javascripts/views/pages/jquery.js index 47f02195..d97ba502 100644 --- a/assets/javascripts/views/pages/jquery.js +++ b/assets/javascripts/views/pages/jquery.js @@ -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 '', """ - - - - """ - source.replace / +\ +` + ); + return source.replace(/ \ -` +`, ); return source.replace(/