├── .gitignore ├── CREDITS.md ├── LICENSE.md ├── README.md ├── dist ├── framework-bundle.js ├── framework-bundle.min.js ├── framework-extras.js ├── framework-extras.min.js ├── framework-turbo.js ├── framework-turbo.min.js ├── framework.js └── framework.min.js ├── package.json ├── src ├── core │ ├── controller.js │ ├── index.js │ ├── migrate.js │ ├── namespace.js │ └── request-builder.js ├── extras │ ├── attach-loader.js │ ├── controller.js │ ├── flash-message.js │ ├── index.js │ ├── migrate.js │ ├── namespace.js │ ├── progress-bar.js │ └── validator.js ├── framework-bundle.js ├── framework-extras.js ├── framework-turbo.js ├── framework.js ├── index.js ├── observe │ ├── application.js │ ├── container.js │ ├── context.js │ ├── control-base.js │ ├── dispatcher.js │ ├── event-listener.js │ ├── index.js │ ├── module.js │ ├── mutation │ │ ├── attribute-observer.js │ │ ├── element-observer.js │ │ ├── index.js │ │ ├── selector-observer.js │ │ ├── token-list-observer.js │ │ └── value-list-observer.js │ ├── namespace.js │ ├── scope-observer.js │ ├── scope.js │ └── util │ │ ├── multimap.js │ │ └── set-operations.js ├── request │ ├── actions.js │ ├── asset-manager.js │ ├── data.js │ ├── index.js │ ├── namespace.js │ ├── options.js │ └── request.js ├── turbo │ ├── browser-adapter.js │ ├── controller.js │ ├── error-renderer.js │ ├── head-details.js │ ├── history.js │ ├── index.js │ ├── location.js │ ├── namespace.js │ ├── renderer.js │ ├── scroll-manager.js │ ├── snapshot-cache.js │ ├── snapshot-renderer.js │ ├── snapshot.js │ ├── view.js │ └── visit.js └── util │ ├── deferred.js │ ├── events.js │ ├── form-serializer.js │ ├── http-request.js │ ├── index.js │ ├── json-parser.js │ ├── referrer.js │ └── wait.js ├── types └── index.d.ts ├── webpack.config.js └── webpack.mix.js /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules 3 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | This library was created with help from the following packages: 4 | 5 | "Turbo", Copyright (c) 2021 Basecamp 6 | https://github.com/hotwired/turbo 7 | 8 | "Stimulus", Copyright (c) 2021 Basecamp 9 | https://github.com/hotwired/stimulus 10 | 11 | "Twitter Bootstrap", Copyright (c) 2011-2022 Twitter, Inc., Copyright (c) 2011-2022 The Bootstrap Authors 12 | https://github.com/twbs/bootstrap 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # October AJAX Framework 2 | 3 | This repository contains the AJAX framework of October CMS. If you want to build a website using October CMS, visit the main [October CMS repository](http://github.com/octobercms/october). 4 | 5 | ## Installation outside October CMS 6 | 7 | Your application can use the `octobercms` npm package to install the AJAX Framework as a module for build tools like webpack. 8 | 9 | 1. Add the `octobercms` package to your application. 10 | 11 | ```js 12 | npm install --save octobercms 13 | ``` 14 | 15 | 2. Require and start the Framework in your JavaScript bundle. 16 | 17 | ```js 18 | import oc from 'octobercms'; 19 | 20 | // Make an AJAX request 21 | oc.ajax('onSomething', { data: someVar }); 22 | 23 | // Serialize an element with the request 24 | oc.request('.some-element', 'onSomething', { data: someVar }); 25 | ``` 26 | 27 | ### jQuery Adapter 28 | 29 | If jQuery is found, the traditional API can also be used. 30 | 31 | ```js 32 | // AJAX request with jQuery 33 | $.request('onSomething', { data: someVar }); 34 | 35 | // Serialized request with jQuery 36 | $('.some-element').request('onSomething', { data: someVar }); 37 | ``` 38 | 39 | ## Documentation 40 | 41 | [Read the complete documentation](https://docs.octobercms.com/3.x/cms/ajax/introduction.html) on the October CMS website. 42 | 43 | ## License 44 | 45 | The October CMS platform is licensed software, see [End User License Agreement](./LICENSE.md) (EULA) for more details. 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "octobercms", 3 | "version": "1.3.2", 4 | "description": "AJAX Framework for October CMS", 5 | "main": "src/index.js", 6 | "types": "types/index.d.ts", 7 | "devDependencies": { 8 | "laravel-mix": "^6.0.44" 9 | }, 10 | "scripts": { 11 | "dev": "npm run development", 12 | "development": "mix", 13 | "watch": "mix watch", 14 | "prod": "npm run production", 15 | "production": "mix --production", 16 | "pub": "npm run development && npm run production", 17 | "test": "echo \"Error: no test specified\" && exit 1" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/octobercms/ajax.git" 22 | }, 23 | "keywords": [ 24 | "octobercms", 25 | "october", 26 | "ajax", 27 | "framework" 28 | ], 29 | "author": "October CMS (Responsiv Pty Ltd)", 30 | "license": "SEE LICENSE IN LICENSE.md", 31 | "bugs": { 32 | "url": "https://github.com/octobercms/ajax/issues" 33 | }, 34 | "homepage": "https://github.com/octobercms/ajax#readme" 35 | } 36 | -------------------------------------------------------------------------------- /src/core/controller.js: -------------------------------------------------------------------------------- 1 | import { Events } from "../util/events"; 2 | import { RequestBuilder } from "./request-builder"; 3 | 4 | export class Controller 5 | { 6 | constructor() { 7 | this.started = false; 8 | this.documentVisible = true; 9 | } 10 | 11 | start() { 12 | if (!this.started) { 13 | // Track unload event for request lib 14 | window.onbeforeunload = this.documentOnBeforeUnload; 15 | 16 | // First page load 17 | addEventListener('DOMContentLoaded', () => this.render()); 18 | 19 | // Again, after new scripts load 20 | addEventListener('page:updated', () => this.render()); 21 | 22 | // Again after AJAX request 23 | addEventListener('ajax:update-complete', () => this.render()); 24 | 25 | // Watching document visibility 26 | addEventListener('visibilitychange', () => this.documentOnVisibilityChange()); 27 | 28 | // Submit form 29 | Events.on(document, 'submit', '[data-request]', this.documentOnSubmit); 30 | 31 | // Track input 32 | Events.on(document, 'input', 'input[data-request][data-track-input]', this.documentOnKeyup); 33 | 34 | // Change select, checkbox, radio, file input 35 | Events.on(document, 'change', 'select[data-request], input[type=radio][data-request], input[type=checkbox][data-request], input[type=file][data-request]', this.documentOnChange); 36 | 37 | // Press enter on orphan input 38 | Events.on(document, 'keydown', 'input[type=text][data-request], input[type=submit][data-request], input[type=password][data-request]', this.documentOnKeydown); 39 | 40 | // Click submit button or link 41 | Events.on(document, 'click', 'a[data-request], button[data-request], input[type=button][data-request], input[type=submit][data-request]', this.documentOnClick); 42 | 43 | this.started = true; 44 | } 45 | } 46 | 47 | stop() { 48 | if (this.started) { 49 | this.started = false; 50 | } 51 | } 52 | 53 | render(event) { 54 | // Pre render event, used to move nodes around 55 | Events.dispatch('before-render'); 56 | 57 | // Render event, used to initialize controls 58 | Events.dispatch('render'); 59 | 60 | // Resize event to adjust all measurements 61 | dispatchEvent(new Event('resize')); 62 | 63 | this.documentOnRender(event); 64 | } 65 | 66 | documentOnVisibilityChange(event) { 67 | this.documentVisible = !document.hidden; 68 | if (this.documentVisible) { 69 | this.documentOnRender(); 70 | } 71 | } 72 | 73 | documentOnRender(event) { 74 | if (!this.documentVisible) { 75 | return; 76 | } 77 | 78 | document.querySelectorAll('[data-auto-submit]').forEach(function(el) { 79 | const interval = el.dataset.autoSubmit || 0; 80 | el.removeAttribute('data-auto-submit'); 81 | setTimeout(function() { 82 | RequestBuilder.fromElement(el); 83 | }, interval); 84 | }); 85 | } 86 | 87 | documentOnSubmit(event) { 88 | event.preventDefault(); 89 | RequestBuilder.fromElement(event.target); 90 | } 91 | 92 | documentOnClick(event) { 93 | event.preventDefault(); 94 | RequestBuilder.fromElement(event.target); 95 | } 96 | 97 | documentOnChange(event) { 98 | RequestBuilder.fromElement(event.target); 99 | } 100 | 101 | documentOnKeyup(event) { 102 | var el = event.target, 103 | lastValue = el.dataset.ocLastValue; 104 | 105 | if (['email', 'number', 'password', 'search', 'text'].indexOf(el.type) === -1) { 106 | return; 107 | } 108 | 109 | if (lastValue !== undefined && lastValue == el.value) { 110 | return; 111 | } 112 | 113 | el.dataset.ocLastValue = el.value; 114 | 115 | if (this.dataTrackInputTimer !== undefined) { 116 | clearTimeout(this.dataTrackInputTimer); 117 | } 118 | 119 | var interval = el.getAttribute('data-track-input'); 120 | if (!interval) { 121 | interval = 300; 122 | } 123 | 124 | var self = this; 125 | this.dataTrackInputTimer = setTimeout(function() { 126 | if (self.lastDataTrackInputRequest) { 127 | self.lastDataTrackInputRequest.abort(); 128 | } 129 | 130 | self.lastDataTrackInputRequest = RequestBuilder.fromElement(el); 131 | }, interval); 132 | } 133 | 134 | documentOnKeydown(event) { 135 | if (event.key === 'Enter') { 136 | event.preventDefault(); 137 | 138 | if (this.dataTrackInputTimer !== undefined) { 139 | clearTimeout(this.dataTrackInputTimer); 140 | } 141 | 142 | RequestBuilder.fromElement(event.target); 143 | } 144 | } 145 | 146 | documentOnBeforeUnload(event) { 147 | window.ocUnloading = true; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import { Events } from "../util/events"; 2 | import { waitFor, domReady } from "../util/wait"; 3 | import namespace from "./namespace"; 4 | export default namespace; 5 | 6 | if (!window.oc) { 7 | window.oc = {}; 8 | } 9 | 10 | if (!window.oc.AjaxFramework) { 11 | // Namespace 12 | window.oc.AjaxFramework = namespace; 13 | 14 | // Request on element with builder 15 | window.oc.request = namespace.requestElement; 16 | 17 | // JSON parser 18 | window.oc.parseJSON = namespace.parseJSON; 19 | 20 | // Form serializer 21 | window.oc.serializeJSON = namespace.serializeJSON; 22 | 23 | // Selector events 24 | window.oc.Events = Events; 25 | 26 | // Wait for a variable to exist 27 | window.oc.waitFor = waitFor; 28 | 29 | // Fallback for turbo 30 | window.oc.pageReady = domReady; 31 | 32 | // Fallback for turbo 33 | window.oc.visit = (url) => window.location.assign(url); 34 | 35 | // Boot controller 36 | if (!isAMD() && !isCommonJS()) { 37 | namespace.start(); 38 | } 39 | } 40 | 41 | function isAMD() { 42 | return typeof define == "function" && define.amd; 43 | } 44 | 45 | function isCommonJS() { 46 | return typeof exports == "object" && typeof module != "undefined"; 47 | } 48 | -------------------------------------------------------------------------------- /src/core/migrate.js: -------------------------------------------------------------------------------- 1 | import { RequestBuilder } from '../core/request-builder'; 2 | import { JsonParser } from "../util/json-parser"; 3 | 4 | export class Migrate 5 | { 6 | bind() { 7 | this.bindRequestFunc(); 8 | this.bindRenderFunc(); 9 | this.bindjQueryEvents(); 10 | } 11 | 12 | bindRequestFunc() { 13 | var old = $.fn.request; 14 | 15 | $.fn.request = function(handler, option) { 16 | var options = typeof option === 'object' ? option : {}; 17 | return new RequestBuilder(this.get(0), handler, options); 18 | } 19 | 20 | $.fn.request.Constructor = RequestBuilder; 21 | 22 | // Basic function 23 | $.request = function(handler, option) { 24 | return $(document).request(handler, option); 25 | } 26 | 27 | // No conflict 28 | $.fn.request.noConflict = function() { 29 | $.fn.request = old; 30 | return this; 31 | } 32 | } 33 | 34 | bindRenderFunc() { 35 | $.fn.render = function(callback) { 36 | $(document).on('render', callback); 37 | }; 38 | } 39 | 40 | bindjQueryEvents() { 41 | // Element 42 | this.migratejQueryEvent(document, 'ajax:setup', 'ajaxSetup', ['context']); 43 | this.migratejQueryEvent(document, 'ajax:promise', 'ajaxPromise', ['context']); 44 | this.migratejQueryEvent(document, 'ajax:fail', 'ajaxFail', ['context', 'data', 'responseCode', 'xhr']); 45 | this.migratejQueryEvent(document, 'ajax:done', 'ajaxDone', ['context', 'data', 'responseCode', 'xhr']); 46 | this.migratejQueryEvent(document, 'ajax:always', 'ajaxAlways', ['context', 'data', 'responseCode', 'xhr']); 47 | this.migratejQueryEvent(document, 'ajax:before-redirect', 'ajaxRedirect'); 48 | 49 | // Updated Element 50 | this.migratejQueryEvent(document, 'ajax:update', 'ajaxUpdate', ['context', 'data', 'responseCode', 'xhr']); 51 | this.migratejQueryEvent(document, 'ajax:before-replace', 'ajaxBeforeReplace'); 52 | 53 | // Trigger Element 54 | this.migratejQueryEvent(document, 'ajax:before-request', 'oc.beforeRequest', ['context']); 55 | this.migratejQueryEvent(document, 'ajax:before-update', 'ajaxBeforeUpdate', ['context', 'data', 'responseCode', 'xhr']); 56 | this.migratejQueryEvent(document, 'ajax:request-success', 'ajaxSuccess', ['context', 'data', 'responseCode', 'xhr']); 57 | this.migratejQueryEvent(document, 'ajax:request-complete', 'ajaxComplete', ['context', 'data', 'responseCode', 'xhr']); 58 | this.migratejQueryEvent(document, 'ajax:request-error', 'ajaxError', ['context', 'message', 'responseCode', 'xhr']); 59 | this.migratejQueryEvent(document, 'ajax:before-validate', 'ajaxValidation', ['context', 'message', 'fields']); 60 | 61 | // Window 62 | this.migratejQueryEvent(window, 'ajax:before-send', 'ajaxBeforeSend', ['context']); 63 | this.migratejQueryEvent(window, 'ajax:update-complete', 'ajaxUpdateComplete', ['context', 'data', 'responseCode', 'xhr']); 64 | this.migratejQueryEvent(window, 'ajax:invalid-field', 'ajaxInvalidField', ['element', 'fieldName', 'errorMsg', 'isFirst']); 65 | this.migratejQueryEvent(window, 'ajax:confirm-message', 'ajaxConfirmMessage', ['message', 'promise']); 66 | this.migratejQueryEvent(window, 'ajax:error-message', 'ajaxErrorMessage', ['message']); 67 | 68 | // Data adapter 69 | this.migratejQueryAttachData(document, 'ajax:setup', 'a[data-request], button[data-request], form[data-request], a[data-handler], button[data-handler]'); 70 | } 71 | 72 | // Private 73 | migratejQueryEvent(target, jsName, jqName, detailNames = []) { 74 | var self = this; 75 | $(target).on(jsName, function(ev) { 76 | self.triggerjQueryEvent(ev.originalEvent, jqName, detailNames); 77 | }); 78 | } 79 | 80 | triggerjQueryEvent(ev, eventName, detailNames = []) { 81 | var jQueryEvent = $.Event(eventName), 82 | args = this.buildDetailArgs(ev, detailNames); 83 | 84 | $(ev.target).trigger(jQueryEvent, args); 85 | 86 | if (jQueryEvent.isDefaultPrevented()) { 87 | ev.preventDefault(); 88 | } 89 | } 90 | 91 | buildDetailArgs(ev, detailNames) { 92 | var args = []; 93 | 94 | detailNames.forEach(function(name) { 95 | args.push(ev.detail[name]); 96 | }); 97 | 98 | return args; 99 | } 100 | 101 | // For instances where data() is populated in the jQ instance 102 | migratejQueryAttachData(target, eventName, selector) { 103 | $(target).on(eventName, selector, function(event) { 104 | var dataObj = $(this).data('request-data'); 105 | if (!dataObj) { 106 | return; 107 | } 108 | 109 | var options = event.detail.context.options; 110 | if (dataObj.constructor === {}.constructor) { 111 | Object.assign(options.data, dataObj); 112 | } 113 | else if (typeof dataObj === 'string') { 114 | Object.assign(options.data, JsonParser.paramToObj('request-data', dataObj)); 115 | } 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/core/namespace.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "./controller"; 2 | import { Migrate } from "./migrate"; 3 | import { RequestBuilder } from "./request-builder"; 4 | import { JsonParser } from "../util/json-parser"; 5 | import { FormSerializer } from "../util/form-serializer"; 6 | const controller = new Controller; 7 | 8 | export default { 9 | controller, 10 | 11 | parseJSON: JsonParser.parseJSON, 12 | 13 | serializeJSON: FormSerializer.serializeJSON, 14 | 15 | requestElement: RequestBuilder.fromElement, 16 | 17 | start() { 18 | controller.start(); 19 | 20 | if (window.jQuery) { 21 | (new Migrate).bind(); 22 | } 23 | }, 24 | 25 | stop() { 26 | controller.stop(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/core/request-builder.js: -------------------------------------------------------------------------------- 1 | import Request from "../request/namespace"; 2 | import { JsonParser } from "../util/json-parser"; 3 | 4 | export class RequestBuilder 5 | { 6 | constructor(element, handler, options) { 7 | this.options = options || {}; 8 | this.ogElement = element; 9 | this.element = this.findElement(element); 10 | 11 | if (!this.element) { 12 | return Request.send(handler, this.options); 13 | } 14 | 15 | this.assignAsEval('beforeUpdateFunc', 'requestBeforeUpdate'); 16 | this.assignAsEval('afterUpdateFunc', 'requestAfterUpdate'); 17 | this.assignAsEval('successFunc', 'requestSuccess'); 18 | this.assignAsEval('errorFunc', 'requestError'); 19 | this.assignAsEval('cancelFunc', 'requestCancel'); 20 | this.assignAsEval('completeFunc', 'requestComplete'); 21 | 22 | this.assignAsData('progressBar', 'requestProgressBar'); 23 | this.assignAsData('message', 'requestMessage'); 24 | this.assignAsData('confirm', 'requestConfirm'); 25 | this.assignAsData('redirect', 'requestRedirect'); 26 | this.assignAsData('loading', 'requestLoading'); 27 | this.assignAsData('form', 'requestForm'); 28 | this.assignAsData('url', 'requestUrl'); 29 | this.assignAsData('bulk', 'requestBulk', { emptyAsTrue: true }); 30 | this.assignAsData('files', 'requestFiles', { emptyAsTrue: true }); 31 | this.assignAsData('flash', 'requestFlash', { emptyAsTrue: true }); 32 | this.assignAsData('download', 'requestDownload', { emptyAsTrue: true }); 33 | this.assignAsData('update', 'requestUpdate', { parseJson: true }); 34 | this.assignAsData('query', 'requestQuery', { emptyAsTrue: true, parseJson: true }); 35 | 36 | this.assignAsData('browserTarget', 'browserTarget'); 37 | this.assignAsData('browserValidate', 'browserValidate', { emptyAsTrue: true }); 38 | this.assignAsData('browserRedirectBack', 'browserRedirectBack', { emptyAsTrue: true }); 39 | 40 | this.assignAsMetaData('update', 'ajaxRequestUpdate', { parseJson: true, mergeValue: true }); 41 | 42 | this.assignRequestData(); 43 | 44 | if (!handler) { 45 | handler = this.getHandlerName(); 46 | } 47 | 48 | return Request.sendElement(this.element, handler, this.options); 49 | } 50 | 51 | static fromElement(element, handler, options) { 52 | if (typeof element === 'string') { 53 | element = document.querySelector(element); 54 | } 55 | 56 | return new RequestBuilder(element, handler, options); 57 | } 58 | 59 | // Event target may some random node inside the data-request container 60 | // so it should bubble up but also capture the ogElement in case it is 61 | // a button that contains data-request-data. 62 | findElement(element) { 63 | if (!element || element === document) { 64 | return null; 65 | } 66 | 67 | if (element.matches('[data-request]')) { 68 | return element; 69 | } 70 | 71 | var parentEl = element.closest('[data-request]'); 72 | if (parentEl) { 73 | return parentEl; 74 | } 75 | 76 | return element; 77 | } 78 | 79 | getHandlerName() { 80 | if (this.element.dataset.dataRequest) { 81 | return this.element.dataset.dataRequest; 82 | } 83 | 84 | return this.element.getAttribute('data-request'); 85 | } 86 | 87 | assignAsEval(optionName, name) { 88 | if (this.options[optionName] !== undefined) { 89 | return; 90 | } 91 | 92 | var attrVal; 93 | if (this.element.dataset[name]) { 94 | attrVal = this.element.dataset[name]; 95 | } 96 | else { 97 | attrVal = this.element.getAttribute('data-' + normalizeDataKey(name)); 98 | } 99 | 100 | if (!attrVal) { 101 | return; 102 | } 103 | 104 | this.options[optionName] = function(element, data) { 105 | return (new Function('data', attrVal)).apply(element, [data]); 106 | }; 107 | } 108 | 109 | assignAsData(optionName, name, { parseJson = false, emptyAsTrue = false } = {}) { 110 | if (this.options[optionName] !== undefined) { 111 | return; 112 | } 113 | 114 | var attrVal; 115 | if (this.element.dataset[name]) { 116 | attrVal = this.element.dataset[name]; 117 | } 118 | else { 119 | attrVal = this.element.getAttribute('data-' + normalizeDataKey(name)); 120 | } 121 | 122 | if (attrVal === null) { 123 | return; 124 | } 125 | 126 | attrVal = this.castAttrToOption(attrVal, emptyAsTrue); 127 | 128 | if (parseJson && typeof attrVal === 'string') { 129 | attrVal = JsonParser.paramToObj( 130 | 'data-' + normalizeDataKey(name), 131 | attrVal 132 | ); 133 | } 134 | 135 | this.options[optionName] = attrVal; 136 | } 137 | 138 | assignAsMetaData(optionName, name, { mergeValue = true, parseJson = false, emptyAsTrue = false } = {}) { 139 | const meta = document.documentElement.querySelector('head meta[name="'+normalizeDataKey(name)+'"]'); 140 | if (!meta) { 141 | return; 142 | } 143 | 144 | var attrVal = meta.getAttribute('content'); 145 | 146 | if (parseJson) { 147 | attrVal = JsonParser.paramToObj(normalizeDataKey(name), attrVal); 148 | } 149 | else { 150 | attrVal = this.castAttrToOption(attrVal, emptyAsTrue); 151 | } 152 | 153 | if (mergeValue) { 154 | this.options[optionName] = { 155 | ...(this.options[optionName] || {}), 156 | ...attrVal 157 | } 158 | } 159 | else { 160 | this.options[optionName] = attrVal; 161 | } 162 | } 163 | 164 | castAttrToOption(val, emptyAsTrue) { 165 | if (emptyAsTrue && val === '') { 166 | return true; 167 | } 168 | 169 | if (val === 'true' || val === '1') { 170 | return true; 171 | } 172 | 173 | if (val === 'false' || val === '0') { 174 | return false; 175 | } 176 | 177 | return val; 178 | } 179 | 180 | assignRequestData() { 181 | const data = {}; 182 | if (this.options.data) { 183 | Object.assign(data, this.options.data); 184 | } 185 | 186 | const attr = this.ogElement.getAttribute('data-request-data'); 187 | if (attr) { 188 | Object.assign(data, JsonParser.paramToObj('data-request-data', attr)); 189 | } 190 | 191 | elementParents(this.ogElement, '[data-request-data]').reverse().forEach(function(el) { 192 | Object.assign(data, JsonParser.paramToObj( 193 | 'data-request-data', 194 | el.getAttribute('data-request-data') 195 | )); 196 | }); 197 | 198 | this.options.data = data; 199 | } 200 | } 201 | 202 | function elementParents(element, selector) { 203 | const parents = []; 204 | if (!element.parentNode) { 205 | return parents; 206 | } 207 | 208 | let ancestor = element.parentNode.closest(selector); 209 | while (ancestor) { 210 | parents.push(ancestor); 211 | ancestor = ancestor.parentNode.closest(selector); 212 | } 213 | 214 | return parents; 215 | } 216 | 217 | function normalizeDataKey(key) { 218 | return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`) 219 | } 220 | -------------------------------------------------------------------------------- /src/extras/attach-loader.js: -------------------------------------------------------------------------------- 1 | import { unindent } from "../util"; 2 | 3 | export class AttachLoader 4 | { 5 | static stylesheetReady = false; 6 | 7 | constructor() { 8 | this.stylesheetElement = this.createStylesheetElement(); 9 | } 10 | 11 | static get defaultCSS() { 12 | return unindent ` 13 | .oc-attach-loader:after { 14 | content: ''; 15 | display: inline-block; 16 | vertical-align: middle; 17 | margin-left: .4em; 18 | height: 1em; 19 | width: 1em; 20 | animation: oc-rotate-loader 0.8s infinite linear; 21 | border: .2em solid currentColor; 22 | border-right-color: transparent; 23 | border-radius: 50%; 24 | opacity: .5; 25 | } 26 | @keyframes oc-rotate-loader { 27 | 0% { transform: rotate(0deg); } 28 | 100% { transform: rotate(360deg); } 29 | } 30 | `; 31 | } 32 | 33 | static get attachLoader() { 34 | return { 35 | show: function(el) { 36 | (new AttachLoader).show(resolveElement(el)); 37 | }, 38 | hide: function(el) { 39 | (new AttachLoader).hide(resolveElement(el)); 40 | }, 41 | hideAll: function() { 42 | (new AttachLoader).hideAll(); 43 | } 44 | }; 45 | } 46 | 47 | // Public 48 | show(el) { 49 | this.installStylesheetElement(); 50 | 51 | if (isElementInput(el)) { 52 | const loadEl = document.createElement('span'); 53 | loadEl.className = 'oc-attach-loader is-inline'; 54 | el.parentNode.insertBefore(loadEl, el.nextSibling); // insertAfter 55 | } 56 | else { 57 | el.classList.add('oc-attach-loader'); 58 | el.disabled = true; 59 | } 60 | } 61 | 62 | hide(el) { 63 | if (isElementInput(el)) { 64 | if (el.nextElementSibling && el.nextElementSibling.classList.contains('oc-attach-loader')) { 65 | el.nextElementSibling.remove(); 66 | } 67 | } 68 | else { 69 | el.classList.remove('oc-attach-loader'); 70 | el.disabled = false; 71 | } 72 | } 73 | 74 | hideAll() { 75 | document.querySelectorAll('.oc-attach-loader.is-inline').forEach((el) => { 76 | el.remove(); 77 | }); 78 | 79 | document.querySelectorAll('.oc-attach-loader').forEach((el) => { 80 | el.classList.remove('oc-attach-loader'); 81 | el.disabled = false; 82 | }); 83 | } 84 | 85 | showForm(el) { 86 | if (el.dataset.attachLoading !== undefined) { 87 | this.show(el); 88 | } 89 | 90 | if (el.matches('form')) { 91 | var self = this; 92 | el.querySelectorAll('[data-attach-loading][type=submit]').forEach(function(otherEl) { 93 | if (!isElementInput(otherEl)) { 94 | self.show(otherEl); 95 | } 96 | }); 97 | } 98 | } 99 | 100 | hideForm(el) { 101 | if (el.dataset.attachLoading !== undefined) { 102 | this.hide(el); 103 | } 104 | 105 | if (el.matches('form')) { 106 | var self = this; 107 | el.querySelectorAll('[data-attach-loading]').forEach(function(otherEl) { 108 | if (!isElementInput(otherEl)) { 109 | self.hide(otherEl); 110 | } 111 | }); 112 | } 113 | } 114 | 115 | // Private 116 | installStylesheetElement() { 117 | if (!AttachLoader.stylesheetReady) { 118 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild); 119 | AttachLoader.stylesheetReady = true; 120 | } 121 | } 122 | 123 | createStylesheetElement() { 124 | const element = document.createElement('style'); 125 | element.textContent = AttachLoader.defaultCSS; 126 | return element; 127 | } 128 | } 129 | 130 | function isElementInput(el) { 131 | return ['input', 'select', 'textarea'].includes((el.tagName || '').toLowerCase()); 132 | } 133 | 134 | function resolveElement(el) { 135 | if (typeof el === 'string') { 136 | el = document.querySelector(el); 137 | } 138 | 139 | if (!el) { 140 | throw new Error("Invalid element for attach loader."); 141 | } 142 | 143 | return el; 144 | } 145 | -------------------------------------------------------------------------------- /src/extras/controller.js: -------------------------------------------------------------------------------- 1 | import { Validator } from "./validator"; 2 | import { AttachLoader } from "./attach-loader"; 3 | import { FlashMessage } from "./flash-message"; 4 | import { Events } from "../util/events"; 5 | import { getReferrerUrl } from "../util/referrer"; 6 | 7 | export class Controller 8 | { 9 | constructor() { 10 | this.started = false; 11 | 12 | // Progress bar default value 13 | this.enableProgressBar = function(event) { 14 | const { options } = event.detail.context; 15 | if (options.progressBar === null) { 16 | options.progressBar = true; 17 | } 18 | } 19 | 20 | // Attach loader 21 | this.showAttachLoader = (event) => { 22 | this.attachLoader.showForm(event.target); 23 | }; 24 | 25 | this.hideAttachLoader = (event) => { 26 | this.attachLoader.hideForm(event.target); 27 | }; 28 | 29 | this.hideAllAttachLoaders = (event) => { 30 | this.attachLoader.hideAll(); 31 | }; 32 | 33 | // Validator 34 | this.validatorSubmit = (event) => { 35 | this.validator.submit(event.target); 36 | }; 37 | 38 | this.validatorValidate = (event) => { 39 | this.validator.validate( 40 | event.target, 41 | event.detail.fields, 42 | event.detail.message, 43 | shouldShowFlashMessage(event.detail.context.options.flash, 'validate') 44 | ); 45 | }; 46 | 47 | // Flash message 48 | this.flashMessageBind = (event) => { 49 | const { options } = event.detail.context; 50 | if (options.flash) { 51 | options.handleErrorMessage = (message) => { 52 | if ( 53 | message && 54 | shouldShowFlashMessage(options.flash, 'error') || 55 | shouldShowFlashMessage(options.flash, 'validate') 56 | ) { 57 | this.flashMessage.show({ message, type: 'error' }); 58 | } 59 | } 60 | 61 | options.handleFlashMessage = (message, type) => { 62 | if (message && shouldShowFlashMessage(options.flash, type)) { 63 | this.flashMessage.show({ message, type }); 64 | } 65 | } 66 | } 67 | 68 | var context = event.detail; 69 | options.handleProgressMessage = (message, isDone) => { 70 | if (!isDone) { 71 | context.progressMessageId = this.flashMessage.show({ message, type: 'loading', interval: 10 }); 72 | } 73 | else { 74 | this.flashMessage.show(context.progressMessageId 75 | ? { replace: context.progressMessageId } 76 | : { hideAll: true }); 77 | 78 | context = null; 79 | } 80 | } 81 | }; 82 | 83 | this.flashMessageRender = (event) => { 84 | this.flashMessage.render(); 85 | }; 86 | 87 | this.hideAllFlashMessages = (event) => { 88 | this.flashMessage.hideAll(); 89 | }; 90 | 91 | // Browser redirect 92 | this.handleBrowserRedirect = function(event) { 93 | if (event.defaultPrevented) { 94 | return; 95 | } 96 | 97 | const href = getReferrerUrl(); 98 | if (!href) { 99 | return; 100 | } 101 | 102 | event.preventDefault(); 103 | if (oc.useTurbo && oc.useTurbo()) { 104 | oc.visit(href); 105 | } 106 | else { 107 | location.assign(href); 108 | } 109 | }; 110 | } 111 | 112 | start() { 113 | if (!this.started) { 114 | // Progress bar 115 | addEventListener('ajax:setup', this.enableProgressBar); 116 | 117 | // Attach loader 118 | this.attachLoader = new AttachLoader; 119 | Events.on(document, 'ajax:promise', 'form, [data-attach-loading]', this.showAttachLoader); 120 | Events.on(document, 'ajax:fail', 'form, [data-attach-loading]', this.hideAttachLoader); 121 | Events.on(document, 'ajax:done', 'form, [data-attach-loading]', this.hideAttachLoader); 122 | addEventListener('page:before-cache', this.hideAllAttachLoaders); 123 | 124 | // Validator 125 | this.validator = new Validator; 126 | Events.on(document, 'ajax:before-validate', '[data-request-validate]', this.validatorValidate); 127 | Events.on(document, 'ajax:promise', '[data-request-validate]', this.validatorSubmit); 128 | 129 | // Flash message 130 | this.flashMessage = new FlashMessage; 131 | addEventListener('render', this.flashMessageRender); 132 | addEventListener('ajax:setup', this.flashMessageBind); 133 | addEventListener('page:before-cache', this.hideAllFlashMessages); 134 | 135 | // Browser redirect 136 | Events.on(document, 'click', '[data-browser-redirect-back]', this.handleBrowserRedirect); 137 | 138 | this.started = true; 139 | } 140 | } 141 | 142 | stop() { 143 | if (this.started) { 144 | // Progress bar 145 | removeEventListener('ajax:setup', this.enableProgressBar); 146 | 147 | // Attach loader 148 | this.attachLoader = null; 149 | Events.off(document, 'ajax:promise', 'form, [data-attach-loading]', this.showAttachLoader); 150 | Events.off(document, 'ajax:fail', 'form, [data-attach-loading]', this.hideAttachLoader); 151 | Events.off(document, 'ajax:done', 'form, [data-attach-loading]', this.hideAttachLoader); 152 | removeEventListener('page:before-cache', this.hideAllAttachLoaders); 153 | 154 | // Validator 155 | this.validator = null; 156 | Events.off(document, 'ajax:before-validate', '[data-request-validate]', this.validatorValidate); 157 | Events.off(document, 'ajax:promise', '[data-request-validate]', this.validatorSubmit); 158 | 159 | // Flash message 160 | this.flashMessage = null; 161 | removeEventListener('render', this.flashMessageRender); 162 | removeEventListener('ajax:setup', this.flashMessageBind); 163 | removeEventListener('page:before-cache', this.hideAllFlashMessages); 164 | 165 | // Browser redirect 166 | Events.off(document, 'click', '[data-browser-redirect-back]', this.handleBrowserRedirect); 167 | 168 | this.started = false; 169 | } 170 | } 171 | } 172 | 173 | function shouldShowFlashMessage(value, type) { 174 | // Validation messages are not included by default 175 | if (value === true && type !== 'validate') { 176 | return true; 177 | } 178 | 179 | if (typeof value !== 'string') { 180 | return false; 181 | } 182 | 183 | if (value === '*') { 184 | return true; 185 | } 186 | 187 | let result = false; 188 | value.split(',').forEach(function(validType) { 189 | if (validType.trim() === type) { 190 | result = true; 191 | } 192 | }); 193 | 194 | return result; 195 | } 196 | -------------------------------------------------------------------------------- /src/extras/flash-message.js: -------------------------------------------------------------------------------- 1 | import { unindent } from "../util"; 2 | 3 | export class FlashMessage 4 | { 5 | static instance = null; 6 | static stylesheetReady = false; 7 | 8 | constructor() { 9 | this.queue = []; 10 | this.lastUniqueId = 0; 11 | this.displayedMessage = null; 12 | this.stylesheetElement = this.createStylesheetElement(); 13 | } 14 | 15 | static get defaultCSS() { 16 | return unindent ` 17 | .oc-flash-message { 18 | display: flex; 19 | position: fixed; 20 | z-index: 10300; 21 | width: 500px; 22 | left: 50%; 23 | top: 50px; 24 | margin-left: -250px; 25 | color: #fff; 26 | font-size: 1rem; 27 | padding: 10px 15px; 28 | border-radius: 5px; 29 | opacity: 0; 30 | transition: all 0.5s, width 0s; 31 | transform: scale(0.9); 32 | } 33 | @media (max-width: 768px) { 34 | .oc-flash-message { 35 | left: 1rem; 36 | right: 1rem; 37 | top: 1rem; 38 | margin-left: 0; 39 | width: auto; 40 | } 41 | } 42 | .oc-flash-message.flash-show { 43 | opacity: 1; 44 | transform: scale(1); 45 | } 46 | .oc-flash-message.loading { 47 | transition: opacity 0.2s; 48 | transform: scale(1); 49 | } 50 | .oc-flash-message.success { 51 | background: #86cb43; 52 | } 53 | .oc-flash-message.error { 54 | background: #cc3300; 55 | } 56 | .oc-flash-message.warning { 57 | background: #f0ad4e; 58 | } 59 | .oc-flash-message.info, .oc-flash-message.loading { 60 | background: #5fb6f5; 61 | } 62 | .oc-flash-message span.flash-message { 63 | flex-grow: 1; 64 | } 65 | .oc-flash-message a.flash-close { 66 | box-sizing: content-box; 67 | width: 1em; 68 | height: 1em; 69 | padding: .25em .25em; 70 | background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23FFF'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat; 71 | border: 0; 72 | border-radius: .25rem; 73 | opacity: .5; 74 | text-decoration: none; 75 | cursor: pointer; 76 | } 77 | .oc-flash-message a.flash-close:hover, 78 | .oc-flash-message a.flash-close:focus { 79 | opacity: 1; 80 | } 81 | .oc-flash-message.loading a.flash-close { 82 | display: none; 83 | } 84 | .oc-flash-message span.flash-loader { 85 | margin-right: 1em; 86 | } 87 | .oc-flash-message span.flash-loader:after { 88 | position: relative; 89 | top: 2px; 90 | content: ''; 91 | display: inline-block; 92 | height: 1.2em; 93 | width: 1.2em; 94 | animation: oc-flash-loader 0.8s infinite linear; 95 | border: .2em solid currentColor; 96 | border-right-color: transparent; 97 | border-radius: 50%; 98 | opacity: .5; 99 | } 100 | html[data-turbo-preview] .oc-flash-message { 101 | opacity: 0; 102 | } 103 | @keyframes oc-flash-loader { 104 | 0% { transform: rotate(0deg); } 105 | 100% { transform: rotate(360deg); } 106 | } 107 | `; 108 | } 109 | 110 | static flashMsg(options) { 111 | return getOrCreateInstance().show(options); 112 | } 113 | 114 | runQueue() { 115 | if (this.displayedMessage) { 116 | return; 117 | } 118 | 119 | var options = this.queue.shift(); 120 | if (options === undefined) { 121 | return; 122 | } 123 | 124 | this.buildFlashMessage(options); 125 | } 126 | 127 | clearQueue() { 128 | this.queue = []; 129 | 130 | if (this.displayedMessage && this.displayedMessage.uniqueId) { 131 | this.hide(this.displayedMessage.uniqueId, true); 132 | } 133 | } 134 | 135 | removeFromQueue(uniqueId) { 136 | for (var index = 0; index < this.queue.length; index++) { 137 | if (this.queue[index].uniqueId == uniqueId) { 138 | this.queue.splice(index, 1); 139 | return; 140 | } 141 | } 142 | } 143 | 144 | show(options = {}) { 145 | this.installStylesheetElement(); 146 | 147 | let { 148 | message = '', 149 | type = 'info', 150 | replace = null, 151 | hideAll = false 152 | } = options; 153 | 154 | // Legacy API 155 | if (options.text) message = options.text; 156 | if (options.class) type = options.class; 157 | 158 | // Clear all messages 159 | if (hideAll || type === 'error' || type === 'loading') { 160 | this.clearQueue(); 161 | } 162 | 163 | // Replace or remove a message 164 | if (replace) { 165 | if (this.displayedMessage && replace === this.displayedMessage.uniqueId) { 166 | this.hide(replace, true); 167 | } 168 | else { 169 | this.removeFromQueue(replace); 170 | } 171 | } 172 | 173 | // Nothing to show 174 | if (!message) { 175 | return; 176 | } 177 | 178 | var uniqueId = this.makeUniqueId(); 179 | 180 | this.queue.push({ 181 | ...options, 182 | uniqueId: uniqueId 183 | }); 184 | 185 | this.runQueue(); 186 | 187 | return uniqueId; 188 | } 189 | 190 | makeUniqueId() { 191 | return ++this.lastUniqueId; 192 | } 193 | 194 | buildFlashMessage(options = {}) { 195 | let { 196 | message = '', 197 | type = 'info', 198 | target = null, 199 | interval = 3 200 | } = options; 201 | 202 | // Legacy API 203 | if (options.text) message = options.text; 204 | if (options.class) type = options.class; 205 | 206 | // Idempotence 207 | if (target) { 208 | target.removeAttribute('data-control'); 209 | } 210 | 211 | // Inject element 212 | var flashElement = this.createFlashElement(message, type); 213 | this.createMessagesElement().appendChild(flashElement); 214 | 215 | this.displayedMessage = { 216 | uniqueId: options.uniqueId, 217 | element: flashElement, 218 | options 219 | }; 220 | 221 | // Remove logic 222 | var remove = (event) => { 223 | clearInterval(timer); 224 | flashElement.removeEventListener('click', pause); 225 | flashElement.removeEventListener('extras:flash-remove', remove); 226 | flashElement.querySelector('.flash-close').removeEventListener('click', remove); 227 | flashElement.classList.remove('flash-show'); 228 | 229 | if (event && event.detail.isReplace) { 230 | flashElement.remove(); 231 | this.displayedMessage = null; 232 | this.runQueue(); 233 | } 234 | else { 235 | setTimeout(() => { 236 | flashElement.remove(); 237 | this.displayedMessage = null; 238 | this.runQueue(); 239 | }, 600); 240 | } 241 | }; 242 | 243 | // Pause logic 244 | var pause = () => { 245 | clearInterval(timer); 246 | }; 247 | 248 | // Events 249 | flashElement.addEventListener('click', pause, { once: true }); 250 | flashElement.addEventListener('extras:flash-remove', remove, { once: true }); 251 | flashElement.querySelector('.flash-close').addEventListener('click', remove, { once: true }); 252 | 253 | // Timeout 254 | var timer; 255 | if (interval && interval !== 0) { 256 | timer = setTimeout(remove, interval * 1000); 257 | } 258 | 259 | setTimeout(() => { 260 | flashElement.classList.add('flash-show'); 261 | }, 20); 262 | } 263 | 264 | render() { 265 | document.querySelectorAll('[data-control=flash-message]').forEach((el) => { 266 | this.show({ ...el.dataset, target: el, message: el.innerHTML }); 267 | el.remove(); 268 | }); 269 | } 270 | 271 | hide(uniqueId, isReplace) { 272 | if (this.displayedMessage && uniqueId === this.displayedMessage.uniqueId) { 273 | this.displayedMessage.element.dispatchEvent(new CustomEvent('extras:flash-remove', { 274 | detail: { isReplace } 275 | })); 276 | } 277 | } 278 | 279 | hideAll() { 280 | this.clearQueue(); 281 | this.displayedMessage = null; 282 | 283 | document.querySelectorAll('.oc-flash-message, [data-control=flash-message]').forEach((el) => { 284 | el.remove(); 285 | }); 286 | } 287 | 288 | createFlashElement(message, type) { 289 | const element = document.createElement('div'); 290 | const loadingHtml = type === 'loading' ? '' : ''; 291 | const closeHtml = ''; 292 | element.className = 'oc-flash-message ' + type; 293 | element.innerHTML = loadingHtml + '' + message + '' + closeHtml; 294 | return element; 295 | } 296 | 297 | // Private 298 | installStylesheetElement() { 299 | if (!FlashMessage.stylesheetReady) { 300 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild); 301 | FlashMessage.stylesheetReady = true; 302 | } 303 | } 304 | 305 | createStylesheetElement() { 306 | const element = document.createElement('style'); 307 | element.textContent = FlashMessage.defaultCSS; 308 | return element; 309 | } 310 | 311 | createMessagesElement() { 312 | const found = document.querySelector('.oc-flash-messages') 313 | if (found) { 314 | return found; 315 | } 316 | 317 | const element = document.createElement('div'); 318 | element.className = 'oc-flash-messages'; 319 | document.body.appendChild(element); 320 | return element; 321 | } 322 | } 323 | 324 | function getOrCreateInstance() { 325 | if (!FlashMessage.instance) { 326 | FlashMessage.instance = new FlashMessage; 327 | } 328 | 329 | return FlashMessage.instance; 330 | } 331 | -------------------------------------------------------------------------------- /src/extras/index.js: -------------------------------------------------------------------------------- 1 | import namespace from "./namespace"; 2 | export default namespace; 3 | 4 | if (!window.oc) { 5 | window.oc = {}; 6 | } 7 | 8 | if (!window.oc.AjaxExtras) { 9 | // Namespace 10 | window.oc.AjaxExtras = namespace; 11 | 12 | // Flash messages 13 | window.oc.flashMsg = namespace.flashMsg; 14 | 15 | // Progress bar 16 | window.oc.progressBar = namespace.progressBar; 17 | 18 | // Attach loader 19 | window.oc.attachLoader = namespace.attachLoader; 20 | 21 | // Boot controller 22 | if (!isAMD() && !isCommonJS()) { 23 | namespace.start(); 24 | } 25 | } 26 | 27 | function isAMD() { 28 | return typeof define == "function" && define.amd; 29 | } 30 | 31 | function isCommonJS() { 32 | return typeof exports == "object" && typeof module != "undefined"; 33 | } 34 | -------------------------------------------------------------------------------- /src/extras/migrate.js: -------------------------------------------------------------------------------- 1 | 2 | export class Migrate 3 | { 4 | bind() { 5 | if ($.oc === undefined) { 6 | $.oc = {}; 7 | } 8 | 9 | $.oc.flashMsg = window.oc.flashMsg; 10 | $.oc.stripeLoadIndicator = window.oc.progressBar; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/extras/namespace.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "./controller"; 2 | import { Migrate } from "./migrate"; 3 | import { FlashMessage } from "./flash-message"; 4 | import { ProgressBar } from "./progress-bar"; 5 | import { AttachLoader } from "./attach-loader"; 6 | const controller = new Controller; 7 | 8 | export default { 9 | controller, 10 | 11 | flashMsg: FlashMessage.flashMsg, 12 | 13 | progressBar: ProgressBar.progressBar, 14 | 15 | attachLoader: AttachLoader.attachLoader, 16 | 17 | start() { 18 | controller.start(); 19 | 20 | if (window.jQuery) { 21 | (new Migrate).bind(); 22 | } 23 | }, 24 | 25 | stop() { 26 | controller.stop(); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/extras/progress-bar.js: -------------------------------------------------------------------------------- 1 | import { unindent } from "../util"; 2 | 3 | export class ProgressBar 4 | { 5 | static instance = null; 6 | static stylesheetReady = false; 7 | static animationDuration = 300; 8 | 9 | constructor() { 10 | this.stylesheetElement = this.createStylesheetElement(); 11 | this.progressElement = this.createProgressElement(); 12 | this.hiding = false; 13 | this.value = 0; 14 | this.visible = false; 15 | this.trickle = () => { 16 | this.setValue(this.value + Math.random() / 100); 17 | }; 18 | } 19 | 20 | static get defaultCSS() { 21 | return unindent ` 22 | .oc-progress-bar { 23 | position: fixed; 24 | display: block; 25 | top: 0; 26 | left: 0; 27 | height: 3px; 28 | background: #0076ff; 29 | z-index: 9999; 30 | transition: 31 | width ${ProgressBar.animationDuration}ms ease-out, 32 | opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in; 33 | transform: translate3d(0, 0, 0); 34 | } 35 | `; 36 | } 37 | 38 | static get progressBar() { 39 | return { 40 | show: function() { 41 | const instance = getOrCreateInstance(); 42 | instance.setValue(0); 43 | instance.show(); 44 | }, 45 | hide: function() { 46 | const instance = getOrCreateInstance(); 47 | instance.setValue(100); 48 | instance.hide(); 49 | } 50 | }; 51 | } 52 | 53 | show(options = {}) { 54 | if (options.cssClass) { 55 | this.progressElement.classList.add(options.cssClass); 56 | } 57 | 58 | if (!this.visible) { 59 | this.visible = true; 60 | this.installStylesheetElement(); 61 | this.installProgressElement(); 62 | this.startTrickling(); 63 | } 64 | } 65 | 66 | hide() { 67 | if (this.visible && !this.hiding) { 68 | this.hiding = true; 69 | this.fadeProgressElement(() => { 70 | this.uninstallProgressElement(); 71 | this.stopTrickling(); 72 | this.visible = false; 73 | this.hiding = false; 74 | }); 75 | } 76 | } 77 | 78 | setValue(value) { 79 | this.value = value; 80 | this.refresh(); 81 | } 82 | 83 | // Private 84 | installStylesheetElement() { 85 | if (!ProgressBar.stylesheetReady) { 86 | document.head.insertBefore(this.stylesheetElement, document.head.firstChild); 87 | ProgressBar.stylesheetReady = true; 88 | } 89 | } 90 | 91 | installProgressElement() { 92 | this.progressElement.style.width = "0"; 93 | this.progressElement.style.opacity = "1"; 94 | document.documentElement.insertBefore(this.progressElement, document.body); 95 | this.refresh(); 96 | } 97 | 98 | fadeProgressElement(callback) { 99 | this.progressElement.style.opacity = "0"; 100 | setTimeout(callback, ProgressBar.animationDuration * 1.5); 101 | } 102 | 103 | uninstallProgressElement() { 104 | if (this.progressElement.parentNode) { 105 | document.documentElement.removeChild(this.progressElement); 106 | } 107 | } 108 | 109 | startTrickling() { 110 | if (!this.trickleInterval) { 111 | this.trickleInterval = setInterval(this.trickle, ProgressBar.animationDuration); 112 | } 113 | } 114 | 115 | stopTrickling() { 116 | clearInterval(this.trickleInterval); 117 | delete this.trickleInterval; 118 | } 119 | 120 | refresh() { 121 | requestAnimationFrame(() => { 122 | this.progressElement.style.width = `${10 + (this.value * 90)}%`; 123 | }); 124 | } 125 | 126 | createStylesheetElement() { 127 | const element = document.createElement('style'); 128 | element.textContent = ProgressBar.defaultCSS; 129 | return element; 130 | } 131 | 132 | createProgressElement() { 133 | const element = document.createElement('div'); 134 | element.className = 'oc-progress-bar'; 135 | return element; 136 | } 137 | } 138 | 139 | function getOrCreateInstance() { 140 | if (!ProgressBar.instance) { 141 | ProgressBar.instance = new ProgressBar; 142 | } 143 | 144 | return ProgressBar.instance; 145 | } 146 | -------------------------------------------------------------------------------- /src/extras/validator.js: -------------------------------------------------------------------------------- 1 | import { Events } from "../util/events"; 2 | 3 | export class Validator 4 | { 5 | submit(el) { 6 | var form = el.closest('form'); 7 | if (!form) { 8 | return; 9 | } 10 | 11 | form.querySelectorAll('[data-validate-for]').forEach(function(el) { 12 | el.classList.remove('oc-visible'); 13 | }); 14 | 15 | form.querySelectorAll('[data-validate-error]').forEach(function(el) { 16 | el.classList.remove('oc-visible'); 17 | }); 18 | } 19 | 20 | validate(el, fields, errorMsg, allowDefault) { 21 | var form = el.closest('form'), 22 | messages = []; 23 | 24 | if (!form) { 25 | return; 26 | } 27 | 28 | for (var fieldName in fields) { 29 | // Build messages 30 | var fieldMessages = fields[fieldName]; 31 | messages = [...messages, ...fieldMessages]; 32 | 33 | // Display message next to field 34 | var field = form.querySelector('[data-validate-for="'+fieldName+'"]'); 35 | if (field) { 36 | if (!field.innerHTML || field.dataset.emptyMode) { 37 | field.dataset.emptyMode = true; 38 | field.innerHTML = fieldMessages.join(', '); 39 | } 40 | field.classList.add('oc-visible'); 41 | } 42 | } 43 | 44 | var container = form.querySelector('[data-validate-error]'); 45 | if (container) { 46 | container.classList.add('oc-visible'); 47 | 48 | // Messages found inside the container 49 | var oldMessages = container.querySelectorAll('[data-message]'); 50 | if (oldMessages.length > 0) { 51 | var clone = oldMessages[0]; 52 | messages.forEach(function(message) { 53 | var newNode = clone.cloneNode(true); 54 | newNode.innerHTML = message; 55 | // Insert after 56 | clone.parentNode.insertBefore(newNode, clone.nextSibling); 57 | }); 58 | 59 | oldMessages.forEach(function(el) { 60 | el.remove(); 61 | }); 62 | } 63 | // Just use the container to set the value 64 | else { 65 | container.innerHTML = errorMsg; 66 | } 67 | } 68 | 69 | // Flash messages want a pass here 70 | if (allowDefault) { 71 | return; 72 | } 73 | 74 | // Prevent default error behavior 75 | Events.one(form, 'ajax:request-error', function(event) { 76 | event.preventDefault(); 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/framework-bundle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * October CMS: Frontend JavaScript Framework 4 | * https://octobercms.com 5 | * -------------------------------------------------------------------------- 6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges 7 | * -------------------------------------------------------------------------- 8 | */ 9 | 10 | import "./request"; 11 | import "./core"; 12 | import "./extras"; 13 | import "./observe"; 14 | import "./turbo"; 15 | -------------------------------------------------------------------------------- /src/framework-extras.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * October CMS: Frontend JavaScript Framework 4 | * https://octobercms.com 5 | * -------------------------------------------------------------------------- 6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges 7 | * -------------------------------------------------------------------------- 8 | */ 9 | 10 | import "./request"; 11 | import "./core"; 12 | import "./extras"; 13 | import "./observe"; 14 | -------------------------------------------------------------------------------- /src/framework-turbo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * October CMS: Frontend JavaScript Framework 4 | * https://octobercms.com 5 | * -------------------------------------------------------------------------- 6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges 7 | * -------------------------------------------------------------------------- 8 | */ 9 | 10 | import "./request"; 11 | import "./core"; 12 | import "./turbo"; 13 | -------------------------------------------------------------------------------- /src/framework.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * October CMS: Frontend JavaScript Framework 4 | * https://octobercms.com 5 | * -------------------------------------------------------------------------- 6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges 7 | * -------------------------------------------------------------------------- 8 | */ 9 | 10 | import "./request"; 11 | import "./core"; 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * -------------------------------------------------------------------------- 3 | * October CMS: Frontend JavaScript Framework 4 | * https://octobercms.com 5 | * -------------------------------------------------------------------------- 6 | * Copyright 2013-2023 Alexey Bobkov, Samuel Georges 7 | * -------------------------------------------------------------------------- 8 | */ 9 | 10 | import AjaxFramework from "./core/namespace"; 11 | import AjaxRequest from "./request/namespace"; 12 | import AjaxExtras from "./extras/namespace"; 13 | import AjaxObserve from "./observe/namespace"; 14 | import AjaxTurbo from "./turbo/namespace"; 15 | import { ControlBase } from "./observe/control-base"; 16 | import { AssetManager } from "./request/asset-manager"; 17 | import { Events } from "./util/events"; 18 | import { waitFor } from "./util/wait"; 19 | 20 | export default { 21 | AjaxFramework, 22 | AjaxRequest, 23 | AjaxExtras, 24 | AjaxObserve, 25 | AjaxTurbo, 26 | ControlBase, 27 | AssetManager, 28 | Events, 29 | waitFor, 30 | ajax: AjaxRequest.send, 31 | request: AjaxFramework.requestElement, 32 | parseJSON: AjaxFramework.parseJSON, 33 | serializeJSON: AjaxFramework.serializeJSON, 34 | flashMsg: AjaxExtras.flashMsg, 35 | progressBar: AjaxExtras.progressBar, 36 | attachLoader: AjaxExtras.attachLoader, 37 | useTurbo: AjaxTurbo.isEnabled, 38 | pageReady: AjaxTurbo.pageReady, 39 | visit: AjaxTurbo.visit, 40 | registerControl: AjaxObserve.registerControl, 41 | importControl: AjaxObserve.importControl, 42 | observeControl: AjaxObserve.observeControl, 43 | fetchControl: AjaxObserve.fetchControl, 44 | fetchControls: AjaxObserve.fetchControls 45 | }; 46 | -------------------------------------------------------------------------------- /src/observe/application.js: -------------------------------------------------------------------------------- 1 | import { Dispatcher } from "./dispatcher"; 2 | import { Container } from "./container"; 3 | import { domReady } from "../util/wait"; 4 | 5 | export class Application 6 | { 7 | constructor() { 8 | this.started = false; 9 | this.element = document.documentElement; 10 | this.dispatcher = new Dispatcher(this); 11 | this.container = new Container(this); 12 | } 13 | 14 | startAsync() { 15 | domReady().then(() => { 16 | this.start(); 17 | }); 18 | } 19 | 20 | start() { 21 | if (!this.started) { 22 | this.started = true; 23 | this.dispatcher.start(); 24 | this.container.start(); 25 | } 26 | } 27 | 28 | stop() { 29 | if (this.started) { 30 | this.dispatcher.stop(); 31 | this.container.stop(); 32 | this.started = false; 33 | } 34 | } 35 | 36 | register(identifier, controlConstructor) { 37 | this.load({ identifier, controlConstructor }); 38 | } 39 | 40 | observe(element, identifier) { 41 | const observer = this.container.scopeObserver; 42 | observer.elementMatchedValue(element, observer.parseValueForToken({ 43 | element, 44 | content: identifier 45 | })); 46 | 47 | const foundControl = this.getControlForElementAndIdentifier(element, identifier); 48 | if (!element.matches(`[data-control~="${identifier}"]`)) { 49 | element.dataset.control = ((element.dataset.control || '') + ' ' + identifier).trim(); 50 | } 51 | return foundControl; 52 | } 53 | 54 | import(identifier) { 55 | const module = this.container.getModuleForIdentifier(identifier); 56 | if (!module) { 57 | throw new Error(`Control is not registered [${identifier}]`); 58 | } 59 | 60 | return module.controlConstructor; 61 | } 62 | 63 | fetch(element, identifier) { 64 | if (typeof element === 'string') { 65 | element = document.querySelector(element); 66 | } 67 | 68 | if (!identifier) { 69 | identifier = element.dataset.control; 70 | } 71 | 72 | return element 73 | ? this.getControlForElementAndIdentifier(element, identifier) 74 | : null; 75 | } 76 | 77 | fetchAll(elements, identifier) { 78 | if (typeof elements === 'string') { 79 | elements = document.querySelectorAll(elements); 80 | } 81 | 82 | const result = []; 83 | elements.forEach((element) => { 84 | const control = this.fetch(element, identifier); 85 | if (control) { 86 | result.push(control); 87 | } 88 | }); 89 | return result; 90 | } 91 | 92 | load(head, ...rest) { 93 | const definitions = Array.isArray(head) ? head : [head, ...rest]; 94 | definitions.forEach((definition) => { 95 | if (definition.controlConstructor.shouldLoad) { 96 | this.container.loadDefinition(definition); 97 | } 98 | }); 99 | } 100 | 101 | unload(head, ...rest) { 102 | const identifiers = Array.isArray(head) ? head : [head, ...rest]; 103 | identifiers.forEach((identifier) => this.container.unloadIdentifier(identifier)); 104 | } 105 | 106 | // Controls 107 | get controls() { 108 | return this.container.contexts.map((context) => context.control); 109 | } 110 | 111 | getControlForElementAndIdentifier(element, identifier) { 112 | const context = this.container.getContextForElementAndIdentifier(element, identifier); 113 | return context ? context.control : null; 114 | } 115 | 116 | // Error handling 117 | handleError(error, message, detail) { 118 | var _a; 119 | console.error(`%s\n\n%o\n\n%o`, message, error, detail); 120 | (_a = window.onerror) === null || _a === void 0 ? void 0 : _a.call(window, message, "", 0, 0, error); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/observe/container.js: -------------------------------------------------------------------------------- 1 | import { Module } from "./module"; 2 | import { Scope } from "./scope"; 3 | import { ScopeObserver } from "./scope-observer"; 4 | import { Multimap } from "./util/multimap"; 5 | 6 | export class Container 7 | { 8 | constructor(application) { 9 | this.application = application; 10 | this.scopeObserver = new ScopeObserver(this.element, this); 11 | this.scopesByIdentifier = new Multimap(); 12 | this.modulesByIdentifier = new Map(); 13 | } 14 | 15 | get element() { 16 | return this.application.element; 17 | } 18 | 19 | get modules() { 20 | return Array.from(this.modulesByIdentifier.values()); 21 | } 22 | 23 | get contexts() { 24 | return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), []); 25 | } 26 | 27 | start() { 28 | this.scopeObserver.start(); 29 | } 30 | 31 | stop() { 32 | this.scopeObserver.stop(); 33 | } 34 | 35 | loadDefinition(definition) { 36 | this.unloadIdentifier(definition.identifier); 37 | const module = new Module(this.application, definition); 38 | this.connectModule(module); 39 | const afterLoad = definition.controlConstructor.afterLoad; 40 | if (afterLoad) { 41 | afterLoad.call(definition.controlConstructor, definition.identifier, this.application); 42 | } 43 | } 44 | 45 | unloadIdentifier(identifier) { 46 | const module = this.modulesByIdentifier.get(identifier); 47 | if (module) { 48 | this.disconnectModule(module); 49 | } 50 | } 51 | 52 | getModuleForIdentifier(identifier) { 53 | return this.modulesByIdentifier.get(identifier); 54 | } 55 | 56 | getContextForElementAndIdentifier(element, identifier) { 57 | const module = this.modulesByIdentifier.get(identifier); 58 | if (module) { 59 | return module.contexts.find((context) => context.element == element); 60 | } 61 | } 62 | 63 | // Error handler delegate 64 | handleError(error, message, detail) { 65 | this.application.handleError(error, message, detail); 66 | } 67 | 68 | // Scope observer delegate 69 | createScopeForElementAndIdentifier(element, identifier) { 70 | return new Scope(element, identifier); 71 | } 72 | 73 | scopeConnected(scope) { 74 | this.scopesByIdentifier.add(scope.identifier, scope); 75 | const module = this.modulesByIdentifier.get(scope.identifier); 76 | if (module) { 77 | module.connectContextForScope(scope); 78 | } 79 | } 80 | 81 | scopeDisconnected(scope) { 82 | this.scopesByIdentifier.delete(scope.identifier, scope); 83 | const module = this.modulesByIdentifier.get(scope.identifier); 84 | if (module) { 85 | module.disconnectContextForScope(scope); 86 | } 87 | } 88 | 89 | // Modules 90 | connectModule(module) { 91 | this.modulesByIdentifier.set(module.identifier, module); 92 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier); 93 | scopes.forEach((scope) => module.connectContextForScope(scope)); 94 | } 95 | 96 | disconnectModule(module) { 97 | this.modulesByIdentifier.delete(module.identifier); 98 | const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier); 99 | scopes.forEach((scope) => module.disconnectContextForScope(scope)); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/observe/context.js: -------------------------------------------------------------------------------- 1 | export class Context 2 | { 3 | constructor(module, scope) { 4 | this.module = module; 5 | this.scope = scope; 6 | this.control = new module.controlConstructor(this); 7 | 8 | try { 9 | this.control.initBefore(); 10 | this.control.init(); 11 | this.control.initAfter(); 12 | } 13 | catch (error) { 14 | this.handleError(error, 'initializing control'); 15 | } 16 | } 17 | 18 | connect() { 19 | try { 20 | this.control.connectBefore(); 21 | this.control.connect(); 22 | this.control.connectAfter(); 23 | } 24 | catch (error) { 25 | this.handleError(error, 'connecting control'); 26 | } 27 | } 28 | 29 | refresh() { 30 | } 31 | 32 | disconnect() { 33 | try { 34 | this.control.disconnectBefore(); 35 | this.control.disconnect(); 36 | this.control.disconnectAfter(); 37 | } 38 | catch (error) { 39 | this.handleError(error, 'disconnecting control'); 40 | } 41 | } 42 | 43 | get application() { 44 | return this.module.application; 45 | } 46 | 47 | get identifier() { 48 | return this.module.identifier; 49 | } 50 | 51 | get dispatcher() { 52 | return this.application.dispatcher; 53 | } 54 | 55 | get element() { 56 | return this.scope.element; 57 | } 58 | 59 | get parentElement() { 60 | return this.element.parentElement; 61 | } 62 | 63 | // Error handling 64 | handleError(error, message, detail = {}) { 65 | const { identifier, control, element } = this; 66 | detail = Object.assign({ identifier, control, element }, detail); 67 | this.application.handleError(error, `Error ${message}`, detail); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/observe/control-base.js: -------------------------------------------------------------------------------- 1 | 2 | class ControlBase 3 | { 4 | static proxyCounter = 0; 5 | 6 | static get shouldLoad() { 7 | return true; 8 | } 9 | 10 | static afterLoad(_identifier, _application) { 11 | return; 12 | } 13 | 14 | constructor(context) { 15 | this.context = context; 16 | 17 | this.config = { ...(context.scope.element.dataset || {}) }; 18 | } 19 | 20 | get application() { 21 | return this.context.application; 22 | } 23 | 24 | get scope() { 25 | return this.context.scope; 26 | } 27 | 28 | get element() { 29 | return this.scope.element; 30 | } 31 | 32 | get identifier() { 33 | return this.scope.identifier; 34 | } 35 | 36 | init() { 37 | // Set up initial control state 38 | } 39 | 40 | connect() { 41 | // Control is connected to the DOM 42 | } 43 | 44 | disconnect() { 45 | // Control is disconnected from the DOM 46 | } 47 | 48 | // Internal events avoid the need to call parent logic 49 | initBefore() { 50 | this.proxiedEvents = {}; 51 | this.proxiedMethods = {}; 52 | } 53 | 54 | initAfter() { 55 | } 56 | 57 | connectBefore() { 58 | } 59 | 60 | connectAfter() { 61 | } 62 | 63 | disconnectBefore() { 64 | } 65 | 66 | disconnectAfter() { 67 | for (const key in this.proxiedEvents) { 68 | this.forget(...this.proxiedEvents[key]); 69 | delete this.proxiedEvents[key]; 70 | } 71 | 72 | for (const key in this.proxiedMethods) { 73 | this.proxiedMethods[key] = undefined; 74 | } 75 | } 76 | 77 | // Events 78 | listen(eventName, targetOrHandler, handlerOrOptions, options) { 79 | if (typeof targetOrHandler === 'string') { 80 | oc.Events.on(this.element, eventName, targetOrHandler, this.proxy(handlerOrOptions), options); 81 | } 82 | else if (targetOrHandler instanceof Element) { 83 | oc.Events.on(targetOrHandler, eventName, this.proxy(handlerOrOptions), options); 84 | } 85 | else { 86 | oc.Events.on(this.element, eventName, this.proxy(targetOrHandler), handlerOrOptions); 87 | } 88 | 89 | // Automatic unbinding 90 | ControlBase.proxyCounter++; 91 | this.proxiedEvents[ControlBase.proxyCounter] = arguments; 92 | } 93 | 94 | forget(eventName, targetOrHandler, handlerOrOptions, options) { 95 | if (typeof targetOrHandler === 'string') { 96 | oc.Events.off(this.element, eventName, targetOrHandler, this.proxy(handlerOrOptions), options); 97 | } 98 | else if (targetOrHandler instanceof Element) { 99 | oc.Events.off(targetOrHandler, eventName, this.proxy(handlerOrOptions), options); 100 | } 101 | else { 102 | oc.Events.off(this.element, eventName, this.proxy(targetOrHandler), handlerOrOptions); 103 | } 104 | 105 | // Fills JS gap 106 | const compareArrays = (a, b) => { 107 | if (a.length === b.length) { 108 | for (var i = 0; i < a.length; i++) { 109 | if (a[i] === b[i]) { 110 | return true; 111 | } 112 | } 113 | } 114 | return false; 115 | }; 116 | 117 | // Seeking GC 118 | for (const key in this.proxiedEvents) { 119 | if (compareArrays(arguments, this.proxiedEvents[key])) { 120 | delete this.proxiedEvents[key]; 121 | } 122 | } 123 | } 124 | 125 | dispatch(eventName, { target = this.element, detail = {}, prefix = this.identifier, bubbles = true, cancelable = true, } = {}) { 126 | const type = prefix ? `${prefix}:${eventName}` : eventName; 127 | const event = new CustomEvent(type, { detail, bubbles, cancelable }); 128 | target.dispatchEvent(event); 129 | return event; 130 | } 131 | 132 | proxy(method) { 133 | if (method.ocProxyId === undefined) { 134 | ControlBase.proxyCounter++; 135 | method.ocProxyId = ControlBase.proxyCounter; 136 | } 137 | 138 | if (this.proxiedMethods[method.ocProxyId] !== undefined) { 139 | return this.proxiedMethods[method.ocProxyId]; 140 | } 141 | 142 | this.proxiedMethods[method.ocProxyId] = method.bind(this); 143 | 144 | return this.proxiedMethods[method.ocProxyId]; 145 | } 146 | } 147 | 148 | export { ControlBase }; 149 | -------------------------------------------------------------------------------- /src/observe/dispatcher.js: -------------------------------------------------------------------------------- 1 | import { EventListener } from "./event-listener"; 2 | 3 | export class Dispatcher 4 | { 5 | constructor(application) { 6 | this.application = application; 7 | this.eventListenerMaps = new Map(); 8 | this.started = false; 9 | } 10 | 11 | start() { 12 | if (!this.started) { 13 | this.started = true; 14 | this.eventListeners.forEach((eventListener) => eventListener.connect()); 15 | } 16 | } 17 | 18 | stop() { 19 | if (this.started) { 20 | this.started = false; 21 | this.eventListeners.forEach((eventListener) => eventListener.disconnect()); 22 | } 23 | } 24 | 25 | get eventListeners() { 26 | return Array.from(this.eventListenerMaps.values()).reduce((listeners, map) => listeners.concat(Array.from(map.values())), []); 27 | } 28 | 29 | // Binding observer delegate 30 | bindingConnected(binding) { 31 | this.fetchEventListenerForBinding(binding).bindingConnected(binding); 32 | } 33 | 34 | bindingDisconnected(binding, clearEventListeners = false) { 35 | this.fetchEventListenerForBinding(binding).bindingDisconnected(binding); 36 | if (clearEventListeners) 37 | this.clearEventListenersForBinding(binding); 38 | } 39 | 40 | // Error handling 41 | handleError(error, message, detail = {}) { 42 | this.application.handleError(error, `Error ${message}`, detail); 43 | } 44 | 45 | clearEventListenersForBinding(binding) { 46 | const eventListener = this.fetchEventListenerForBinding(binding); 47 | if (!eventListener.hasBindings()) { 48 | eventListener.disconnect(); 49 | this.removeMappedEventListenerFor(binding); 50 | } 51 | } 52 | 53 | removeMappedEventListenerFor(binding) { 54 | const { eventTarget, eventName, eventOptions } = binding; 55 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget); 56 | const cacheKey = this.cacheKey(eventName, eventOptions); 57 | eventListenerMap.delete(cacheKey); 58 | 59 | if (eventListenerMap.size == 0) { 60 | this.eventListenerMaps.delete(eventTarget); 61 | } 62 | } 63 | 64 | fetchEventListenerForBinding(binding) { 65 | const { eventTarget, eventName, eventOptions } = binding; 66 | return this.fetchEventListener(eventTarget, eventName, eventOptions); 67 | } 68 | 69 | fetchEventListener(eventTarget, eventName, eventOptions) { 70 | const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget); 71 | const cacheKey = this.cacheKey(eventName, eventOptions); 72 | let eventListener = eventListenerMap.get(cacheKey); 73 | if (!eventListener) { 74 | eventListener = this.createEventListener(eventTarget, eventName, eventOptions); 75 | eventListenerMap.set(cacheKey, eventListener); 76 | } 77 | return eventListener; 78 | } 79 | 80 | createEventListener(eventTarget, eventName, eventOptions) { 81 | const eventListener = new EventListener(eventTarget, eventName, eventOptions); 82 | if (this.started) { 83 | eventListener.connect(); 84 | } 85 | return eventListener; 86 | } 87 | 88 | fetchEventListenerMapForEventTarget(eventTarget) { 89 | let eventListenerMap = this.eventListenerMaps.get(eventTarget); 90 | if (!eventListenerMap) { 91 | eventListenerMap = new Map(); 92 | this.eventListenerMaps.set(eventTarget, eventListenerMap); 93 | } 94 | return eventListenerMap; 95 | } 96 | 97 | cacheKey(eventName, eventOptions) { 98 | const parts = [eventName]; 99 | Object.keys(eventOptions) 100 | .sort() 101 | .forEach((key) => { 102 | parts.push(`${eventOptions[key] ? "" : "!"}${key}`); 103 | }); 104 | return parts.join(":"); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/observe/event-listener.js: -------------------------------------------------------------------------------- 1 | export class EventListener 2 | { 3 | constructor(eventTarget, eventName, eventOptions) { 4 | this.eventTarget = eventTarget; 5 | this.eventName = eventName; 6 | this.eventOptions = eventOptions; 7 | this.unorderedBindings = new Set(); 8 | } 9 | 10 | connect() { 11 | this.eventTarget.addEventListener(this.eventName, this, this.eventOptions); 12 | } 13 | 14 | disconnect() { 15 | this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions); 16 | } 17 | 18 | // Binding observer delegate 19 | bindingConnected(binding) { 20 | this.unorderedBindings.add(binding); 21 | } 22 | 23 | bindingDisconnected(binding) { 24 | this.unorderedBindings.delete(binding); 25 | } 26 | 27 | handleEvent(event) { 28 | const extendedEvent = extendEvent(event); 29 | for (const binding of this.bindings) { 30 | if (extendedEvent.immediatePropagationStopped) { 31 | break; 32 | } 33 | else { 34 | binding.handleEvent(extendedEvent); 35 | } 36 | } 37 | } 38 | 39 | hasBindings() { 40 | return this.unorderedBindings.size > 0; 41 | } 42 | 43 | get bindings() { 44 | return Array.from(this.unorderedBindings).sort((left, right) => { 45 | const leftIndex = left.index, rightIndex = right.index; 46 | return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0; 47 | }); 48 | } 49 | } 50 | 51 | function extendEvent(event) { 52 | if ('immediatePropagationStopped' in event) { 53 | return event; 54 | } 55 | else { 56 | const { stopImmediatePropagation } = event; 57 | return Object.assign(event, { 58 | immediatePropagationStopped: false, 59 | stopImmediatePropagation() { 60 | this.immediatePropagationStopped = true; 61 | stopImmediatePropagation.call(this); 62 | }, 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/observe/index.js: -------------------------------------------------------------------------------- 1 | import { ControlBase } from "./control-base"; 2 | import namespace from "./namespace"; 3 | export default namespace; 4 | 5 | if (!window.oc) { 6 | window.oc = {}; 7 | } 8 | 9 | if (!window.oc.AjaxObserve) { 10 | // Namespace 11 | window.oc.AjaxObserve = namespace; 12 | 13 | // Control API 14 | window.oc.registerControl = namespace.registerControl; 15 | 16 | window.oc.importControl = namespace.importControl; 17 | 18 | window.oc.observeControl = namespace.observeControl; 19 | 20 | window.oc.fetchControl = namespace.fetchControl; 21 | 22 | window.oc.fetchControls = namespace.fetchControls; 23 | 24 | // Control base class 25 | window.oc.ControlBase = ControlBase; 26 | 27 | // Boot controller 28 | if (!isAMD() && !isCommonJS()) { 29 | namespace.start(); 30 | } 31 | } 32 | 33 | function isAMD() { 34 | return typeof define == "function" && define.amd; 35 | } 36 | 37 | function isCommonJS() { 38 | return typeof exports == "object" && typeof module != "undefined"; 39 | } 40 | -------------------------------------------------------------------------------- /src/observe/module.js: -------------------------------------------------------------------------------- 1 | import { Context } from "./context"; 2 | 3 | export class Module 4 | { 5 | constructor(application, definition) { 6 | this.application = application; 7 | this.definition = blessDefinition(definition); 8 | this.contextsByScope = new WeakMap(); 9 | this.connectedContexts = new Set(); 10 | } 11 | 12 | get identifier() { 13 | return this.definition.identifier; 14 | } 15 | 16 | get controlConstructor() { 17 | return this.definition.controlConstructor; 18 | } 19 | 20 | get contexts() { 21 | return Array.from(this.connectedContexts); 22 | } 23 | 24 | connectContextForScope(scope) { 25 | const context = this.fetchContextForScope(scope); 26 | this.connectedContexts.add(context); 27 | context.connect(); 28 | } 29 | 30 | disconnectContextForScope(scope) { 31 | const context = this.contextsByScope.get(scope); 32 | if (context) { 33 | this.connectedContexts.delete(context); 34 | context.disconnect(); 35 | } 36 | } 37 | 38 | fetchContextForScope(scope) { 39 | let context = this.contextsByScope.get(scope); 40 | if (!context) { 41 | context = new Context(this, scope); 42 | this.contextsByScope.set(scope, context); 43 | } 44 | return context; 45 | } 46 | } 47 | 48 | function blessDefinition(definition) { 49 | return { 50 | identifier: definition.identifier, 51 | controlConstructor: definition.controlConstructor, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/observe/mutation/attribute-observer.js: -------------------------------------------------------------------------------- 1 | import { ElementObserver } from "./element-observer"; 2 | 3 | export class AttributeObserver 4 | { 5 | constructor(element, attributeName, delegate) { 6 | this.delegate = delegate; 7 | this.attributeName = attributeName; 8 | this.elementObserver = new ElementObserver(element, this); 9 | } 10 | 11 | get element() { 12 | return this.elementObserver.element; 13 | } 14 | 15 | get selector() { 16 | return `[${this.attributeName}]`; 17 | } 18 | 19 | start() { 20 | this.elementObserver.start(); 21 | } 22 | 23 | pause(callback) { 24 | this.elementObserver.pause(callback); 25 | } 26 | 27 | stop() { 28 | this.elementObserver.stop(); 29 | } 30 | 31 | refresh() { 32 | this.elementObserver.refresh(); 33 | } 34 | 35 | get started() { 36 | return this.elementObserver.started; 37 | } 38 | 39 | // Element observer delegate 40 | matchElement(element) { 41 | return element.hasAttribute(this.attributeName); 42 | } 43 | 44 | matchElementsInTree(tree) { 45 | const match = this.matchElement(tree) ? [tree] : []; 46 | const matches = Array.from(tree.querySelectorAll(this.selector)); 47 | return match.concat(matches); 48 | } 49 | 50 | elementMatched(element) { 51 | if (this.delegate.elementMatchedAttribute) { 52 | this.delegate.elementMatchedAttribute(element, this.attributeName); 53 | } 54 | } 55 | 56 | elementUnmatched(element) { 57 | if (this.delegate.elementUnmatchedAttribute) { 58 | this.delegate.elementUnmatchedAttribute(element, this.attributeName); 59 | } 60 | } 61 | 62 | elementAttributeChanged(element, attributeName) { 63 | if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) { 64 | this.delegate.elementAttributeValueChanged(element, attributeName); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/observe/mutation/element-observer.js: -------------------------------------------------------------------------------- 1 | export class ElementObserver 2 | { 3 | constructor(element, delegate) { 4 | this.mutationObserverInit = { attributes: true, childList: true, subtree: true }; 5 | this.element = element; 6 | this.started = false; 7 | this.delegate = delegate; 8 | this.elements = new Set(); 9 | this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations)); 10 | } 11 | 12 | start() { 13 | if (!this.started) { 14 | this.started = true; 15 | this.mutationObserver.observe(this.element, this.mutationObserverInit); 16 | this.refresh(); 17 | } 18 | } 19 | 20 | pause(callback) { 21 | if (this.started) { 22 | this.mutationObserver.disconnect(); 23 | this.started = false; 24 | } 25 | callback(); 26 | if (!this.started) { 27 | this.mutationObserver.observe(this.element, this.mutationObserverInit); 28 | this.started = true; 29 | } 30 | } 31 | 32 | stop() { 33 | if (this.started) { 34 | this.mutationObserver.takeRecords(); 35 | this.mutationObserver.disconnect(); 36 | this.started = false; 37 | } 38 | } 39 | 40 | refresh() { 41 | if (this.started) { 42 | const matches = new Set(this.matchElementsInTree()); 43 | for (const element of Array.from(this.elements)) { 44 | if (!matches.has(element)) { 45 | this.removeElement(element); 46 | } 47 | } 48 | for (const element of Array.from(matches)) { 49 | this.addElement(element); 50 | } 51 | } 52 | } 53 | 54 | // Mutation record processing 55 | processMutations(mutations) { 56 | if (this.started) { 57 | for (const mutation of mutations) { 58 | this.processMutation(mutation); 59 | } 60 | } 61 | } 62 | 63 | processMutation(mutation) { 64 | if (mutation.type == "attributes") { 65 | this.processAttributeChange(mutation.target, mutation.attributeName); 66 | } 67 | else if (mutation.type == "childList") { 68 | this.processRemovedNodes(mutation.removedNodes); 69 | this.processAddedNodes(mutation.addedNodes); 70 | } 71 | } 72 | 73 | processAttributeChange(element, attributeName) { 74 | if (this.elements.has(element)) { 75 | if (this.delegate.elementAttributeChanged && this.matchElement(element)) { 76 | this.delegate.elementAttributeChanged(element, attributeName); 77 | } 78 | else { 79 | this.removeElement(element); 80 | } 81 | } 82 | else if (this.matchElement(element)) { 83 | this.addElement(element); 84 | } 85 | } 86 | 87 | processRemovedNodes(nodes) { 88 | for (const node of Array.from(nodes)) { 89 | const element = this.elementFromNode(node); 90 | if (element) { 91 | this.processTree(element, this.removeElement); 92 | } 93 | } 94 | } 95 | 96 | processAddedNodes(nodes) { 97 | for (const node of Array.from(nodes)) { 98 | const element = this.elementFromNode(node); 99 | if (element && this.elementIsActive(element)) { 100 | this.processTree(element, this.addElement); 101 | } 102 | } 103 | } 104 | 105 | // Element matching 106 | matchElement(element) { 107 | return this.delegate.matchElement(element); 108 | } 109 | 110 | matchElementsInTree(tree = this.element) { 111 | return this.delegate.matchElementsInTree(tree); 112 | } 113 | 114 | processTree(tree, processor) { 115 | for (const element of this.matchElementsInTree(tree)) { 116 | processor.call(this, element); 117 | } 118 | } 119 | 120 | elementFromNode(node) { 121 | if (node.nodeType == Node.ELEMENT_NODE) { 122 | return node; 123 | } 124 | } 125 | 126 | elementIsActive(element) { 127 | if (element.isConnected != this.element.isConnected) { 128 | return false; 129 | } 130 | else { 131 | return this.element.contains(element); 132 | } 133 | } 134 | 135 | // Element tracking 136 | addElement(element) { 137 | if (!this.elements.has(element)) { 138 | if (this.elementIsActive(element)) { 139 | this.elements.add(element); 140 | if (this.delegate.elementMatched) { 141 | this.delegate.elementMatched(element); 142 | } 143 | } 144 | } 145 | } 146 | 147 | removeElement(element) { 148 | if (this.elements.has(element)) { 149 | this.elements.delete(element); 150 | if (this.delegate.elementUnmatched) { 151 | this.delegate.elementUnmatched(element); 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/observe/mutation/index.js: -------------------------------------------------------------------------------- 1 | export * from "./attribute-observer"; 2 | export * from "./element-observer"; 3 | export * from "./selector-observer"; 4 | export * from "./token-list-observer"; 5 | export * from "./value-list-observer"; 6 | -------------------------------------------------------------------------------- /src/observe/mutation/selector-observer.js: -------------------------------------------------------------------------------- 1 | import { ElementObserver } from "./element-observer"; 2 | import { Multimap } from "../util/multimap"; 3 | 4 | export class SelectorObserver 5 | { 6 | constructor(element, selector, delegate, details) { 7 | this._selector = selector; 8 | this.details = details; 9 | this.elementObserver = new ElementObserver(element, this); 10 | this.delegate = delegate; 11 | this.matchesByElement = new Multimap(); 12 | } 13 | get started() { 14 | return this.elementObserver.started; 15 | } 16 | get selector() { 17 | return this._selector; 18 | } 19 | set selector(selector) { 20 | this._selector = selector; 21 | this.refresh(); 22 | } 23 | start() { 24 | this.elementObserver.start(); 25 | } 26 | pause(callback) { 27 | this.elementObserver.pause(callback); 28 | } 29 | stop() { 30 | this.elementObserver.stop(); 31 | } 32 | refresh() { 33 | this.elementObserver.refresh(); 34 | } 35 | get element() { 36 | return this.elementObserver.element; 37 | } 38 | // Element observer delegate 39 | matchElement(element) { 40 | const { selector } = this; 41 | if (selector) { 42 | const matches = element.matches(selector); 43 | if (this.delegate.selectorMatchElement) { 44 | return matches && this.delegate.selectorMatchElement(element, this.details); 45 | } 46 | return matches; 47 | } 48 | else { 49 | return false; 50 | } 51 | } 52 | matchElementsInTree(tree) { 53 | const { selector } = this; 54 | if (selector) { 55 | const match = this.matchElement(tree) ? [tree] : []; 56 | const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match)); 57 | return match.concat(matches); 58 | } 59 | else { 60 | return []; 61 | } 62 | } 63 | elementMatched(element) { 64 | const { selector } = this; 65 | if (selector) { 66 | this.selectorMatched(element, selector); 67 | } 68 | } 69 | elementUnmatched(element) { 70 | const selectors = this.matchesByElement.getKeysForValue(element); 71 | for (const selector of selectors) { 72 | this.selectorUnmatched(element, selector); 73 | } 74 | } 75 | elementAttributeChanged(element, _attributeName) { 76 | const { selector } = this; 77 | if (selector) { 78 | const matches = this.matchElement(element); 79 | const matchedBefore = this.matchesByElement.has(selector, element); 80 | if (matches && !matchedBefore) { 81 | this.selectorMatched(element, selector); 82 | } 83 | else if (!matches && matchedBefore) { 84 | this.selectorUnmatched(element, selector); 85 | } 86 | } 87 | } 88 | // Selector management 89 | selectorMatched(element, selector) { 90 | this.delegate.selectorMatched(element, selector, this.details); 91 | this.matchesByElement.add(selector, element); 92 | } 93 | selectorUnmatched(element, selector) { 94 | this.delegate.selectorUnmatched(element, selector, this.details); 95 | this.matchesByElement.delete(selector, element); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/observe/mutation/token-list-observer.js: -------------------------------------------------------------------------------- 1 | import { AttributeObserver } from "./attribute-observer"; 2 | import { Multimap } from "../util/multimap"; 3 | 4 | export class TokenListObserver 5 | { 6 | constructor(element, attributeName, delegate) { 7 | this.delegate = delegate; 8 | this.attributeObserver = new AttributeObserver(element, attributeName, this); 9 | this.tokensByElement = new Multimap(); 10 | } 11 | 12 | get started() { 13 | return this.attributeObserver.started; 14 | } 15 | 16 | start() { 17 | this.attributeObserver.start(); 18 | } 19 | 20 | pause(callback) { 21 | this.attributeObserver.pause(callback); 22 | } 23 | 24 | stop() { 25 | this.attributeObserver.stop(); 26 | } 27 | 28 | refresh() { 29 | this.attributeObserver.refresh(); 30 | } 31 | 32 | get element() { 33 | return this.attributeObserver.element; 34 | } 35 | 36 | get attributeName() { 37 | return this.attributeObserver.attributeName; 38 | } 39 | 40 | // Attribute observer delegate 41 | elementMatchedAttribute(element) { 42 | this.tokensMatched(this.readTokensForElement(element)); 43 | } 44 | 45 | elementAttributeValueChanged(element) { 46 | const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element); 47 | this.tokensUnmatched(unmatchedTokens); 48 | this.tokensMatched(matchedTokens); 49 | } 50 | 51 | elementUnmatchedAttribute(element) { 52 | this.tokensUnmatched(this.tokensByElement.getValuesForKey(element)); 53 | } 54 | 55 | tokensMatched(tokens) { 56 | tokens.forEach((token) => this.tokenMatched(token)); 57 | } 58 | 59 | tokensUnmatched(tokens) { 60 | tokens.forEach((token) => this.tokenUnmatched(token)); 61 | } 62 | 63 | tokenMatched(token) { 64 | this.delegate.tokenMatched(token); 65 | this.tokensByElement.add(token.element, token); 66 | } 67 | 68 | tokenUnmatched(token) { 69 | this.delegate.tokenUnmatched(token); 70 | this.tokensByElement.delete(token.element, token); 71 | } 72 | 73 | refreshTokensForElement(element) { 74 | const previousTokens = this.tokensByElement.getValuesForKey(element); 75 | const currentTokens = this.readTokensForElement(element); 76 | const firstDifferingIndex = zip(previousTokens, currentTokens).findIndex(([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken)); 77 | if (firstDifferingIndex == -1) { 78 | return [[], []]; 79 | } 80 | else { 81 | return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)]; 82 | } 83 | } 84 | 85 | readTokensForElement(element) { 86 | const attributeName = this.attributeName; 87 | const tokenString = element.getAttribute(attributeName) || ""; 88 | return parseTokenString(tokenString, element, attributeName); 89 | } 90 | } 91 | 92 | function parseTokenString(tokenString, element, attributeName) { 93 | return tokenString 94 | .trim() 95 | .split(/\s+/) 96 | .filter((content) => content.length) 97 | .map((content, index) => ({ element, attributeName, content, index })); 98 | } 99 | 100 | function zip(left, right) { 101 | const length = Math.max(left.length, right.length); 102 | return Array.from({ length }, (_, index) => [left[index], right[index]]); 103 | } 104 | 105 | function tokensAreEqual(left, right) { 106 | return left && right && left.index == right.index && left.content == right.content; 107 | } 108 | -------------------------------------------------------------------------------- /src/observe/mutation/value-list-observer.js: -------------------------------------------------------------------------------- 1 | import { TokenListObserver } from "./token-list-observer"; 2 | 3 | export class ValueListObserver 4 | { 5 | constructor(element, attributeName, delegate) { 6 | this.tokenListObserver = new TokenListObserver(element, attributeName, this); 7 | this.delegate = delegate; 8 | this.parseResultsByToken = new WeakMap(); 9 | this.valuesByTokenByElement = new WeakMap(); 10 | } 11 | 12 | get started() { 13 | return this.tokenListObserver.started; 14 | } 15 | 16 | start() { 17 | this.tokenListObserver.start(); 18 | } 19 | 20 | stop() { 21 | this.tokenListObserver.stop(); 22 | } 23 | 24 | refresh() { 25 | this.tokenListObserver.refresh(); 26 | } 27 | 28 | get element() { 29 | return this.tokenListObserver.element; 30 | } 31 | 32 | get attributeName() { 33 | return this.tokenListObserver.attributeName; 34 | } 35 | 36 | tokenMatched(token) { 37 | const { element } = token; 38 | const { value } = this.fetchParseResultForToken(token); 39 | if (value) { 40 | this.fetchValuesByTokenForElement(element).set(token, value); 41 | this.delegate.elementMatchedValue(element, value); 42 | } 43 | } 44 | 45 | tokenUnmatched(token) { 46 | const { element } = token; 47 | const { value } = this.fetchParseResultForToken(token); 48 | if (value) { 49 | this.fetchValuesByTokenForElement(element).delete(token); 50 | this.delegate.elementUnmatchedValue(element, value); 51 | } 52 | } 53 | 54 | fetchParseResultForToken(token) { 55 | let parseResult = this.parseResultsByToken.get(token); 56 | if (!parseResult) { 57 | parseResult = this.parseToken(token); 58 | this.parseResultsByToken.set(token, parseResult); 59 | } 60 | return parseResult; 61 | } 62 | 63 | fetchValuesByTokenForElement(element) { 64 | let valuesByToken = this.valuesByTokenByElement.get(element); 65 | if (!valuesByToken) { 66 | valuesByToken = new Map(); 67 | this.valuesByTokenByElement.set(element, valuesByToken); 68 | } 69 | return valuesByToken; 70 | } 71 | 72 | parseToken(token) { 73 | try { 74 | const value = this.delegate.parseValueForToken(token); 75 | return { value }; 76 | } 77 | catch (error) { 78 | return { error }; 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/observe/namespace.js: -------------------------------------------------------------------------------- 1 | import { Application } from "./application"; 2 | const application = new Application; 3 | 4 | export default { 5 | application, 6 | 7 | registerControl(id, control) { 8 | return application.register(id, control); 9 | }, 10 | 11 | importControl(id) { 12 | return application.import(id); 13 | }, 14 | 15 | observeControl(element, id) { 16 | return application.observe(element, id); 17 | }, 18 | 19 | fetchControl(element) { 20 | return application.fetch(element); 21 | }, 22 | 23 | fetchControls(elements) { 24 | return application.fetchAll(elements); 25 | }, 26 | 27 | start() { 28 | application.startAsync(); 29 | }, 30 | 31 | stop() { 32 | application.stop(); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/observe/scope-observer.js: -------------------------------------------------------------------------------- 1 | import { ValueListObserver } from "./mutation"; 2 | 3 | export class ScopeObserver 4 | { 5 | constructor(element, delegate) { 6 | this.element = element; 7 | this.delegate = delegate; 8 | this.valueListObserver = new ValueListObserver(this.element, this.controlAttribute, this); 9 | this.scopesByIdentifierByElement = new WeakMap(); 10 | this.scopeReferenceCounts = new WeakMap(); 11 | } 12 | 13 | start() { 14 | this.valueListObserver.start(); 15 | } 16 | 17 | stop() { 18 | this.valueListObserver.stop(); 19 | } 20 | 21 | get controlAttribute() { 22 | return 'data-control'; 23 | } 24 | 25 | // Value observer delegate 26 | parseValueForToken(token) { 27 | const { element, content: identifier } = token; 28 | const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element); 29 | let scope = scopesByIdentifier.get(identifier); 30 | if (!scope) { 31 | scope = this.delegate.createScopeForElementAndIdentifier(element, identifier); 32 | scopesByIdentifier.set(identifier, scope); 33 | } 34 | return scope; 35 | } 36 | 37 | elementMatchedValue(element, value) { 38 | const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1; 39 | this.scopeReferenceCounts.set(value, referenceCount); 40 | if (referenceCount == 1) { 41 | this.delegate.scopeConnected(value); 42 | } 43 | } 44 | 45 | elementUnmatchedValue(element, value) { 46 | const referenceCount = this.scopeReferenceCounts.get(value); 47 | if (referenceCount) { 48 | this.scopeReferenceCounts.set(value, referenceCount - 1); 49 | if (referenceCount == 1) { 50 | this.delegate.scopeDisconnected(value); 51 | } 52 | } 53 | } 54 | 55 | fetchScopesByIdentifierForElement(element) { 56 | let scopesByIdentifier = this.scopesByIdentifierByElement.get(element); 57 | if (!scopesByIdentifier) { 58 | scopesByIdentifier = new Map(); 59 | this.scopesByIdentifierByElement.set(element, scopesByIdentifier); 60 | } 61 | return scopesByIdentifier; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/observe/scope.js: -------------------------------------------------------------------------------- 1 | export class Scope 2 | { 3 | constructor(element, identifier) { 4 | this.element = element; 5 | this.identifier = identifier; 6 | 7 | this.containsElement = (element) => { 8 | return element.closest(this.controlSelector) === this.element; 9 | }; 10 | } 11 | 12 | findElement(selector) { 13 | return this.element.matches(selector) ? this.element : this.queryElements(selector).find(this.containsElement); 14 | } 15 | 16 | findAllElements(selector) { 17 | return [ 18 | ...(this.element.matches(selector) ? [this.element] : []), 19 | ...this.queryElements(selector).filter(this.containsElement), 20 | ]; 21 | } 22 | 23 | queryElements(selector) { 24 | return Array.from(this.element.querySelectorAll(selector)); 25 | } 26 | 27 | get controlSelector() { 28 | return attributeValueContainsToken('data-control', this.identifier); 29 | } 30 | 31 | get isDocumentScope() { 32 | return this.element === document.documentElement; 33 | } 34 | 35 | get documentScope() { 36 | return this.isDocumentScope 37 | ? this 38 | : new Scope(document.documentElement, this.identifier); 39 | } 40 | } 41 | 42 | function attributeValueContainsToken(attributeName, token) { 43 | return `[${attributeName}~="${token}"]` 44 | } 45 | -------------------------------------------------------------------------------- /src/observe/util/multimap.js: -------------------------------------------------------------------------------- 1 | import { add, del } from "./set-operations"; 2 | 3 | export class Multimap 4 | { 5 | constructor() { 6 | this.valuesByKey = new Map(); 7 | } 8 | 9 | get keys() { 10 | return Array.from(this.valuesByKey.keys()); 11 | } 12 | 13 | get values() { 14 | const sets = Array.from(this.valuesByKey.values()); 15 | return sets.reduce((values, set) => values.concat(Array.from(set)), React.createElement(V, null), [] > []); 16 | } 17 | 18 | get size() { 19 | const sets = Array.from(this.valuesByKey.values()); 20 | return sets.reduce((size, set) => size + set.size, 0); 21 | } 22 | 23 | add(key, value) { 24 | add(this.valuesByKey, key, value); 25 | } 26 | 27 | delete(key, value) { 28 | del(this.valuesByKey, key, value); 29 | } 30 | 31 | has(key, value) { 32 | const values = this.valuesByKey.get(key); 33 | return values != null && values.has(value); 34 | } 35 | 36 | hasKey(key) { 37 | return this.valuesByKey.has(key); 38 | } 39 | 40 | hasValue(value) { 41 | const sets = Array.from(this.valuesByKey.values()); 42 | return sets.some((set) => set.has(value)); 43 | } 44 | 45 | getValuesForKey(key) { 46 | const values = this.valuesByKey.get(key); 47 | return values ? Array.from(values) : []; 48 | } 49 | 50 | getKeysForValue(value) { 51 | return Array.from(this.valuesByKey) 52 | .filter(([_key, values]) => values.has(value)) 53 | .map(([key, _values]) => key); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/observe/util/set-operations.js: -------------------------------------------------------------------------------- 1 | export function add(map, key, value) { 2 | fetch(map, key).add(value); 3 | } 4 | 5 | export function del(map, key, value) { 6 | fetch(map, key).delete(value); 7 | prune(map, key); 8 | } 9 | 10 | export function fetch(map, key) { 11 | let values = map.get(key); 12 | if (!values) { 13 | values = new Set(); 14 | map.set(key, values); 15 | } 16 | return values; 17 | } 18 | 19 | export function prune(map, key) { 20 | const values = map.get(key); 21 | if (values != null && values.size == 0) { 22 | map.delete(key); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/request/asset-manager.js: -------------------------------------------------------------------------------- 1 | export class AssetManager 2 | { 3 | static load(collection, callback) { 4 | return (new AssetManager()).loadCollection(collection, callback); 5 | } 6 | 7 | loadCollection(collection, callback) { 8 | var self = this, 9 | jsList = (collection.js) ? collection.js : [], 10 | cssList = (collection.css) ? collection.css : [], 11 | imgList = (collection.img) ? collection.img : []; 12 | 13 | jsList = assGrep(jsList, function(item) { 14 | return !document.querySelector('head script[src="'+item+'"]'); 15 | }) 16 | 17 | cssList = assGrep(cssList, function(item) { 18 | return !document.querySelector('head link[href="'+item+'"]'); 19 | }) 20 | 21 | var cssCounter = 0, 22 | jsLoaded = false, 23 | imgLoaded = false; 24 | 25 | if (jsList.length === 0 && cssList.length === 0 && imgList.length === 0) { 26 | callback && callback(); 27 | return; 28 | } 29 | 30 | this.loadJavaScript(jsList, function() { 31 | jsLoaded = true; 32 | checkLoaded(); 33 | }); 34 | 35 | cssList.forEach(function(source) { 36 | self.loadStyleSheet(source, function() { 37 | cssCounter++; 38 | checkLoaded(); 39 | }); 40 | }); 41 | 42 | this.loadImage(imgList, function() { 43 | imgLoaded = true; 44 | checkLoaded(); 45 | }); 46 | 47 | function checkLoaded() { 48 | if (!imgLoaded) { 49 | return false 50 | } 51 | 52 | if (!jsLoaded) { 53 | return false 54 | } 55 | 56 | if (cssCounter < cssList.length) { 57 | return false 58 | } 59 | 60 | callback && callback(); 61 | } 62 | } 63 | 64 | // Loads StyleSheet files 65 | loadStyleSheet(source, callback) { 66 | var cssElement = document.createElement('link'); 67 | cssElement.setAttribute('rel', 'stylesheet'); 68 | cssElement.setAttribute('type', 'text/css'); 69 | cssElement.setAttribute('href', source); 70 | cssElement.addEventListener('load', callback, false); 71 | 72 | if (typeof cssElement != 'undefined') { 73 | document.getElementsByTagName('head')[0].appendChild(cssElement); 74 | } 75 | 76 | return cssElement; 77 | } 78 | 79 | // Loads JavaScript files in sequence 80 | loadJavaScript(sources, callback) { 81 | if (sources.length <= 0) { 82 | return callback(); 83 | } 84 | 85 | var self = this, 86 | source = sources.shift(), 87 | jsElement = document.createElement('script'); 88 | 89 | jsElement.setAttribute('type', 'text/javascript'); 90 | jsElement.setAttribute('src', source); 91 | jsElement.addEventListener('load', function() { 92 | self.loadJavaScript(sources, callback); 93 | }, false); 94 | 95 | if (typeof jsElement != 'undefined') { 96 | document.getElementsByTagName('head')[0].appendChild(jsElement); 97 | } 98 | } 99 | 100 | // Loads Image files 101 | loadImage(sources, callback) { 102 | if (sources.length <= 0) { 103 | return callback(); 104 | } 105 | 106 | var loaded = 0; 107 | sources.forEach(function(source) { 108 | var img = new Image() 109 | img.onload = function() { 110 | if (++loaded == sources.length && callback) { 111 | callback(); 112 | } 113 | } 114 | img.src = source; 115 | }); 116 | } 117 | } 118 | 119 | function assGrep(items, callback) { 120 | var filtered = [], 121 | len = items.length, 122 | i = 0; 123 | 124 | for (i; i < len; i++) { 125 | if (callback(items[i])) { 126 | filtered.push(items[i]); 127 | } 128 | } 129 | 130 | return filtered; 131 | } 132 | -------------------------------------------------------------------------------- /src/request/data.js: -------------------------------------------------------------------------------- 1 | import { FormSerializer } from "../util/form-serializer"; 2 | 3 | export class Data 4 | { 5 | constructor(userData, targetEl, formEl) { 6 | this.userData = userData || {}; 7 | this.targetEl = targetEl; 8 | this.formEl = formEl; 9 | } 10 | 11 | // Public 12 | getRequestData() { 13 | let requestData; 14 | 15 | // Serialize form 16 | if (this.formEl) { 17 | requestData = new FormData(this.formEl); 18 | } 19 | else { 20 | requestData = new FormData; 21 | } 22 | 23 | // Add single input data 24 | this.appendSingleInputElement(requestData); 25 | 26 | return requestData; 27 | } 28 | 29 | getAsFormData() { 30 | return this.appendJsonToFormData( 31 | this.getRequestData(), 32 | this.userData 33 | ); 34 | } 35 | 36 | getAsQueryString() { 37 | return this.convertFormDataToQuery( 38 | this.getAsFormData() 39 | ); 40 | } 41 | 42 | getAsJsonData() { 43 | return JSON.stringify( 44 | this.convertFormDataToJson( 45 | this.getAsFormData() 46 | ) 47 | ); 48 | } 49 | 50 | // Private 51 | appendSingleInputElement(requestData) { 52 | // Has a form, no target element, or not a singular input 53 | if (this.formEl || !this.targetEl || !isElementInput(this.targetEl)) { 54 | return; 55 | } 56 | 57 | // No name or supplied by user data already 58 | const inputName = this.targetEl.name; 59 | if (!inputName || this.userData[inputName] !== undefined) { 60 | return; 61 | } 62 | 63 | // Include files, if they are any 64 | if (this.targetEl.type === 'file') { 65 | this.targetEl.files.forEach(function(value) { 66 | requestData.append(inputName, value); 67 | }); 68 | } 69 | else { 70 | requestData.append(inputName, this.targetEl.value); 71 | } 72 | } 73 | 74 | appendJsonToFormData(formData, useJson, parentKey) { 75 | var self = this; 76 | for (var key in useJson) { 77 | var fieldKey = key; 78 | if (parentKey) { 79 | fieldKey = parentKey + '[' + key + ']'; 80 | } 81 | 82 | var value = useJson[key]; 83 | 84 | // Object 85 | if (value && value.constructor === {}.constructor) { 86 | this.appendJsonToFormData(formData, value, fieldKey); 87 | } 88 | // Array 89 | else if (value && value.constructor === [].constructor) { 90 | value.forEach(function(v, i) { 91 | if ( 92 | v.constructor === {}.constructor || 93 | v.constructor === [].constructor 94 | ) { 95 | self.appendJsonToFormData(formData, v, fieldKey + '[' + i + ']'); 96 | } 97 | else { 98 | formData.append(fieldKey + '[]', self.castJsonToFormData(v)); 99 | } 100 | }); 101 | } 102 | // Mixed 103 | else { 104 | formData.append(fieldKey, this.castJsonToFormData(value)); 105 | } 106 | } 107 | 108 | return formData; 109 | } 110 | 111 | convertFormDataToQuery(formData) { 112 | // Process to a flat object with array values 113 | let flatData = this.formDataToArray(formData); 114 | 115 | // Process HTML names to a query string 116 | return Object.keys(flatData) 117 | .map(function(key) { 118 | if (key.endsWith('[]')) { 119 | return flatData[key].map(function(val) { 120 | return encodeURIComponent(key) + '=' + encodeURIComponent(val); 121 | }).join('&'); 122 | } 123 | else { 124 | return encodeURIComponent(key) + '=' + encodeURIComponent(flatData[key]); 125 | } 126 | }) 127 | .join('&'); 128 | } 129 | 130 | convertFormDataToJson(formData) { 131 | // Process to a flat object with array values 132 | let flatData = this.formDataToArray(formData); 133 | 134 | // Process HTML names to a nested object 135 | let jsonData = {}; 136 | for (var key in flatData) { 137 | FormSerializer.assignToObj(jsonData, key, flatData[key]); 138 | } 139 | 140 | return jsonData; 141 | } 142 | 143 | formDataToArray(formData) { 144 | return Object.fromEntries( 145 | Array.from(formData.keys()).map(key => [ 146 | key, 147 | key.endsWith('[]') 148 | ? formData.getAll(key) 149 | : formData.getAll(key).pop() 150 | ]) 151 | ); 152 | } 153 | 154 | castJsonToFormData(val) { 155 | if (val === null) { 156 | return ''; 157 | } 158 | 159 | if (val === true) { 160 | return '1'; 161 | } 162 | 163 | if (val === false) { 164 | return '0'; 165 | } 166 | 167 | return val; 168 | } 169 | } 170 | 171 | function isElementInput(el) { 172 | return ['input', 'select', 'textarea'].includes((el.tagName || '').toLowerCase()); 173 | } 174 | -------------------------------------------------------------------------------- /src/request/index.js: -------------------------------------------------------------------------------- 1 | import { AssetManager } from "./asset-manager"; 2 | import namespace from "./namespace"; 3 | export default namespace; 4 | 5 | if (!window.oc) { 6 | window.oc = {}; 7 | } 8 | 9 | if (!window.oc.AjaxRequest) { 10 | // Namespace 11 | window.oc.AjaxRequest = namespace; 12 | 13 | // Asset manager 14 | window.oc.AssetManager = AssetManager; 15 | 16 | // Request without element 17 | window.oc.ajax = namespace.send; 18 | 19 | // Request on element (framework can override) 20 | if (!window.oc.request) { 21 | window.oc.request = namespace.sendElement; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/request/namespace.js: -------------------------------------------------------------------------------- 1 | import { Request } from "./request"; 2 | export default Request; 3 | -------------------------------------------------------------------------------- /src/request/options.js: -------------------------------------------------------------------------------- 1 | export class Options 2 | { 3 | constructor(handler, options) { 4 | if (!handler) { 5 | throw new Error('The request handler name is not specified.') 6 | } 7 | 8 | if (!handler.match(/^(?:\w+\:{2})?on*/)) { 9 | throw new Error('Invalid handler name. The correct handler name format is: "onEvent".'); 10 | } 11 | 12 | if (typeof FormData === 'undefined') { 13 | throw new Error('The browser does not support the FormData interface.'); 14 | } 15 | 16 | this.options = options; 17 | this.handler = handler; 18 | } 19 | 20 | static fetch(handler, options) { 21 | return (new this(handler, options)).getRequestOptions(); 22 | } 23 | 24 | // Public 25 | getRequestOptions() { 26 | return { 27 | method: 'POST', 28 | url: this.options.url ? this.options.url : window.location.href, 29 | headers: this.buildHeaders(), 30 | responseType: this.options.download === false ? '' : 'blob' 31 | }; 32 | } 33 | 34 | // Private 35 | buildHeaders() { 36 | const { handler, options } = this; 37 | const headers = { 38 | 'X-Requested-With': 'XMLHttpRequest', 39 | 'X-OCTOBER-REQUEST-HANDLER': handler 40 | }; 41 | 42 | if (!options.files) { 43 | headers['Content-Type'] = options.bulk 44 | ? 'application/json' 45 | : 'application/x-www-form-urlencoded'; 46 | } 47 | 48 | if (options.flash) { 49 | headers['X-OCTOBER-REQUEST-FLASH'] = 1; 50 | } 51 | 52 | if (options.partial) { 53 | headers['X-OCTOBER-REQUEST-PARTIAL'] = options.partial; 54 | } 55 | 56 | var partials = this.extractPartials(options.update, options.partial); 57 | if (partials) { 58 | headers['X-OCTOBER-REQUEST-PARTIALS'] = partials; 59 | } 60 | 61 | var xsrfToken = this.getXSRFToken(); 62 | if (xsrfToken) { 63 | headers['X-XSRF-TOKEN'] = xsrfToken; 64 | } 65 | 66 | var csrfToken = this.getCSRFToken(); 67 | if (csrfToken) { 68 | headers['X-CSRF-TOKEN'] = csrfToken; 69 | } 70 | 71 | if (options.headers && options.headers.constructor === {}.constructor) { 72 | Object.assign(headers, options.headers); 73 | } 74 | 75 | return headers; 76 | } 77 | 78 | extractPartials(update = {}, selfPartial) { 79 | var result = []; 80 | 81 | if (update) { 82 | if (typeof update !== 'object') { 83 | throw new Error('Invalid update value. The correct format is an object ({...})'); 84 | } 85 | 86 | for (var partial in update) { 87 | if (partial === '_self' && selfPartial) { 88 | result.push(selfPartial); 89 | } 90 | else { 91 | result.push(partial); 92 | } 93 | } 94 | } 95 | 96 | return result.join('&'); 97 | } 98 | 99 | getCSRFToken() { 100 | var tag = document.querySelector('meta[name="csrf-token"]'); 101 | return tag ? tag.getAttribute('content') : null; 102 | } 103 | 104 | getXSRFToken() { 105 | var cookieValue = null; 106 | if (document.cookie && document.cookie != '') { 107 | var cookies = document.cookie.split(';'); 108 | for (var i = 0; i < cookies.length; i++) { 109 | var cookie = cookies[i].replace(/^([\s]*)|([\s]*)$/g, ''); 110 | if (cookie.substring(0, 11) == ('XSRF-TOKEN' + '=')) { 111 | cookieValue = decodeURIComponent(cookie.substring(11)); 112 | break; 113 | } 114 | } 115 | } 116 | return cookieValue; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/request/request.js: -------------------------------------------------------------------------------- 1 | import { Options } from "./options"; 2 | import { Actions } from "./actions"; 3 | import { Data } from "./data"; 4 | import { HttpRequest, SystemStatusCode } from "../util/http-request"; 5 | import { Deferred } from "../util/deferred"; 6 | import { ProgressBar } from "../extras/progress-bar"; 7 | import { dispatch } from "../util"; 8 | 9 | export class Request 10 | { 11 | constructor(element, handler, options) { 12 | this.el = element; 13 | this.handler = handler; 14 | this.options = { ...this.constructor.DEFAULTS, ...(options || {}) }; 15 | this.context = { el: element, handler: handler, options: this.options }; 16 | 17 | this.progressBar = new ProgressBar; 18 | this.showProgressBar = () => { 19 | this.progressBar.show({ cssClass: 'is-ajax' }); 20 | }; 21 | } 22 | 23 | static get DEFAULTS() { 24 | return { 25 | handler: null, 26 | update: {}, 27 | files: false, 28 | bulk: false, 29 | download: false, 30 | browserTarget: null, 31 | browserValidate: false, 32 | browserRedirectBack: false, 33 | progressBarDelay: 500, 34 | progressBar: null 35 | } 36 | } 37 | 38 | start() { 39 | // Setup 40 | if (!this.applicationAllowsSetup()) { 41 | return; 42 | } 43 | 44 | this.initOtherElements(); 45 | this.preprocessOptions(); 46 | 47 | // Prepare actions 48 | this.actions = new Actions(this, this.context, this.options); 49 | if (!this.validateClientSideForm() || !this.applicationAllowsRequest()) { 50 | return; 51 | } 52 | 53 | // Confirm before sending 54 | if (this.options.confirm && !this.actions.invoke('handleConfirmMessage', [this.options.confirm])) { 55 | return; 56 | } 57 | 58 | // Send request 59 | this.sendInternal(); 60 | 61 | return this.options.async 62 | ? this.wrapInAsyncPromise(this.promise) 63 | : this.promise; 64 | } 65 | 66 | sendInternal() { 67 | // Prepare data 68 | const dataObj = new Data(this.options.data, this.el, this.formEl); 69 | let data; 70 | if (this.options.files) { 71 | data = dataObj.getAsFormData(); 72 | } 73 | else if (this.options.bulk) { 74 | data = dataObj.getAsJsonData(); 75 | } 76 | else { 77 | data = dataObj.getAsQueryString(); 78 | } 79 | 80 | // Prepare query 81 | if (this.options.query) { 82 | this.actions.invoke('applyQueryToUrl', [ 83 | this.options.query !== true 84 | ? this.options.query 85 | : JSON.parse(dataObj.getAsJsonData()) 86 | ]); 87 | } 88 | 89 | // Prepare request 90 | const { url, headers, method, responseType } = Options.fetch(this.handler, this.options); 91 | this.request = new HttpRequest(this, url, { method, headers, responseType, data, trackAbort: true }); 92 | this.promise = new Deferred({ delegate: this.request }); 93 | this.isRedirect = this.options.redirect && this.options.redirect.length > 0; 94 | 95 | // Lifecycle events 96 | this.notifyApplicationBeforeSend(); 97 | this.notifyApplicationAjaxPromise(); 98 | this.promise 99 | .fail((data, responseCode, xhr) => { 100 | if (!this.isRedirect) { 101 | this.notifyApplicationAjaxFail(data, responseCode, xhr); 102 | } 103 | }) 104 | .done((data, responseCode, xhr) => { 105 | if (!this.isRedirect) { 106 | this.notifyApplicationAjaxDone(data, responseCode, xhr); 107 | } 108 | }) 109 | .always((data, responseCode, xhr) => { 110 | this.notifyApplicationAjaxAlways(data, responseCode, xhr); 111 | }); 112 | 113 | this.request.send(); 114 | } 115 | 116 | static send(handler, options) { 117 | return (new Request(document, handler, options)).start(); 118 | } 119 | 120 | static sendElement(element, handler, options) { 121 | if (typeof element === 'string') { 122 | element = document.querySelector(element); 123 | } 124 | 125 | return (new Request(element, handler, options)).start(); 126 | } 127 | 128 | toggleRedirect(redirectUrl) { 129 | if (!redirectUrl) { 130 | this.options.redirect = null; 131 | this.isRedirect = false; 132 | } 133 | else { 134 | this.options.redirect = redirectUrl; 135 | this.isRedirect = true; 136 | } 137 | } 138 | 139 | applicationAllowsSetup() { 140 | const event = this.notifyApplicationAjaxSetup(); 141 | return !event.defaultPrevented; 142 | } 143 | 144 | applicationAllowsRequest() { 145 | const event = this.notifyApplicationBeforeRequest(); 146 | return !event.defaultPrevented; 147 | } 148 | 149 | applicationAllowsUpdate(data, responseCode, xhr) { 150 | const event = this.notifyApplicationBeforeUpdate(data, responseCode, xhr); 151 | return !event.defaultPrevented; 152 | } 153 | 154 | applicationAllowsError(message, responseCode, xhr) { 155 | const event = this.notifyApplicationRequestError(message, responseCode, xhr); 156 | return !event.defaultPrevented; 157 | } 158 | 159 | // Application events 160 | notifyApplicationAjaxSetup() { 161 | return dispatch('ajax:setup', { target: this.el, detail: { context: this.context } }); 162 | } 163 | 164 | notifyApplicationAjaxPromise() { 165 | return dispatch('ajax:promise', { target: this.el, detail: { context: this.context } }); 166 | } 167 | 168 | notifyApplicationAjaxFail(data, responseCode, xhr) { 169 | return dispatch('ajax:fail', { target: this.el, detail: { context: this.context, data, responseCode, xhr } }); 170 | } 171 | 172 | notifyApplicationAjaxDone(data, responseCode, xhr) { 173 | return dispatch('ajax:done', { target: this.el, detail: { context: this.context, data, responseCode, xhr } }); 174 | } 175 | 176 | notifyApplicationAjaxAlways(data, responseCode, xhr) { 177 | return dispatch('ajax:always', { target: this.el, detail: { context: this.context, data, responseCode, xhr } }); 178 | } 179 | 180 | notifyApplicationAjaxUpdate(target, data, responseCode, xhr) { 181 | return dispatch('ajax:update', { target, detail: { context: this.context, data, responseCode, xhr } }); 182 | } 183 | 184 | notifyApplicationBeforeRedirect() { 185 | return dispatch('ajax:before-redirect', { target: this.el }); 186 | } 187 | 188 | notifyApplicationBeforeRequest() { 189 | return dispatch('ajax:before-request', { target: this.triggerEl, detail: { context: this.context } }); 190 | } 191 | 192 | notifyApplicationBeforeUpdate(data, responseCode, xhr) { 193 | return dispatch('ajax:before-update', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } }); 194 | } 195 | 196 | notifyApplicationRequestSuccess(data, responseCode, xhr) { 197 | return dispatch('ajax:request-success', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } }); 198 | } 199 | 200 | notifyApplicationRequestError(message, responseCode, xhr) { 201 | return dispatch('ajax:request-error', { target: this.triggerEl, detail: { context: this.context, message, responseCode, xhr } }); 202 | } 203 | 204 | notifyApplicationRequestComplete(data, responseCode, xhr) { 205 | return dispatch('ajax:request-complete', { target: this.triggerEl, detail: { context: this.context, data, responseCode, xhr } }); 206 | } 207 | 208 | notifyApplicationBeforeValidate(message, fields) { 209 | return dispatch('ajax:before-validate', { target: this.triggerEl, detail: { context: this.context, message, fields } }); 210 | } 211 | 212 | notifyApplicationBeforeReplace(target) { 213 | return dispatch('ajax:before-replace', { target }); 214 | } 215 | 216 | // Window-based events 217 | notifyApplicationBeforeSend() { 218 | return dispatch('ajax:before-send', { target: window, detail: { context: this.context } }); 219 | } 220 | 221 | notifyApplicationUpdateComplete(data, responseCode, xhr) { 222 | return dispatch('ajax:update-complete', { target: window, detail: { context: this.context, data, responseCode, xhr } }); 223 | } 224 | 225 | notifyApplicationFieldInvalid(element, fieldName, errorMsg, isFirst) { 226 | return dispatch('ajax:invalid-field', { target: window, detail: { element, fieldName, errorMsg, isFirst } }); 227 | } 228 | 229 | notifyApplicationConfirmMessage(message, promise) { 230 | return dispatch('ajax:confirm-message', { target: window, detail: { message, promise } }); 231 | } 232 | 233 | notifyApplicationErrorMessage(message) { 234 | return dispatch('ajax:error-message', { target: window, detail: { message } }); 235 | } 236 | 237 | notifyApplicationCustomEvent(name, data) { 238 | return dispatch(name, { target: this.el, detail: data }); 239 | } 240 | 241 | // HTTP request delegate 242 | requestStarted() { 243 | this.markAsProgress(true); 244 | this.toggleLoadingElement(true); 245 | 246 | if (this.options.progressBar) { 247 | this.showProgressBarAfterDelay(); 248 | } 249 | 250 | this.actions.invoke('start', [this.request.xhr]); 251 | } 252 | 253 | requestProgressed(progress) { 254 | this.promise.notify(progress); 255 | } 256 | 257 | requestCompletedWithResponse(response, statusCode) { 258 | this.actions.invoke('success', [response, statusCode, this.request.xhr]); 259 | this.actions.invoke('complete', [response, statusCode, this.request.xhr]); 260 | this.promise.resolve(response, statusCode, this.request.xhr); 261 | } 262 | 263 | requestFailedWithStatusCode(statusCode, response) { 264 | if (statusCode == SystemStatusCode.userAborted) { 265 | this.actions.invoke('cancel', []); 266 | } 267 | else { 268 | this.actions.invoke('error', [response, statusCode, this.request.xhr]); 269 | } 270 | 271 | this.actions.invoke('complete', [response, statusCode, this.request.xhr]); 272 | this.promise.reject(response, statusCode, this.request.xhr); 273 | } 274 | 275 | requestFinished() { 276 | this.markAsProgress(false); 277 | this.toggleLoadingElement(false); 278 | 279 | if (this.options.progressBar) { 280 | this.hideProgressBar(); 281 | } 282 | } 283 | 284 | // Private 285 | initOtherElements() { 286 | if (typeof this.options.form === 'string') { 287 | this.formEl = document.querySelector(this.options.form); 288 | } 289 | else if (this.options.form) { 290 | this.formEl = this.options.form; 291 | } 292 | else { 293 | this.formEl = this.el && this.el !== document ? this.el.closest('form') : null; 294 | } 295 | 296 | this.triggerEl = this.formEl ? this.formEl : this.el; 297 | 298 | this.partialEl = this.el && this.el !== document ? this.el.closest('[data-ajax-partial]') : null; 299 | 300 | this.loadingEl = typeof this.options.loading === 'string' 301 | ? document.querySelector(this.options.loading) 302 | : this.options.loading; 303 | } 304 | 305 | preprocessOptions() { 306 | // Partial mode 307 | if (this.options.partial === undefined && this.partialEl && this.partialEl.dataset.ajaxPartial !== undefined) { 308 | this.options.partial = this.partialEl.dataset.ajaxPartial || true; 309 | } 310 | } 311 | 312 | validateClientSideForm() { 313 | if ( 314 | this.options.browserValidate && 315 | typeof document.createElement('input').reportValidity === 'function' && 316 | this.formEl && 317 | !this.formEl.checkValidity() 318 | ) { 319 | this.formEl.reportValidity(); 320 | return false; 321 | } 322 | 323 | return true; 324 | } 325 | 326 | toggleLoadingElement(isLoading) { 327 | if (!this.loadingEl) { 328 | return; 329 | } 330 | 331 | if ( 332 | typeof this.loadingEl.show !== 'function' || 333 | typeof this.loadingEl.hide !== 'function' 334 | ) { 335 | this.loadingEl.style.display = isLoading ? 'block' : 'none'; 336 | return; 337 | } 338 | 339 | if (isLoading) { 340 | this.loadingEl.show(); 341 | } 342 | else { 343 | this.loadingEl.hide(); 344 | } 345 | } 346 | 347 | showProgressBarAfterDelay() { 348 | this.progressBar.setValue(0); 349 | this.progressBarTimeout = window.setTimeout(this.showProgressBar, this.options.progressBarDelay); 350 | } 351 | 352 | hideProgressBar() { 353 | this.progressBar.setValue(100); 354 | this.progressBar.hide(); 355 | if (this.progressBarTimeout != null) { 356 | window.clearTimeout(this.progressBarTimeout); 357 | delete this.progressBarTimeout; 358 | } 359 | } 360 | 361 | markAsProgress(isLoading) { 362 | if (isLoading) { 363 | document.documentElement.setAttribute('data-ajax-progress', ''); 364 | if (this.formEl) { 365 | this.formEl.setAttribute('data-ajax-progress', this.handler); 366 | } 367 | } 368 | else { 369 | document.documentElement.removeAttribute('data-ajax-progress'); 370 | if (this.formEl) { 371 | this.formEl.removeAttribute('data-ajax-progress'); 372 | } 373 | } 374 | } 375 | 376 | 377 | // @todo v2: this needs to pass more than just "data" 378 | // perhaps { data, responseCode, headers } 379 | wrapInAsyncPromise(requestPromise) { 380 | return new Promise(function (resolve, reject, onCancel) { 381 | requestPromise 382 | .fail(function(data) { 383 | reject(data); 384 | }) 385 | .done(function(data) { 386 | resolve(data); 387 | }); 388 | 389 | if (onCancel) { 390 | onCancel(function() { 391 | requestPromise.abort(); 392 | }); 393 | } 394 | }); 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/turbo/browser-adapter.js: -------------------------------------------------------------------------------- 1 | import { SystemStatusCode } from "../util/http-request"; 2 | import { ProgressBar } from "../extras/progress-bar"; 3 | import { uuid } from "../util"; 4 | 5 | export class BrowserAdapter 6 | { 7 | constructor(controller) { 8 | this.progressBar = new ProgressBar; 9 | this.showProgressBar = () => { 10 | this.progressBar.show({ cssClass: 'is-turbo' }); 11 | }; 12 | this.controller = controller; 13 | } 14 | 15 | visitProposedToLocationWithAction(location, action) { 16 | const restorationIdentifier = uuid(); 17 | this.controller.startVisitToLocationWithAction(location, action, restorationIdentifier); 18 | } 19 | 20 | visitStarted(visit) { 21 | visit.issueRequest(); 22 | visit.changeHistory(); 23 | visit.goToSamePageAnchor(); 24 | visit.loadCachedSnapshot(); 25 | } 26 | 27 | visitRequestStarted(visit) { 28 | this.progressBar.setValue(0); 29 | if (visit.hasCachedSnapshot() || visit.action != 'restore') { 30 | this.showProgressBarAfterDelay(); 31 | } 32 | else { 33 | this.showProgressBar(); 34 | } 35 | } 36 | 37 | visitRequestProgressed(visit) { 38 | this.progressBar.setValue(visit.progress); 39 | } 40 | 41 | visitRequestCompleted(visit) { 42 | visit.loadResponse(); 43 | } 44 | 45 | visitRequestFailedWithStatusCode(visit, statusCode) { 46 | switch (statusCode) { 47 | case SystemStatusCode.networkFailure: 48 | case SystemStatusCode.timeoutFailure: 49 | case SystemStatusCode.contentTypeMismatch: 50 | case SystemStatusCode.userAborted: 51 | return this.reload(); 52 | default: 53 | return visit.loadResponse(); 54 | } 55 | } 56 | 57 | visitRequestFinished(visit) { 58 | this.hideProgressBar(); 59 | } 60 | 61 | visitCompleted(visit) { 62 | visit.followRedirect(); 63 | } 64 | 65 | pageInvalidated() { 66 | this.reload(); 67 | } 68 | 69 | visitFailed(visit) { 70 | } 71 | 72 | visitRendered(visit) { 73 | } 74 | 75 | // Private 76 | showProgressBarAfterDelay() { 77 | if (this.controller.progressBarVisible) { 78 | this.progressBarTimeout = window.setTimeout(this.showProgressBar, this.controller.progressBarDelay); 79 | } 80 | } 81 | 82 | hideProgressBar() { 83 | if (this.controller.progressBarVisible) { 84 | this.progressBar.hide(); 85 | if (this.progressBarTimeout !== null) { 86 | window.clearTimeout(this.progressBarTimeout); 87 | delete this.progressBarTimeout; 88 | } 89 | } 90 | } 91 | 92 | reload() { 93 | window.location.reload(); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/turbo/error-renderer.js: -------------------------------------------------------------------------------- 1 | import { Renderer } from "./renderer"; 2 | import { array } from "../util"; 3 | 4 | export class ErrorRenderer extends Renderer 5 | { 6 | constructor(delegate, html) { 7 | super(); 8 | this.delegate = delegate; 9 | this.htmlElement = (() => { 10 | const htmlElement = document.createElement('html'); 11 | htmlElement.innerHTML = html; 12 | return htmlElement; 13 | })(); 14 | this.newHead = this.htmlElement.querySelector('head') || document.createElement('head'); 15 | this.newBody = this.htmlElement.querySelector('body') || document.createElement('body'); 16 | } 17 | 18 | static render(delegate, callback, html) { 19 | return new this(delegate, html).render(callback); 20 | } 21 | 22 | render(callback) { 23 | this.renderView(() => { 24 | this.replaceHeadAndBody(); 25 | this.activateBodyScriptElements(); 26 | callback(); 27 | }); 28 | } 29 | 30 | replaceHeadAndBody() { 31 | const { documentElement, head, body } = document; 32 | documentElement.replaceChild(this.newHead, head); 33 | documentElement.replaceChild(this.newBody, body); 34 | } 35 | 36 | activateBodyScriptElements() { 37 | for (const replaceableElement of this.getScriptElements()) { 38 | const parentNode = replaceableElement.parentNode; 39 | if (parentNode) { 40 | const element = this.createScriptElement(replaceableElement); 41 | parentNode.replaceChild(element, replaceableElement); 42 | } 43 | } 44 | } 45 | 46 | getScriptElements() { 47 | return array(document.documentElement.querySelectorAll('script')); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/turbo/head-details.js: -------------------------------------------------------------------------------- 1 | import { array } from "../util"; 2 | 3 | export class HeadDetails 4 | { 5 | constructor(children) { 6 | this.detailsByOuterHTML = children.reduce((result, element) => { 7 | const { outerHTML } = element; 8 | const details = outerHTML in result 9 | ? result[outerHTML] 10 | : { 11 | type: elementType(element), 12 | tracked: elementIsTracked(element), 13 | elements: [] 14 | }; 15 | return Object.assign(Object.assign({}, result), { [outerHTML]: Object.assign(Object.assign({}, details), { elements: [...details.elements, element] }) }); 16 | }, {}); 17 | } 18 | 19 | static fromHeadElement(headElement) { 20 | const children = headElement ? array(headElement.children) : []; 21 | return new this(children); 22 | } 23 | 24 | getTrackedElementSignature() { 25 | return Object.keys(this.detailsByOuterHTML) 26 | .filter(outerHTML => this.detailsByOuterHTML[outerHTML].tracked) 27 | .join(""); 28 | } 29 | 30 | getScriptElementsNotInDetails(headDetails) { 31 | return this.getElementsMatchingTypeNotInDetails('script', headDetails); 32 | } 33 | 34 | getStylesheetElementsNotInDetails(headDetails) { 35 | return this.getElementsMatchingTypeNotInDetails('stylesheet', headDetails); 36 | } 37 | 38 | getElementsMatchingTypeNotInDetails(matchedType, headDetails) { 39 | return Object.keys(this.detailsByOuterHTML) 40 | .filter(outerHTML => !(outerHTML in headDetails.detailsByOuterHTML)) 41 | .map(outerHTML => this.detailsByOuterHTML[outerHTML]) 42 | .filter(({ type }) => type == matchedType) 43 | .map(({ elements: [element] }) => element); 44 | } 45 | 46 | getProvisionalElements() { 47 | return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { 48 | const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]; 49 | if (type == null && !tracked) { 50 | return [...result, ...elements]; 51 | } 52 | else if (elements.length > 1) { 53 | return [...result, ...elements.slice(1)]; 54 | } 55 | else { 56 | return result; 57 | } 58 | }, []); 59 | } 60 | 61 | getMetaValue(name) { 62 | const element = this.findMetaElementByName(name); 63 | return element 64 | ? element.getAttribute('content') 65 | : null; 66 | } 67 | 68 | findMetaElementByName(name) { 69 | return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => { 70 | const { elements: [element] } = this.detailsByOuterHTML[outerHTML]; 71 | return elementIsMetaElementWithName(element, name) ? element : result; 72 | }, undefined); 73 | } 74 | } 75 | 76 | function elementType(element) { 77 | if (elementIsScript(element)) { 78 | return 'script'; 79 | } 80 | 81 | else if (elementIsStylesheet(element)) { 82 | return 'stylesheet'; 83 | } 84 | } 85 | 86 | function elementIsTracked(element) { 87 | return element.getAttribute('data-turbo-track') == 'reload'; 88 | } 89 | 90 | function elementIsScript(element) { 91 | const tagName = element.tagName.toLowerCase(); 92 | return tagName == 'script'; 93 | } 94 | 95 | function elementIsStylesheet(element) { 96 | const tagName = element.tagName.toLowerCase(); 97 | return tagName == 'style' || (tagName == 'link' && element.getAttribute('rel') == 'stylesheet'); 98 | } 99 | 100 | function elementIsMetaElementWithName(element, name) { 101 | const tagName = element.tagName.toLowerCase(); 102 | return tagName == 'meta' && element.getAttribute('name') == name; 103 | } 104 | -------------------------------------------------------------------------------- /src/turbo/history.js: -------------------------------------------------------------------------------- 1 | import { Location } from "./location"; 2 | import { defer } from "../util"; 3 | 4 | export class History 5 | { 6 | constructor(delegate) { 7 | this.started = false; 8 | this.pageLoaded = false; 9 | 10 | // Event handlers 11 | this.onPopState = (event) => { 12 | if (!this.shouldHandlePopState()) { 13 | return; 14 | } 15 | 16 | if (!event.state || !event.state.ajaxTurbo) { 17 | return; 18 | } 19 | 20 | const { ajaxTurbo } = event.state; 21 | const location = Location.currentLocation; 22 | const { restorationIdentifier } = ajaxTurbo; 23 | 24 | this.delegate.historyPoppedToLocationWithRestorationIdentifier(location, restorationIdentifier); 25 | }; 26 | 27 | this.onPageLoad = (event) => { 28 | defer(() => { 29 | this.pageLoaded = true; 30 | }); 31 | }; 32 | 33 | this.delegate = delegate; 34 | } 35 | 36 | start() { 37 | if (!this.started) { 38 | addEventListener('popstate', this.onPopState, false); 39 | addEventListener('load', this.onPageLoad, false); 40 | this.started = true; 41 | } 42 | } 43 | 44 | stop() { 45 | if (this.started) { 46 | removeEventListener('popstate', this.onPopState, false); 47 | removeEventListener('load', this.onPageLoad, false); 48 | this.started = false; 49 | } 50 | } 51 | 52 | push(location, restorationIdentifier) { 53 | this.update(history.pushState, location, restorationIdentifier); 54 | } 55 | 56 | replace(location, restorationIdentifier) { 57 | this.update(history.replaceState, location, restorationIdentifier); 58 | } 59 | 60 | // Private 61 | shouldHandlePopState() { 62 | // Safari dispatches a popstate event after window's load event, ignore it 63 | return this.pageIsLoaded(); 64 | } 65 | 66 | pageIsLoaded() { 67 | return this.pageLoaded || document.readyState == 'complete'; 68 | } 69 | 70 | update(method, location, restorationIdentifier) { 71 | const state = { ajaxTurbo: { restorationIdentifier } }; 72 | 73 | method.call(history, state, '', location.absoluteURL); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/turbo/index.js: -------------------------------------------------------------------------------- 1 | import namespace from "./namespace"; 2 | export default namespace; 3 | 4 | if (!window.oc) { 5 | window.oc = {}; 6 | } 7 | 8 | if (!window.oc.AjaxTurbo) { 9 | // Namespace 10 | window.oc.AjaxTurbo = namespace; 11 | 12 | // Visit helper 13 | window.oc.visit = namespace.visit; 14 | 15 | // Enabled helper 16 | window.oc.useTurbo = namespace.isEnabled; 17 | 18 | // Page ready helper 19 | window.oc.pageReady = namespace.pageReady; 20 | 21 | // Boot controller 22 | if (!isAMD() && !isCommonJS()) { 23 | namespace.start(); 24 | } 25 | } 26 | 27 | function isAMD() { 28 | return typeof define == "function" && define.amd; 29 | } 30 | 31 | function isCommonJS() { 32 | return typeof exports == "object" && typeof module != "undefined"; 33 | } 34 | -------------------------------------------------------------------------------- /src/turbo/location.js: -------------------------------------------------------------------------------- 1 | export class Location 2 | { 3 | constructor(url) { 4 | const linkWithAnchor = document.createElement('a'); 5 | linkWithAnchor.href = url; 6 | this.absoluteURL = linkWithAnchor.href; 7 | const anchorLength = linkWithAnchor.hash.length; 8 | if (anchorLength < 2) { 9 | this.requestURL = this.absoluteURL; 10 | } 11 | else { 12 | this.requestURL = this.absoluteURL.slice(0, -anchorLength); 13 | this.anchor = linkWithAnchor.hash.slice(1); 14 | } 15 | } 16 | 17 | static get currentLocation() { 18 | return this.wrap(window.location.toString()); 19 | } 20 | 21 | static wrap(locatable) { 22 | if (typeof locatable == 'string') { 23 | return new Location(locatable); 24 | } 25 | else if (locatable != null) { 26 | return locatable; 27 | } 28 | } 29 | 30 | getOrigin() { 31 | return this.absoluteURL.split("/", 3).join("/"); 32 | } 33 | 34 | getPath() { 35 | return (this.requestURL.match(/\/\/[^/]*(\/[^?;]*)/) || [])[1] || "/"; 36 | } 37 | 38 | getPathComponents() { 39 | return this.getPath().split("/").slice(1); 40 | } 41 | 42 | getLastPathComponent() { 43 | return this.getPathComponents().slice(-1)[0]; 44 | } 45 | 46 | getExtension() { 47 | return (this.getLastPathComponent().match(/\.[^.]*$/) || [])[0] || ""; 48 | } 49 | 50 | isHTML() { 51 | return this.getExtension().match(/^(?:|\.(?:htm|html|xhtml))$/); 52 | } 53 | 54 | isPrefixedBy(location) { 55 | const prefixURL = getPrefixURL(location); 56 | return this.isEqualTo(location) || stringStartsWith(this.absoluteURL, prefixURL); 57 | } 58 | 59 | isEqualTo(location) { 60 | return location && this.absoluteURL === location.absoluteURL; 61 | } 62 | 63 | toCacheKey() { 64 | return this.requestURL; 65 | } 66 | 67 | toJSON() { 68 | return this.absoluteURL; 69 | } 70 | 71 | toString() { 72 | return this.absoluteURL; 73 | } 74 | 75 | valueOf() { 76 | return this.absoluteURL; 77 | } 78 | } 79 | 80 | function getPrefixURL(location) { 81 | return addTrailingSlash(location.getOrigin() + location.getPath()); 82 | } 83 | 84 | function addTrailingSlash(url) { 85 | return stringEndsWith(url, "/") ? url : url + "/"; 86 | } 87 | 88 | function stringStartsWith(string, prefix) { 89 | return string.slice(0, prefix.length) === prefix; 90 | } 91 | 92 | function stringEndsWith(string, suffix) { 93 | return string.slice(-suffix.length) === suffix; 94 | } 95 | -------------------------------------------------------------------------------- /src/turbo/namespace.js: -------------------------------------------------------------------------------- 1 | import { Controller } from "./controller"; 2 | const controller = new Controller; 3 | 4 | export default { 5 | get supported() { 6 | return Controller.supported; 7 | }, 8 | 9 | controller, 10 | 11 | visit(location, options) { 12 | controller.visit(location, options); 13 | }, 14 | 15 | clearCache() { 16 | controller.clearCache(); 17 | }, 18 | 19 | setProgressBarVisible(value) { 20 | controller.setProgressBarVisible(value); 21 | }, 22 | 23 | setProgressBarDelay(delay) { 24 | controller.setProgressBarDelay(delay); 25 | }, 26 | 27 | start() { 28 | controller.start(); 29 | }, 30 | 31 | isEnabled() { 32 | return controller.isEnabled(); 33 | }, 34 | 35 | pageReady() { 36 | return controller.pageReady(); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/turbo/renderer.js: -------------------------------------------------------------------------------- 1 | import { array } from "../util"; 2 | 3 | export class Renderer 4 | { 5 | renderView(callback) { 6 | const renderInterception = () => { 7 | callback(); 8 | this.delegate.viewRendered(this.newBody); 9 | }; 10 | 11 | const options = { resume: renderInterception }; 12 | const immediateRender = this.delegate.viewAllowsImmediateRender(this.newBody, options); 13 | if (immediateRender) { 14 | renderInterception(); 15 | } 16 | } 17 | 18 | invalidateView() { 19 | this.delegate.viewInvalidated(); 20 | } 21 | 22 | createScriptElement(element) { 23 | if ( 24 | element.getAttribute('data-turbo-eval') === 'false' || 25 | this.delegate.applicationHasSeenInlineScript(element) 26 | ) { 27 | return element; 28 | } 29 | 30 | const createdScriptElement = document.createElement('script'); 31 | createdScriptElement.textContent = element.textContent; 32 | createdScriptElement.async = false; 33 | copyElementAttributes(createdScriptElement, element); 34 | return createdScriptElement; 35 | } 36 | } 37 | 38 | function copyElementAttributes(destinationElement, sourceElement) { 39 | for (const { name, value } of array(sourceElement.attributes)) { 40 | destinationElement.setAttribute(name, value); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/turbo/scroll-manager.js: -------------------------------------------------------------------------------- 1 | export class ScrollManager 2 | { 3 | constructor(delegate) { 4 | this.started = false; 5 | this.onScroll = () => { 6 | this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset }); 7 | }; 8 | this.delegate = delegate; 9 | } 10 | 11 | start() { 12 | if (!this.started) { 13 | addEventListener('scroll', this.onScroll, false); 14 | this.onScroll(); 15 | this.started = true; 16 | } 17 | } 18 | 19 | stop() { 20 | if (this.started) { 21 | removeEventListener('scroll', this.onScroll, false); 22 | this.started = false; 23 | } 24 | } 25 | 26 | scrollToElement(element) { 27 | element.scrollIntoView(); 28 | } 29 | 30 | scrollToPosition({ x, y }) { 31 | window.scrollTo(x, y); 32 | } 33 | 34 | // Private 35 | updatePosition(position) { 36 | this.delegate.scrollPositionChanged(position); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/turbo/snapshot-cache.js: -------------------------------------------------------------------------------- 1 | export class SnapshotCache 2 | { 3 | constructor(size) { 4 | this.keys = []; 5 | this.snapshots = {}; 6 | this.size = size; 7 | } 8 | 9 | has(location) { 10 | return location.toCacheKey() in this.snapshots; 11 | } 12 | 13 | get(location) { 14 | if (this.has(location)) { 15 | const snapshot = this.read(location); 16 | this.touch(location); 17 | return snapshot; 18 | } 19 | } 20 | 21 | put(location, snapshot) { 22 | this.write(location, snapshot); 23 | this.touch(location); 24 | return snapshot; 25 | } 26 | 27 | // Private 28 | read(location) { 29 | return this.snapshots[location.toCacheKey()]; 30 | } 31 | 32 | write(location, snapshot) { 33 | this.snapshots[location.toCacheKey()] = snapshot; 34 | } 35 | 36 | touch(location) { 37 | const key = location.toCacheKey(); 38 | const index = this.keys.indexOf(key); 39 | if (index > -1) 40 | this.keys.splice(index, 1); 41 | this.keys.unshift(key); 42 | this.trim(); 43 | } 44 | 45 | trim() { 46 | for (const key of this.keys.splice(this.size)) { 47 | delete this.snapshots[key]; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/turbo/snapshot-renderer.js: -------------------------------------------------------------------------------- 1 | import { Renderer } from "./renderer"; 2 | import { array } from "../util"; 3 | 4 | export class SnapshotRenderer extends Renderer 5 | { 6 | constructor(delegate, currentSnapshot, newSnapshot, isPreview) { 7 | super(); 8 | this.delegate = delegate; 9 | this.currentSnapshot = currentSnapshot; 10 | this.currentHeadDetails = currentSnapshot.headDetails; 11 | this.newSnapshot = newSnapshot; 12 | this.newHeadDetails = newSnapshot.headDetails; 13 | this.newBody = newSnapshot.bodyElement; 14 | this.isPreview = isPreview; 15 | } 16 | 17 | static render(delegate, callback, currentSnapshot, newSnapshot, isPreview) { 18 | return new this(delegate, currentSnapshot, newSnapshot, isPreview).render(callback); 19 | } 20 | 21 | render(callback) { 22 | if (this.shouldRender()) { 23 | this.mergeHead(); 24 | this.renderView(() => { 25 | this.replaceBody(); 26 | if (!this.isPreview) { 27 | this.focusFirstAutofocusableElement(); 28 | } 29 | callback(); 30 | }); 31 | } 32 | else { 33 | this.invalidateView(); 34 | } 35 | } 36 | 37 | mergeHead() { 38 | this.copyNewHeadStylesheetElements(); 39 | this.copyNewHeadScriptElements(); 40 | this.removeCurrentHeadProvisionalElements(); 41 | this.copyNewHeadProvisionalElements(); 42 | } 43 | 44 | replaceBody() { 45 | const placeholders = this.relocateCurrentBodyPermanentElements(); 46 | this.activateNewBodyScriptElements(); 47 | this.assignNewBody(); 48 | this.replacePlaceholderElementsWithClonedPermanentElements(placeholders); 49 | } 50 | 51 | shouldRender() { 52 | return this.currentSnapshot.isEnabled() && this.newSnapshot.isVisitable() && this.trackedElementsAreIdentical(); 53 | } 54 | 55 | trackedElementsAreIdentical() { 56 | return this.currentHeadDetails.getTrackedElementSignature() == this.newHeadDetails.getTrackedElementSignature(); 57 | } 58 | 59 | copyNewHeadStylesheetElements() { 60 | for (const element of this.getNewHeadStylesheetElements()) { 61 | document.head.appendChild(element); 62 | } 63 | } 64 | 65 | copyNewHeadScriptElements() { 66 | for (const element of this.getNewHeadScriptElements()) { 67 | document.head.appendChild( 68 | this.bindPendingAssetLoadedEventOnce( 69 | this.createScriptElement(element) 70 | ) 71 | ); 72 | } 73 | } 74 | 75 | bindPendingAssetLoadedEventOnce(element) { 76 | if (!element.hasAttribute('src')) { 77 | return element; 78 | } 79 | 80 | var self = this, 81 | loadEvent = function() { 82 | self.delegate.decrementPendingAsset(); 83 | element.removeEventListener('load', loadEvent); 84 | }; 85 | 86 | element.addEventListener('load', loadEvent); 87 | this.delegate.incrementPendingAsset(); 88 | return element; 89 | } 90 | 91 | removeCurrentHeadProvisionalElements() { 92 | for (const element of this.getCurrentHeadProvisionalElements()) { 93 | document.head.removeChild(element); 94 | } 95 | } 96 | 97 | copyNewHeadProvisionalElements() { 98 | for (const element of this.getNewHeadProvisionalElements()) { 99 | document.head.appendChild(element); 100 | } 101 | } 102 | 103 | relocateCurrentBodyPermanentElements() { 104 | return this.getCurrentBodyPermanentElements().reduce((placeholders, permanentElement) => { 105 | const newElement = this.newSnapshot.getPermanentElementById(permanentElement.id); 106 | if (newElement) { 107 | const placeholder = createPlaceholderForPermanentElement(permanentElement); 108 | replaceElementWithElement(permanentElement, placeholder.element); 109 | replaceElementWithElement(newElement, permanentElement); 110 | return [...placeholders, placeholder]; 111 | } 112 | else { 113 | return placeholders; 114 | } 115 | }, []); 116 | } 117 | 118 | replacePlaceholderElementsWithClonedPermanentElements(placeholders) { 119 | for (const { element, permanentElement } of placeholders) { 120 | const clonedElement = permanentElement.cloneNode(true); 121 | replaceElementWithElement(element, clonedElement); 122 | } 123 | } 124 | 125 | activateNewBodyScriptElements() { 126 | for (const inertScriptElement of this.getNewBodyScriptElements()) { 127 | const activatedScriptElement = this.createScriptElement(inertScriptElement); 128 | replaceElementWithElement(inertScriptElement, activatedScriptElement); 129 | } 130 | } 131 | 132 | assignNewBody() { 133 | replaceElementWithElement(document.body, this.newBody); 134 | } 135 | 136 | focusFirstAutofocusableElement() { 137 | const element = this.newSnapshot.findFirstAutofocusableElement(); 138 | if (elementIsFocusable(element)) { 139 | element.focus(); 140 | } 141 | } 142 | 143 | getNewHeadStylesheetElements() { 144 | return this.newHeadDetails.getStylesheetElementsNotInDetails(this.currentHeadDetails); 145 | } 146 | 147 | getNewHeadScriptElements() { 148 | return this.newHeadDetails.getScriptElementsNotInDetails(this.currentHeadDetails); 149 | } 150 | 151 | getCurrentHeadProvisionalElements() { 152 | return this.currentHeadDetails.getProvisionalElements(); 153 | } 154 | 155 | getNewHeadProvisionalElements() { 156 | return this.newHeadDetails.getProvisionalElements(); 157 | } 158 | 159 | getCurrentBodyPermanentElements() { 160 | return this.currentSnapshot.getPermanentElementsPresentInSnapshot(this.newSnapshot); 161 | } 162 | 163 | getNewBodyScriptElements() { 164 | return array(this.newBody.querySelectorAll('script')); 165 | } 166 | } 167 | 168 | function createPlaceholderForPermanentElement(permanentElement) { 169 | const element = document.createElement('meta'); 170 | element.setAttribute('name', 'turbo-permanent-placeholder'); 171 | element.setAttribute('content', permanentElement.id); 172 | return { element, permanentElement }; 173 | } 174 | 175 | function replaceElementWithElement(fromElement, toElement) { 176 | const parentElement = fromElement.parentElement; 177 | if (parentElement) { 178 | return parentElement.replaceChild(toElement, fromElement); 179 | } 180 | } 181 | 182 | function elementIsFocusable(element) { 183 | return element && typeof element.focus == 'function'; 184 | } 185 | -------------------------------------------------------------------------------- /src/turbo/snapshot.js: -------------------------------------------------------------------------------- 1 | import { HeadDetails } from "./head-details"; 2 | import { Location } from "./location"; 3 | import { array } from "../util"; 4 | 5 | export class Snapshot 6 | { 7 | constructor(headDetails, bodyElement) { 8 | this.headDetails = headDetails; 9 | this.bodyElement = bodyElement; 10 | } 11 | 12 | static wrap(value) { 13 | if (value instanceof this) { 14 | return value; 15 | } 16 | else if (typeof value == 'string') { 17 | return this.fromHTMLString(value); 18 | } 19 | else { 20 | return this.fromHTMLElement(value); 21 | } 22 | } 23 | 24 | static fromHTMLString(html) { 25 | const element = document.createElement('html'); 26 | element.innerHTML = html; 27 | return this.fromHTMLElement(element); 28 | } 29 | 30 | static fromHTMLElement(htmlElement) { 31 | const headElement = htmlElement.querySelector('head'); 32 | const bodyElement = htmlElement.querySelector('body') || document.createElement('body'); 33 | const headDetails = HeadDetails.fromHeadElement(headElement); 34 | return new this(headDetails, bodyElement); 35 | } 36 | 37 | clone() { 38 | return new Snapshot(this.headDetails, this.bodyElement.cloneNode(true)); 39 | } 40 | 41 | getRootLocation() { 42 | const root = this.getSetting('root', '/'); 43 | return new Location(root); 44 | } 45 | 46 | getCacheControlValue() { 47 | return this.getSetting('cache-control'); 48 | } 49 | 50 | getElementForAnchor(anchor) { 51 | try { 52 | return this.bodyElement.querySelector(`[id='${anchor}'], a[name='${anchor}']`); 53 | } 54 | catch (e) { 55 | return null; 56 | } 57 | } 58 | 59 | getPermanentElements() { 60 | return array(this.bodyElement.querySelectorAll('[id][data-turbo-permanent]')); 61 | } 62 | 63 | getPermanentElementById(id) { 64 | return this.bodyElement.querySelector(`#${id}[data-turbo-permanent]`); 65 | } 66 | 67 | getPermanentElementsPresentInSnapshot(snapshot) { 68 | return this.getPermanentElements().filter(({ id }) => snapshot.getPermanentElementById(id)); 69 | } 70 | 71 | findFirstAutofocusableElement() { 72 | return this.bodyElement.querySelector('[autofocus]'); 73 | } 74 | 75 | hasAnchor(anchor) { 76 | return this.getElementForAnchor(anchor) != null; 77 | } 78 | 79 | isPreviewable() { 80 | return this.getCacheControlValue() != 'no-preview'; 81 | } 82 | 83 | isCacheable() { 84 | return this.getCacheControlValue() != 'no-cache'; 85 | } 86 | 87 | isNativeError() { 88 | return this.getSetting('visit-control', false) != false; 89 | } 90 | 91 | isEnabled() { 92 | return this.getSetting('visit-control') != 'disable'; 93 | } 94 | 95 | isVisitable() { 96 | return this.isEnabled() && this.getSetting('visit-control') != 'reload'; 97 | } 98 | 99 | getSetting(name, defaultValue) { 100 | const value = this.headDetails.getMetaValue(`turbo-${name}`); 101 | return value == null ? defaultValue : value; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/turbo/view.js: -------------------------------------------------------------------------------- 1 | import { ErrorRenderer } from "./error-renderer"; 2 | import { Snapshot } from "./snapshot"; 3 | import { SnapshotRenderer } from "./snapshot-renderer"; 4 | 5 | export class View 6 | { 7 | constructor(delegate) { 8 | this.htmlElement = document.documentElement; 9 | this.delegate = delegate; 10 | } 11 | 12 | getRootLocation() { 13 | return this.getSnapshot().getRootLocation(); 14 | } 15 | 16 | getElementForAnchor(anchor) { 17 | return this.getSnapshot().getElementForAnchor(anchor); 18 | } 19 | 20 | getSnapshot() { 21 | return Snapshot.fromHTMLElement(this.htmlElement); 22 | } 23 | 24 | render({ snapshot, error, isPreview }, callback) { 25 | this.markAsPreview(isPreview); 26 | if (snapshot) { 27 | this.renderSnapshot(snapshot, isPreview, callback); 28 | } 29 | else { 30 | this.renderError(error, callback); 31 | } 32 | } 33 | 34 | // Private 35 | markAsPreview(isPreview) { 36 | if (isPreview) { 37 | this.htmlElement.setAttribute('data-turbo-preview', ''); 38 | } 39 | else { 40 | this.htmlElement.removeAttribute('data-turbo-preview'); 41 | } 42 | } 43 | 44 | renderSnapshot(snapshot, isPreview, callback) { 45 | SnapshotRenderer.render(this.delegate, callback, this.getSnapshot(), snapshot, isPreview || false); 46 | } 47 | 48 | renderError(error, callback) { 49 | ErrorRenderer.render(this.delegate, callback, error || ''); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/turbo/visit.js: -------------------------------------------------------------------------------- 1 | import { HttpRequest } from "../util/http-request"; 2 | import { Location } from "./location"; 3 | import { Snapshot } from "./snapshot"; 4 | import { uuid } from "../util"; 5 | 6 | export var TimingMetric = { 7 | visitStart: 'visitStart', 8 | requestStart: 'requestStart', 9 | requestEnd: 'requestEnd', 10 | visitEnd: 'visitEnd' 11 | }; 12 | 13 | export var VisitState = { 14 | initialized: 'initialized', 15 | started: 'started', 16 | canceled: 'canceled', 17 | failed: 'failed', 18 | completed: 'completed' 19 | }; 20 | 21 | export class Visit 22 | { 23 | constructor(controller, location, action, restorationIdentifier = uuid()) { 24 | this.identifier = uuid(); 25 | this.timingMetrics = {}; 26 | this.followedRedirect = false; 27 | this.historyChanged = false; 28 | this.progress = 0; 29 | this.scrolled = false; 30 | this.snapshotCached = action === 'swap'; 31 | this.state = VisitState.initialized; 32 | 33 | // Scrolling 34 | this.performScroll = () => { 35 | if (!this.scrolled) { 36 | if (this.action == 'restore') { 37 | this.scrollToRestoredPosition() || this.scrollToTop(); 38 | } 39 | else { 40 | this.scrollToAnchor() || this.scrollToTop(); 41 | } 42 | this.scrolled = true; 43 | } 44 | }; 45 | 46 | this.controller = controller; 47 | this.location = location; 48 | this.action = action; 49 | this.adapter = controller.adapter; 50 | this.restorationIdentifier = restorationIdentifier; 51 | this.isSamePage = this.locationChangeIsSamePage(); 52 | } 53 | 54 | start() { 55 | if (this.state == VisitState.initialized) { 56 | this.recordTimingMetric(TimingMetric.visitStart); 57 | this.state = VisitState.started; 58 | this.adapter.visitStarted(this); 59 | } 60 | } 61 | 62 | cancel() { 63 | if (this.state == VisitState.started) { 64 | if (this.request) { 65 | this.request.abort(); 66 | } 67 | 68 | this.cancelRender(); 69 | this.state = VisitState.canceled; 70 | } 71 | } 72 | 73 | complete() { 74 | if (this.state == VisitState.started) { 75 | this.recordTimingMetric(TimingMetric.visitEnd); 76 | this.state = VisitState.completed; 77 | this.adapter.visitCompleted(this); 78 | this.controller.visitCompleted(this); 79 | } 80 | } 81 | 82 | fail() { 83 | if (this.state == VisitState.started) { 84 | this.state = VisitState.failed; 85 | this.adapter.visitFailed(this); 86 | } 87 | } 88 | 89 | changeHistory() { 90 | if (!this.historyChanged) { 91 | const actionForHistory = this.location.isEqualTo(this.referrer) ? 'replace' : this.action; 92 | const method = this.getHistoryMethodForAction(actionForHistory); 93 | method.call(this.controller, this.location, this.restorationIdentifier); 94 | this.historyChanged = true; 95 | } 96 | } 97 | 98 | issueRequest() { 99 | if (this.shouldIssueRequest() && !this.request) { 100 | const url = Location.wrap(this.location).absoluteURL; 101 | const options = { 102 | method: 'GET', 103 | headers: {}, 104 | htmlOnly: true, 105 | timeout: 240 106 | }; 107 | 108 | options.headers['Accept'] = 'text/html, application/xhtml+xml'; 109 | options.headers['X-PJAX'] = 1; 110 | 111 | if (this.hasCachedSnapshot()) { 112 | options.headers['X-PJAX-CACHED'] = 1; 113 | } 114 | 115 | if (this.referrer) { 116 | options.headers['X-PJAX-REFERRER'] = Location.wrap(this.referrer).absoluteURL; 117 | } 118 | 119 | this.progress = 0; 120 | this.request = new HttpRequest(this, url, options); 121 | this.request.send(); 122 | } 123 | } 124 | 125 | getCachedSnapshot() { 126 | const snapshot = this.controller.getCachedSnapshotForLocation(this.location); 127 | if (snapshot && (!this.location.anchor || snapshot.hasAnchor(this.location.anchor))) { 128 | if (this.action == 'restore' || snapshot.isPreviewable()) { 129 | return snapshot; 130 | } 131 | } 132 | } 133 | 134 | hasCachedSnapshot() { 135 | return this.getCachedSnapshot() != null; 136 | } 137 | 138 | loadCachedSnapshot() { 139 | const snapshot = this.getCachedSnapshot(); 140 | if (snapshot) { 141 | const isPreview = this.shouldIssueRequest(); 142 | 143 | this.render(() => { 144 | this.cacheSnapshot(); 145 | if (this.isSamePage) { 146 | this.performScroll(); 147 | this.adapter.visitRendered(this); 148 | } 149 | else { 150 | this.controller.render({ snapshot, isPreview }, this.performScroll); 151 | this.adapter.visitRendered(this); 152 | if (!isPreview) { 153 | this.complete(); 154 | } 155 | } 156 | }); 157 | } 158 | } 159 | 160 | loadResponse() { 161 | const { request, response } = this; 162 | if (request && response) { 163 | this.render(() => { 164 | const snapshot = Snapshot.fromHTMLString(response); 165 | 166 | this.cacheSnapshot(); 167 | if (request.failed && !snapshot.isNativeError()) { 168 | this.controller.render({ error: response }, this.performScroll); 169 | this.adapter.visitRendered(this); 170 | this.fail(); 171 | } 172 | else { 173 | this.controller.render({ snapshot }, this.performScroll); 174 | this.adapter.visitRendered(this); 175 | this.complete(); 176 | } 177 | }); 178 | } 179 | } 180 | 181 | followRedirect() { 182 | if (this.redirectedToLocation && !this.followedRedirect) { 183 | this.location = this.redirectedToLocation; 184 | this.controller.replaceHistoryWithLocationAndRestorationIdentifier(this.redirectedToLocation, this.restorationIdentifier); 185 | this.followedRedirect = true; 186 | } 187 | } 188 | 189 | goToSamePageAnchor() { 190 | if (this.isSamePage) { 191 | this.render(() => { 192 | this.cacheSnapshot(); 193 | this.performScroll(); 194 | this.adapter.visitRendered(this); 195 | }); 196 | } 197 | } 198 | 199 | // HTTP request delegate 200 | requestStarted() { 201 | this.recordTimingMetric(TimingMetric.requestStart); 202 | this.adapter.visitRequestStarted(this); 203 | } 204 | 205 | requestProgressed(progress) { 206 | this.progress = progress; 207 | if (this.adapter.visitRequestProgressed) { 208 | this.adapter.visitRequestProgressed(this); 209 | } 210 | } 211 | 212 | requestCompletedWithResponse(response, statusCode, redirectedToLocation) { 213 | this.response = response; 214 | this.redirectedToLocation = Location.wrap(redirectedToLocation); 215 | this.adapter.visitRequestCompleted(this); 216 | } 217 | 218 | requestFailedWithStatusCode(statusCode, response) { 219 | this.response = response; 220 | this.adapter.visitRequestFailedWithStatusCode(this, statusCode); 221 | } 222 | 223 | requestFinished() { 224 | this.recordTimingMetric(TimingMetric.requestEnd); 225 | this.adapter.visitRequestFinished(this); 226 | } 227 | 228 | scrollToRestoredPosition() { 229 | const position = this.restorationData ? this.restorationData.scrollPosition : undefined; 230 | if (position) { 231 | this.controller.scrollToPosition(position); 232 | return true; 233 | } 234 | } 235 | 236 | scrollToAnchor() { 237 | if (this.location.anchor != null) { 238 | this.controller.scrollToAnchor(this.location.anchor); 239 | return true; 240 | } 241 | } 242 | 243 | scrollToTop() { 244 | this.controller.scrollToPosition({ x: 0, y: 0 }); 245 | } 246 | 247 | // Instrumentation 248 | recordTimingMetric(metric) { 249 | this.timingMetrics[metric] = new Date().getTime(); 250 | } 251 | 252 | getTimingMetrics() { 253 | return Object.assign({}, this.timingMetrics); 254 | } 255 | 256 | // Private 257 | getHistoryMethodForAction(action) { 258 | switch (action) { 259 | case 'swap': 260 | case 'replace': 261 | return this.controller.replaceHistoryWithLocationAndRestorationIdentifier; 262 | 263 | case 'advance': 264 | case 'restore': 265 | return this.controller.pushHistoryWithLocationAndRestorationIdentifier; 266 | } 267 | } 268 | 269 | shouldIssueRequest() { 270 | if (this.action == 'restore') { 271 | return !this.hasCachedSnapshot(); 272 | } 273 | else if (this.isSamePage) { 274 | return false; 275 | } 276 | else { 277 | return true; 278 | } 279 | } 280 | 281 | locationChangeIsSamePage() { 282 | if (this.action == 'swap') { 283 | return true; 284 | } 285 | 286 | const lastLocation = this.action == 'restore' && this.controller.lastRenderedLocation; 287 | return this.controller.locationIsSamePageAnchor(lastLocation || this.location); 288 | } 289 | 290 | cacheSnapshot() { 291 | if (!this.snapshotCached) { 292 | this.controller.cacheSnapshot(); 293 | this.snapshotCached = true; 294 | } 295 | } 296 | 297 | render(callback) { 298 | this.cancelRender(); 299 | this.frame = requestAnimationFrame(() => { 300 | delete this.frame; 301 | callback.call(this); 302 | }); 303 | } 304 | 305 | cancelRender() { 306 | if (this.frame) { 307 | cancelAnimationFrame(this.frame); 308 | delete this.frame; 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /src/util/deferred.js: -------------------------------------------------------------------------------- 1 | export var DeferredState = { 2 | pending: 'pending', 3 | rejected: 'rejected', 4 | resolved: 'resolved' 5 | } 6 | 7 | export class Deferred 8 | { 9 | constructor(options) { 10 | this.options = options || {}; 11 | this.stateStr = DeferredState.pending; 12 | 13 | this.successFuncs = []; 14 | this.failureFuncs = []; 15 | this.progressFuncs = []; 16 | 17 | this.resolveArgs = []; 18 | this.rejectArgs = []; 19 | this.progressArgs = []; 20 | 21 | this.isProgressNotified = false; 22 | } 23 | 24 | // Public 25 | resolve() { 26 | if (this.stateStr === DeferredState.pending) { 27 | this.resolveArgs = arguments; 28 | this.callFunction.call(this, this.successFuncs, this.resolveArgs); 29 | this.stateStr = DeferredState.resolved; 30 | } 31 | 32 | return this; 33 | } 34 | 35 | reject() { 36 | if (this.stateStr === DeferredState.pending) { 37 | this.rejectArgs = arguments; 38 | this.callFunction.call(this, this.failureFuncs, this.rejectArgs); 39 | this.stateStr = DeferredState.rejected; 40 | } 41 | 42 | return this; 43 | } 44 | 45 | notify() { 46 | if (this.stateStr === DeferredState.pending) { 47 | this.progressArgs = arguments; 48 | this.callFunction.call(this, this.progressFuncs, this.progressArgs); 49 | this.isProgressNotified = true; 50 | } 51 | return this; 52 | } 53 | 54 | abort() { 55 | this.options.delegate && this.options.delegate.abort(); 56 | } 57 | 58 | done() { 59 | var argumentsArray = Array.prototype.slice.call(arguments); 60 | this.successFuncs = this.successFuncs.concat(argumentsArray); 61 | 62 | if (this.stateStr === DeferredState.resolved) { 63 | this.callFunction.call(this, argumentsArray, this.resolveArgs); 64 | } 65 | 66 | return this; 67 | } 68 | 69 | fail() { 70 | var argumentsArray = Array.prototype.slice.call(arguments); 71 | this.failureFuncs = this.failureFuncs.concat(argumentsArray); 72 | 73 | if (this.stateStr === DeferredState.rejected) { 74 | this.callFunction.call(this, argumentsArray, this.rejectArgs); 75 | } 76 | 77 | return this; 78 | } 79 | 80 | progress() { 81 | var argumentsArray = Array.prototype.slice.call(arguments); 82 | this.progressFuncs = this.progressFuncs.concat(argumentsArray); 83 | 84 | if (this.stateStr === DeferredState.pending && this.isProgressNotified) { 85 | this.callFunction.call(this, argumentsArray, this.progressArgs); 86 | } 87 | 88 | return this; 89 | } 90 | 91 | always() { 92 | var argumentsArray = Array.prototype.slice.call(arguments); 93 | this.successFuncs = this.successFuncs.concat(argumentsArray); 94 | this.failureFuncs = this.failureFuncs.concat(argumentsArray); 95 | 96 | if (this.stateStr !== DeferredState.pending) { 97 | this.callFunction.call(this, argumentsArray, this.resolveArgs || this.rejectArgs); 98 | } 99 | 100 | return this; 101 | } 102 | 103 | then() { 104 | var tempArgs = []; 105 | for (var index in arguments) { 106 | var itemToPush; 107 | 108 | if (Array.isArray(arguments[index])) { 109 | itemToPush = arguments[index]; 110 | } 111 | else { 112 | itemToPush = [arguments[index]]; 113 | } 114 | 115 | tempArgs.push(itemToPush); 116 | } 117 | 118 | this.done.apply(this, tempArgs[0]); 119 | this.fail.apply(this, tempArgs[1]); 120 | this.progress.apply(this, tempArgs[2]); 121 | 122 | return this; 123 | } 124 | 125 | promise() { 126 | var protectedNames = ['resolve', 'reject', 'promise', 'notify']; 127 | var result = {}; 128 | 129 | for (var key in this) { 130 | if (protectedNames.indexOf(key) === -1) { 131 | result[key] = this[key]; 132 | } 133 | } 134 | 135 | return result; 136 | } 137 | 138 | state() { 139 | if (arguments.length > 0) { 140 | stateStr = arguments[0]; 141 | } 142 | 143 | return stateStr; 144 | } 145 | 146 | // Private 147 | callFunction(functionDefinitionArray, functionArgumentArray, options) { 148 | options = options || {}; 149 | var scope = options.scope || this; 150 | 151 | for (var index in functionDefinitionArray) { 152 | var item = functionDefinitionArray[index]; 153 | if (typeof(item) === 'function') { 154 | item.apply(scope, functionArgumentArray); 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/util/events.js: -------------------------------------------------------------------------------- 1 | import { dispatch } from "./index"; 2 | 3 | /** 4 | * Constants 5 | */ 6 | const namespaceRegex = /[^.]*(?=\..*)\.|.*/ 7 | const stripNameRegex = /\..*/ 8 | const stripUidRegex = /::\d+$/ 9 | const eventRegistry = {} // Events storage 10 | 11 | let uidEvent = 1; 12 | 13 | const customEvents = { 14 | mouseenter: 'mouseover', 15 | mouseleave: 'mouseout' 16 | } 17 | 18 | const nativeEvents = new Set([ 19 | 'click', 20 | 'dblclick', 21 | 'mouseup', 22 | 'mousedown', 23 | 'contextmenu', 24 | 'mousewheel', 25 | 'DOMMouseScroll', 26 | 'mouseover', 27 | 'mouseout', 28 | 'mousemove', 29 | 'selectstart', 30 | 'selectend', 31 | 'keydown', 32 | 'keypress', 33 | 'keyup', 34 | 'orientationchange', 35 | 'touchstart', 36 | 'touchmove', 37 | 'touchend', 38 | 'touchcancel', 39 | 'pointerdown', 40 | 'pointermove', 41 | 'pointerup', 42 | 'pointerleave', 43 | 'pointercancel', 44 | 'gesturestart', 45 | 'gesturechange', 46 | 'gestureend', 47 | 'focus', 48 | 'blur', 49 | 'change', 50 | 'reset', 51 | 'select', 52 | 'submit', 53 | 'focusin', 54 | 'focusout', 55 | 'load', 56 | 'unload', 57 | 'beforeunload', 58 | 'resize', 59 | 'move', 60 | 'DOMContentLoaded', 61 | 'readystatechange', 62 | 'error', 63 | 'abort', 64 | 'scroll' 65 | ]); 66 | 67 | export class Events 68 | { 69 | static on(element, event, handler, delegationFunction, options) { 70 | addHandler(element, event, handler, delegationFunction, options, false); 71 | } 72 | 73 | static one(element, event, handler, delegationFunction, options) { 74 | addHandler(element, event, handler, delegationFunction, options, true); 75 | } 76 | 77 | static off(element, originalTypeEvent, handler, delegationFunction, options) { 78 | if (typeof originalTypeEvent !== 'string' || !element) { 79 | return; 80 | } 81 | 82 | const [isDelegated, callable, typeEvent, opts] = normalizeParameters(originalTypeEvent, handler, delegationFunction, options); 83 | const inNamespace = typeEvent !== originalTypeEvent; 84 | const events = getElementEvents(element); 85 | const storeElementEvent = events[typeEvent] || {}; 86 | const isNamespace = originalTypeEvent.startsWith('.'); 87 | 88 | if (typeof callable !== 'undefined') { 89 | // Simplest case: handler is passed, remove that listener ONLY. 90 | if (!storeElementEvent) { 91 | return; 92 | } 93 | 94 | removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null, opts); 95 | return; 96 | } 97 | 98 | if (isNamespace) { 99 | for (const elementEvent of Object.keys(events)) { 100 | removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1)); 101 | } 102 | } 103 | 104 | for (const keyHandlers of Object.keys(storeElementEvent)) { 105 | const handlerKey = keyHandlers.replace(stripUidRegex, ''); 106 | 107 | if (!inNamespace || originalTypeEvent.includes(handlerKey)) { 108 | const event = storeElementEvent[keyHandlers]; 109 | removeHandler(element, events, typeEvent, event.callable, event.delegationSelector, opts); 110 | } 111 | } 112 | } 113 | 114 | static dispatch(eventName, { target = document, detail = {}, bubbles = true, cancelable = true } = {}) { 115 | return dispatch(eventName, { target, detail, bubbles, cancelable }); 116 | } 117 | } 118 | 119 | /** 120 | * Private methods 121 | */ 122 | function makeEventUid(element, uid) { 123 | return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++; 124 | } 125 | 126 | function getElementEvents(element) { 127 | const uid = makeEventUid(element); 128 | 129 | element.uidEvent = uid; 130 | eventRegistry[uid] = eventRegistry[uid] || {}; 131 | 132 | return eventRegistry[uid]; 133 | } 134 | 135 | function findHandler(events, callable, delegationSelector = null) { 136 | return Object.values(events) 137 | .find(event => event.callable === callable && event.delegationSelector === delegationSelector); 138 | } 139 | 140 | function normalizeParameters(originalTypeEvent, handler, delegationFunction, options) { 141 | const isDelegated = typeof handler === 'string'; 142 | const callable = isDelegated ? delegationFunction : handler; 143 | const opts = isDelegated ? options : delegationFunction; 144 | let typeEvent = getTypeEvent(originalTypeEvent); 145 | 146 | if (!nativeEvents.has(typeEvent)) { 147 | typeEvent = originalTypeEvent; 148 | } 149 | 150 | return [isDelegated, callable, typeEvent, opts]; 151 | } 152 | 153 | function addHandler(element, originalTypeEvent, handler, delegationFunction, options, oneOff) { 154 | if (typeof originalTypeEvent !== 'string' || !element) { 155 | return; 156 | } 157 | 158 | let [isDelegated, callable, typeEvent, opts] = normalizeParameters(originalTypeEvent, handler, delegationFunction, options); 159 | 160 | // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position 161 | // this prevents the handler from being dispatched the same way as mouseover or mouseout does 162 | if (originalTypeEvent in customEvents) { 163 | const wrapFunction = fn => { 164 | return function (event) { 165 | if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) { 166 | return fn.call(this, event); 167 | } 168 | } 169 | } 170 | 171 | callable = wrapFunction(callable); 172 | } 173 | 174 | const events = getElementEvents(element); 175 | const handlers = events[typeEvent] || (events[typeEvent] = {}); 176 | const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null); 177 | 178 | if (previousFunction) { 179 | previousFunction.oneOff = previousFunction.oneOff && oneOff; 180 | return; 181 | } 182 | 183 | const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, '')); 184 | const fn = isDelegated 185 | ? internalDelegationHandler(element, handler, callable) 186 | : internalHandler(element, callable); 187 | 188 | fn.delegationSelector = isDelegated ? handler : null; 189 | fn.callable = callable; 190 | fn.oneOff = oneOff; 191 | fn.uidEvent = uid; 192 | handlers[uid] = fn; 193 | 194 | element.addEventListener(typeEvent, fn, opts); 195 | } 196 | 197 | function removeHandler(element, events, typeEvent, handler, delegationSelector, options) { 198 | const fn = findHandler(events[typeEvent], handler, delegationSelector); 199 | 200 | if (!fn) { 201 | return; 202 | } 203 | 204 | element.removeEventListener(typeEvent, fn, options); 205 | delete events[typeEvent][fn.uidEvent]; 206 | } 207 | 208 | function internalHandler(element, fn) { 209 | return function handler(event) { 210 | event.delegateTarget = element; 211 | 212 | if (handler.oneOff) { 213 | Events.off(element, event.type, fn); 214 | } 215 | 216 | return fn.apply(element, [event]); 217 | } 218 | } 219 | 220 | function internalDelegationHandler(element, selector, fn) { 221 | return function handler(event) { 222 | const domElements = element.querySelectorAll(selector); 223 | 224 | for (let { target } = event; target && target !== this; target = target.parentNode) { 225 | for (const domElement of domElements) { 226 | if (domElement !== target) { 227 | continue; 228 | } 229 | 230 | event.delegateTarget = target; 231 | 232 | if (handler.oneOff) { 233 | Events.off(element, event.type, selector, fn); 234 | } 235 | 236 | return fn.apply(target, [event]); 237 | } 238 | } 239 | } 240 | } 241 | 242 | function removeNamespacedHandlers(element, events, typeEvent, namespace) { 243 | const storeElementEvent = events[typeEvent] || {}; 244 | 245 | for (const handlerKey of Object.keys(storeElementEvent)) { 246 | if (handlerKey.includes(namespace)) { 247 | const event = storeElementEvent[handlerKey]; 248 | removeHandler(element, events, typeEvent, event.callable, event.delegationSelector); 249 | } 250 | } 251 | } 252 | 253 | // Allow to get the native events from namespaced events ('click.bs.button' --> 'click') 254 | function getTypeEvent(event) { 255 | event = event.replace(stripNameRegex, ''); 256 | return customEvents[event] || event; 257 | } 258 | -------------------------------------------------------------------------------- /src/util/form-serializer.js: -------------------------------------------------------------------------------- 1 | // FormSerializer serializes input elements to JSON 2 | export class FormSerializer 3 | { 4 | // Public 5 | static assignToObj(obj, name, value) { 6 | (new FormSerializer).assignObjectInternal(obj, name, value); 7 | } 8 | 9 | static serializeJSON(element) { 10 | return (new FormSerializer).parseContainer(element); 11 | } 12 | 13 | // Private 14 | parseContainer(element) { 15 | let jsonData = {}; 16 | element.querySelectorAll('input, textarea, select').forEach((field) => { 17 | if (!field.name || field.disabled || ['file', 'reset', 'submit', 'button'].indexOf(field.type) > -1) { 18 | return; 19 | } 20 | 21 | if (['checkbox', 'radio'].indexOf(field.type) > -1 && !field.checked) { 22 | return; 23 | } 24 | 25 | if (field.type === 'select-multiple') { 26 | var arr = []; 27 | Array.from(field.options).forEach(function(option) { 28 | if (option.selected) { 29 | arr.push({ 30 | name: field.name, 31 | value: option.value 32 | }); 33 | } 34 | }); 35 | this.assignObjectInternal(jsonData, field.name, arr); 36 | return; 37 | } 38 | 39 | this.assignObjectInternal(jsonData, field.name, field.value); 40 | }); 41 | 42 | return jsonData; 43 | } 44 | 45 | assignObjectInternal(obj, fieldName, fieldValue) { 46 | this.assignObjectNested( 47 | obj, 48 | this.nameToArray(fieldName), 49 | fieldValue, 50 | fieldName.endsWith('[]') 51 | ); 52 | } 53 | 54 | assignObjectNested(obj, fieldArr, fieldValue, isArray) { 55 | var currentTarget = obj, 56 | lastIndex = fieldArr.length - 1; 57 | 58 | fieldArr.forEach(function(prop, index) { 59 | if (isArray && index === lastIndex) { 60 | if (!Array.isArray(currentTarget[prop])) { 61 | currentTarget[prop] = []; 62 | } 63 | 64 | currentTarget[prop].push(fieldValue); 65 | } 66 | else { 67 | if (currentTarget[prop] === undefined || currentTarget[prop].constructor !== {}.constructor) { 68 | currentTarget[prop] = {}; 69 | } 70 | 71 | if (index === lastIndex) { 72 | currentTarget[prop] = fieldValue; 73 | } 74 | 75 | currentTarget = currentTarget[prop]; 76 | } 77 | }); 78 | } 79 | 80 | nameToArray(fieldName) { 81 | var expression = /([^\]\[]+)/g, 82 | elements = [], 83 | searchResult; 84 | 85 | while ((searchResult = expression.exec(fieldName))) { 86 | elements.push(searchResult[0]); 87 | } 88 | 89 | return elements; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/util/http-request.js: -------------------------------------------------------------------------------- 1 | import { Events } from "./events"; 2 | 3 | export var SystemStatusCode = { 4 | networkFailure: 0, 5 | timeoutFailure: -1, 6 | contentTypeMismatch: -2, 7 | userAborted: -3 8 | } 9 | 10 | export class HttpRequest 11 | { 12 | constructor(delegate, url, options) { 13 | this.failed = false; 14 | this.progress = 0; 15 | this.sent = false; 16 | 17 | this.delegate = delegate; 18 | this.url = url; 19 | this.options = options; 20 | 21 | this.headers = options.headers || {}; 22 | this.method = options.method || 'GET'; 23 | this.responseType = options.responseType || ''; 24 | this.data = options.data; 25 | this.timeout = options.timeout || 0; 26 | 27 | // XMLHttpRequest events 28 | this.requestProgressed = (event) => { 29 | if (event.lengthComputable) { 30 | this.setProgress(event.loaded / event.total); 31 | } 32 | }; 33 | 34 | this.requestLoaded = () => { 35 | this.endRequest(xhr => { 36 | this.processResponseData(xhr, (xhr, data) => { 37 | const contentType = xhr.getResponseHeader('Content-Type'); 38 | const responseData = contentTypeIsJSON(contentType) ? JSON.parse(data) : data; 39 | 40 | if (this.options.htmlOnly && !contentTypeIsHTML(contentType)) { 41 | this.failed = true; 42 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.contentTypeMismatch); 43 | return; 44 | } 45 | 46 | if (xhr.status >= 200 && xhr.status < 300) { 47 | this.delegate.requestCompletedWithResponse(responseData, xhr.status, contentResponseIsRedirect(xhr, this.url)); 48 | } 49 | else { 50 | this.failed = true; 51 | this.delegate.requestFailedWithStatusCode(xhr.status, responseData); 52 | } 53 | }); 54 | }); 55 | }; 56 | 57 | this.requestFailed = () => { 58 | this.endRequest(() => { 59 | this.failed = true; 60 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.networkFailure); 61 | }); 62 | }; 63 | 64 | this.requestTimedOut = () => { 65 | this.endRequest(() => { 66 | this.failed = true; 67 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.timeoutFailure); 68 | }); 69 | }; 70 | 71 | this.requestCanceled = () => { 72 | if (this.options.trackAbort) { 73 | this.endRequest(() => { 74 | this.failed = true; 75 | this.delegate.requestFailedWithStatusCode(SystemStatusCode.userAborted); 76 | }); 77 | } 78 | else { 79 | this.endRequest(); 80 | } 81 | }; 82 | 83 | this.createXHR(); 84 | } 85 | 86 | send() { 87 | if (this.xhr && !this.sent) { 88 | this.notifyApplicationBeforeRequestStart(); 89 | this.setProgress(0); 90 | this.xhr.send(this.data || null); 91 | this.sent = true; 92 | this.delegate.requestStarted(); 93 | } 94 | } 95 | 96 | abort() { 97 | if (this.xhr && this.sent) { 98 | this.xhr.abort(); 99 | } 100 | } 101 | 102 | // Application events 103 | notifyApplicationBeforeRequestStart() { 104 | Events.dispatch('ajax:request-start', { detail: { url: this.url, xhr: this.xhr }, cancelable: false }); 105 | } 106 | 107 | notifyApplicationAfterRequestEnd() { 108 | Events.dispatch('ajax:request-end', { detail: { url: this.url, xhr: this.xhr }, cancelable: false }); 109 | } 110 | 111 | // Private 112 | createXHR() { 113 | const xhr = this.xhr = new XMLHttpRequest; 114 | xhr.open(this.method, this.url, true); 115 | xhr.responseType = this.responseType; 116 | 117 | xhr.onprogress = this.requestProgressed; 118 | xhr.onload = this.requestLoaded; 119 | xhr.onerror = this.requestFailed; 120 | xhr.ontimeout = this.requestTimedOut; 121 | xhr.onabort = this.requestCanceled; 122 | 123 | if (this.timeout) { 124 | xhr.timeout = this.timeout * 1000; 125 | } 126 | 127 | for (var i in this.headers) { 128 | xhr.setRequestHeader(i, this.headers[i]); 129 | } 130 | 131 | return xhr; 132 | } 133 | 134 | endRequest(callback = () => { }) { 135 | if (this.xhr) { 136 | this.notifyApplicationAfterRequestEnd(); 137 | callback(this.xhr); 138 | this.destroy(); 139 | } 140 | } 141 | 142 | setProgress(progress) { 143 | this.progress = progress; 144 | this.delegate.requestProgressed(progress); 145 | } 146 | 147 | destroy() { 148 | this.setProgress(1); 149 | this.delegate.requestFinished(); 150 | } 151 | 152 | processResponseData(xhr, callback) { 153 | if (this.responseType !== 'blob') { 154 | callback(xhr, xhr.responseText); 155 | return; 156 | } 157 | 158 | // Confirm response is a download 159 | const contentDisposition = xhr.getResponseHeader('Content-Disposition') || ''; 160 | if (contentDisposition.indexOf('attachment') === 0 || contentDisposition.indexOf('inline') === 0) { 161 | callback(xhr, xhr.response); 162 | return; 163 | } 164 | 165 | // Convert blob to text 166 | const reader = new FileReader; 167 | reader.addEventListener('load', () => { callback(xhr, reader.result); }); 168 | reader.readAsText(xhr.response); 169 | } 170 | } 171 | 172 | function contentResponseIsRedirect(xhr, url) { 173 | if (xhr.getResponseHeader('X-OCTOBER-LOCATION')) { 174 | return xhr.getResponseHeader('X-OCTOBER-LOCATION'); 175 | } 176 | 177 | var anchorMatch = url.match(/^(.*)#/), 178 | wantUrl = anchorMatch ? anchorMatch[1] : url; 179 | 180 | return wantUrl !== xhr.responseURL ? xhr.responseURL : null; 181 | } 182 | 183 | function contentTypeIsHTML(contentType) { 184 | return (contentType || '').match(/^text\/html|^application\/xhtml\+xml/); 185 | } 186 | 187 | function contentTypeIsJSON(contentType) { 188 | return (contentType || '').includes('application/json'); 189 | } 190 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | export function dispatch(eventName, { target = document, detail = {}, bubbles = true, cancelable = true } = {}) { 2 | const event = new CustomEvent(eventName, { detail, bubbles, cancelable }); 3 | target.dispatchEvent(event); 4 | return event; 5 | } 6 | 7 | export function defer(callback) { 8 | setTimeout(callback, 1); 9 | } 10 | 11 | export function unindent(strings, ...values) { 12 | const lines = trimLeft(interpolate(strings, values)).split("\n"); 13 | const match = lines[0].match(/^\s+/); 14 | const indent = match ? match[0].length : 0; 15 | return lines.map(line => line.slice(indent)).join("\n"); 16 | } 17 | 18 | function trimLeft(string) { 19 | return string.replace(/^\n/, ""); 20 | } 21 | 22 | function interpolate(strings, values) { 23 | return strings.reduce((result, string, i) => { 24 | const value = values[i] == undefined ? "" : values[i]; 25 | return result + string + value; 26 | }, ""); 27 | } 28 | 29 | export function array(values) { 30 | return Array.prototype.slice.call(values); 31 | } 32 | 33 | export function uuid() { 34 | return Array.apply(null, { length: 36 }).map((_, i) => { 35 | if (i == 8 || i == 13 || i == 18 || i == 23) { 36 | return "-"; 37 | } 38 | else if (i == 14) { 39 | return "4"; 40 | } 41 | else if (i == 19) { 42 | return (Math.floor(Math.random() * 4) + 8).toString(16); 43 | } 44 | else { 45 | return Math.floor(Math.random() * 15).toString(16); 46 | } 47 | }).join(""); 48 | } 49 | -------------------------------------------------------------------------------- /src/util/json-parser.js: -------------------------------------------------------------------------------- 1 | // JsonParser serializes JS-syntax to JSON without using eval 2 | export class JsonParser 3 | { 4 | // Public 5 | static paramToObj(name, value) { 6 | if (value === undefined) { 7 | value = ''; 8 | } 9 | 10 | if (typeof value === 'object') { 11 | return value; 12 | } 13 | 14 | if (value.charAt(0) !== '{') { 15 | value = "{" + value + "}"; 16 | } 17 | 18 | try { 19 | return this.parseJSON(value); 20 | } 21 | catch (e) { 22 | throw new Error('Error parsing the ' + name + ' attribute value. ' + e); 23 | } 24 | } 25 | 26 | static parseJSON(json) { 27 | return JSON.parse((new JsonParser).parseString(json)); 28 | } 29 | 30 | // Private 31 | parseString(str) { 32 | str = str.trim(); 33 | if (!str.length) { 34 | throw new Error("Broken JSON object."); 35 | } 36 | 37 | var result = ""; 38 | 39 | /* 40 | * the mistake ',' 41 | */ 42 | while (str && str[0] === ",") { 43 | str = str.substr(1); 44 | } 45 | 46 | /* 47 | * string 48 | */ 49 | if (str[0] === "\"" || str[0] === "'") { 50 | if (str[str.length - 1] !== str[0]) { 51 | throw new Error("Invalid string JSON object."); 52 | } 53 | 54 | var body = "\""; 55 | for (var i = 1; i < str.length; i++) { 56 | if (str[i] === "\\") { 57 | if (str[i + 1] === "'") { 58 | body += str[i + 1] 59 | } 60 | else { 61 | body += str[i]; 62 | body += str[i + 1]; 63 | } 64 | i++; 65 | } 66 | else if (str[i] === str[0]) { 67 | body += "\""; 68 | return body 69 | } 70 | else if (str[i] === "\"") { 71 | body += "\\\"" 72 | } 73 | else body += str[i]; 74 | } 75 | throw new Error("Invalid string JSON object."); 76 | } 77 | 78 | /* 79 | * boolean 80 | */ 81 | if (str === "true" || str === "false") { 82 | return str; 83 | } 84 | 85 | /* 86 | * null 87 | */ 88 | if (str === "null") { 89 | return "null"; 90 | } 91 | 92 | /* 93 | * number 94 | */ 95 | var num = parseFloat(str); 96 | if (!isNaN(num)) { 97 | return num.toString(); 98 | } 99 | 100 | /* 101 | * object 102 | */ 103 | if (str[0] === "{") { 104 | var type = "needKey"; 105 | var result = "{"; 106 | 107 | for (var i = 1; i < str.length; i++) { 108 | if (this.isBlankChar(str[i])) { 109 | continue; 110 | } 111 | else if (type === "needKey" && (str[i] === "\"" || str[i] === "'")) { 112 | var key = this.parseKey(str, i + 1, str[i]); 113 | result += "\"" + key + "\""; 114 | i += key.length; 115 | i += 1; 116 | type = "afterKey"; 117 | } 118 | else if (type === "needKey" && this.canBeKeyHead(str[i])) { 119 | var key = this.parseKey(str, i); 120 | result += "\""; 121 | result += key; 122 | result += "\""; 123 | i += key.length - 1; 124 | type = "afterKey"; 125 | } 126 | else if (type === "afterKey" && str[i] === ":") { 127 | result += ":"; 128 | type = ":"; 129 | } 130 | else if (type === ":") { 131 | var body = this.getBody(str, i); 132 | 133 | i = i + body.originLength - 1; 134 | result += this.parseString(body.body); 135 | 136 | type = "afterBody"; 137 | } 138 | else if (type === "afterBody" || type === "needKey") { 139 | var last = i; 140 | while (str[last] === "," || this.isBlankChar(str[last])) { 141 | last++; 142 | } 143 | if (str[last] === "}" && last === str.length - 1) { 144 | while (result[result.length - 1] === ",") { 145 | result = result.substr(0, result.length - 1); 146 | } 147 | result += "}"; 148 | return result; 149 | } 150 | else if (last !== i && result !== "{") { 151 | result += ","; 152 | type = "needKey"; 153 | i = last - 1; 154 | } 155 | } 156 | } 157 | throw new Error("Broken JSON object near " + result); 158 | } 159 | 160 | /* 161 | * array 162 | */ 163 | if (str[0] === "[") { 164 | var result = "["; 165 | var type = "needBody"; 166 | for (var i = 1; i < str.length; i++) { 167 | if (" " === str[i] || "\n" === str[i] || "\t" === str[i]) { 168 | continue; 169 | } 170 | else if (type === "needBody") { 171 | if (str[i] === ",") { 172 | result += "null,"; 173 | continue; 174 | } 175 | if (str[i] === "]" && i === str.length - 1) { 176 | if (result[result.length - 1] === ",") result = result.substr(0, result.length - 1); 177 | result += "]"; 178 | return result; 179 | } 180 | 181 | var body = this.getBody(str, i); 182 | 183 | i = i + body.originLength - 1; 184 | result += this.parseString(body.body); 185 | 186 | type = "afterBody"; 187 | } 188 | else if (type === "afterBody") { 189 | if (str[i] === ",") { 190 | result += ","; 191 | type = "needBody"; 192 | 193 | // deal with mistake "," 194 | while (str[i + 1] === "," || this.isBlankChar(str[i + 1])) { 195 | if (str[i + 1] === ",") result += "null,"; 196 | i++; 197 | } 198 | } 199 | else if (str[i] === "]" && i === str.length - 1) { 200 | result += "]"; 201 | return result; 202 | } 203 | } 204 | } 205 | throw new Error("Broken JSON array near " + result); 206 | } 207 | } 208 | 209 | parseKey(str, pos, quote) { 210 | var key = ""; 211 | for (var i = pos; i < str.length; i++) { 212 | if (quote && quote === str[i]) { 213 | return key; 214 | } 215 | else if (!quote && (str[i] === " " || str[i] === ":")) { 216 | return key; 217 | } 218 | 219 | key += str[i]; 220 | 221 | if (str[i] === "\\" && i + 1 < str.length) { 222 | key += str[i + 1]; 223 | i++; 224 | } 225 | } 226 | throw new Error("Broken JSON syntax near " + key); 227 | } 228 | 229 | getBody(str, pos) { 230 | // parse string body 231 | if (str[pos] === "\"" || str[pos] === "'") { 232 | var body = str[pos]; 233 | for (var i = pos + 1; i < str.length; i++) { 234 | if (str[i] === "\\") { 235 | body += str[i]; 236 | if (i + 1 < str.length) body += str[i + 1]; 237 | i++; 238 | } 239 | else if (str[i] === str[pos]) { 240 | body += str[pos]; 241 | return { 242 | originLength: body.length, 243 | body: body 244 | }; 245 | } 246 | else body += str[i]; 247 | } 248 | throw new Error("Broken JSON string body near " + body); 249 | } 250 | 251 | // parse true / false 252 | if (str[pos] === "t") { 253 | if (str.indexOf("true", pos) === pos) { 254 | return { 255 | originLength: "true".length, 256 | body: "true" 257 | }; 258 | } 259 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10)); 260 | } 261 | if (str[pos] === "f") { 262 | if (str.indexOf("f", pos) === pos) { 263 | return { 264 | originLength: "false".length, 265 | body: "false" 266 | }; 267 | } 268 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10)); 269 | } 270 | 271 | // parse null 272 | if (str[pos] === "n") { 273 | if (str.indexOf("null", pos) === pos) { 274 | return { 275 | originLength: "null".length, 276 | body: "null" 277 | }; 278 | } 279 | throw new Error("Broken JSON boolean body near " + str.substr(0, pos + 10)); 280 | } 281 | 282 | // parse number 283 | if (str[pos] === "-" || str[pos] === "+" || str[pos] === "." || (str[pos] >= "0" && str[pos] <= "9")) { 284 | var body = ""; 285 | for (var i = pos; i < str.length; i++) { 286 | if (str[i] === "-" || str[i] === "+" || str[i] === "." || (str[i] >= "0" && str[i] <= "9")) { 287 | body += str[i]; 288 | } 289 | else { 290 | return { 291 | originLength: body.length, 292 | body: body 293 | }; 294 | } 295 | } 296 | throw new Error("Broken JSON number body near " + body); 297 | } 298 | 299 | // parse object 300 | if (str[pos] === "{" || str[pos] === "[") { 301 | var stack = [str[pos]]; 302 | var body = str[pos]; 303 | for (var i = pos + 1; i < str.length; i++) { 304 | body += str[i]; 305 | if (str[i] === "\\") { 306 | if (i + 1 < str.length) body += str[i + 1]; 307 | i++; 308 | } 309 | else if (str[i] === "\"") { 310 | if (stack[stack.length - 1] === "\"") { 311 | stack.pop(); 312 | } 313 | else if (stack[stack.length - 1] !== "'") { 314 | stack.push(str[i]); 315 | } 316 | } 317 | else if (str[i] === "'") { 318 | if (stack[stack.length - 1] === "'") { 319 | stack.pop(); 320 | } 321 | else if (stack[stack.length - 1] !== "\"") { 322 | stack.push(str[i]); 323 | } 324 | } 325 | else if (stack[stack.length - 1] !== "\"" && stack[stack.length - 1] !== "'") { 326 | if (str[i] === "{") { 327 | stack.push("{"); 328 | } 329 | else if (str[i] === "}") { 330 | if (stack[stack.length - 1] === "{") { 331 | stack.pop(); 332 | } 333 | else { 334 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body); 335 | } 336 | } 337 | else if (str[i] === "[") { 338 | stack.push("["); 339 | } 340 | else if (str[i] === "]") { 341 | if (stack[stack.length - 1] === "[") { 342 | stack.pop(); 343 | } 344 | else { 345 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body); 346 | } 347 | } 348 | } 349 | if (!stack.length) { 350 | return { 351 | originLength: i - pos, 352 | body: body 353 | }; 354 | } 355 | } 356 | throw new Error("Broken JSON " + (str[pos] === "{" ? "object" : "array") + " body near " + body); 357 | } 358 | throw new Error("Broken JSON body near " + str.substr((pos - 5 >= 0) ? pos - 5 : 0, 50)); 359 | } 360 | 361 | canBeKeyHead(ch) { 362 | if (ch[0] === "\\") return false; 363 | if ((ch[0] >= 'a' && ch[0] <= 'z') || (ch[0] >= 'A' && ch[0] <= 'Z') || ch[0] === '_') return true; 364 | if (ch[0] >= '0' && ch[0] <= '9') return true; 365 | if (ch[0] === '$') return true; 366 | if (ch.charCodeAt(0) > 255) return true; 367 | return false; 368 | } 369 | 370 | isBlankChar(ch) { 371 | return ch === " " || ch === "\n" || ch === "\t"; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /src/util/referrer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * getReferrerUrl returns the last visited URL 3 | */ 4 | export function getReferrerUrl() { 5 | const url = oc.useTurbo && oc.useTurbo() 6 | ? oc.AjaxTurbo.controller.getLastVisitUrl() 7 | : getReferrerFromSameOrigin(); 8 | 9 | 10 | if (!url || isSameBaseUrl(url)) { 11 | return null; 12 | } 13 | 14 | return url; 15 | } 16 | 17 | function getReferrerFromSameOrigin() { 18 | if (!document.referrer) { 19 | return null; 20 | } 21 | 22 | // Fallback when turbo router is not activated 23 | try { 24 | const referrer = new URL(document.referrer); 25 | if (referrer.origin !== location.origin) { 26 | return null; 27 | } 28 | 29 | const pushReferrer = localStorage.getItem('ocPushStateReferrer'); 30 | if (pushReferrer && pushReferrer.indexOf(referrer.pathname) === 0) { 31 | return pushReferrer; 32 | } 33 | 34 | return document.referrer; 35 | } 36 | catch (e) { 37 | } 38 | } 39 | 40 | function isSameBaseUrl(url) { 41 | const givenUrl = new URL(url, window.location.origin), 42 | currentUrl = new URL(window.location.href); 43 | 44 | return givenUrl.origin === currentUrl.origin && givenUrl.pathname === currentUrl.pathname; 45 | } 46 | -------------------------------------------------------------------------------- /src/util/wait.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Function to wait for predicates. 3 | * @param {function() : Boolean} predicate - A function that returns a bool 4 | * @param {Number} [timeout] - Optional maximum waiting time in ms after rejected 5 | */ 6 | export function waitFor(predicate, timeout) { 7 | return new Promise((resolve, reject) => { 8 | const check = () => { 9 | if (!predicate()) { 10 | return; 11 | } 12 | clearInterval(interval); 13 | resolve(); 14 | }; 15 | const interval = setInterval(check, 100); 16 | check(); 17 | 18 | if (!timeout) { 19 | return; 20 | } 21 | 22 | setTimeout(() => { 23 | clearInterval(interval); 24 | reject(); 25 | }, timeout); 26 | }); 27 | } 28 | 29 | /** 30 | * Function to wait for the DOM to be ready, if not already 31 | */ 32 | export function domReady() { 33 | return new Promise((resolve) => { 34 | if (document.readyState === 'loading') { 35 | document.addEventListener('DOMContentLoaded', () => resolve()); 36 | } 37 | else { 38 | resolve(); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'octobercms'; 2 | 3 | type Constructor = new (...args: any[]) => T; 4 | 5 | type ResponseCallback = (data: T, statusCode: number, xhr: XMLHttpRequest) => void; 6 | 7 | type DataResponse = Record; 8 | 9 | export interface ProgressBar { 10 | show: () => void; 11 | hide: () => void; 12 | } 13 | 14 | export interface RequestOptions { 15 | update?: Record; 16 | confirm?: string; 17 | data?: unknown; 18 | query?: unknown; 19 | headers?: Record; 20 | redirect?: string; 21 | beforeUpdate?: ResponseCallback; 22 | afterUpdate?: ResponseCallback; 23 | success?: ResponseCallback; 24 | error?: ResponseCallback; 25 | complete?: () => void; 26 | form?: HTMLElement | string; 27 | flash?: boolean | string; 28 | files?: FormData; 29 | download?: boolean; 30 | bulk?: boolean; 31 | browserValidate?: boolean; 32 | browserTarget?: string; 33 | loading?: string; 34 | progressBar?: boolean; 35 | handleConfirmMessage?: (message: string, promise: Promise) => void; 36 | handleErrorMessage?: (message: string) => void; 37 | handleValidationMessage?: (message: string, fields: Record) => void; 38 | handleFlashMessage?: (message: string, type: string) => void; 39 | handleRedirectResponse?: (url: string) => void; 40 | } 41 | 42 | export interface Deferred { 43 | resolve: (...args) => Deferred; 44 | reject: (...args) => Deferred; 45 | notify: (...args) => Deferred; 46 | abort: () => void; 47 | done: (...args) => Deferred; 48 | fail: (...args) => Deferred; 49 | progress: (...args) => Deferred; 50 | always: (...args) => Deferred; 51 | then: (...args) => Deferred; 52 | promise: (...args) => Deferred; 53 | } 54 | 55 | export interface AjaxEventContext { 56 | el: Document | HTMLElement; 57 | handler: string; 58 | options: RequestOptions; 59 | } 60 | 61 | export interface AjaxBeforeSendEvent extends Event { 62 | detail: { 63 | context: AjaxEventContext; 64 | }; 65 | } 66 | 67 | export interface AjaxUpdateEvent extends Event { 68 | detail: { 69 | context: AjaxEventContext; 70 | data: DataResponse; 71 | responseCode: number; 72 | xhr: XMLHttpRequest; 73 | }; 74 | } 75 | 76 | export interface AjaxBeforeUpdateEvent extends AjaxUpdateEvent { 77 | 78 | } 79 | 80 | export interface AjaxUpdateCompleteEvent extends AjaxUpdateEvent { 81 | 82 | } 83 | 84 | export interface AjaxRequestResponseEvent extends Event { 85 | detail: { 86 | context: AjaxEventContext; 87 | data: DataResponse; 88 | responseCode: number; 89 | xhr: XMLHttpRequest; 90 | }; 91 | } 92 | 93 | export interface AjaxRequestSuccessEvent extends AjaxRequestResponseEvent { 94 | 95 | } 96 | 97 | export interface AjaxRequestErrorEvent extends AjaxRequestResponseEvent { 98 | 99 | } 100 | 101 | export interface AjaxErrorMessageEvent extends Event { 102 | detail: { 103 | message: string; 104 | } 105 | } 106 | 107 | export interface AjaxConfirmMessageEvent extends Event { 108 | detail: { 109 | message: string; 110 | promise: Deferred; 111 | }; 112 | } 113 | 114 | export interface AjaxSetupEvent extends Event { 115 | detail: { 116 | context: AjaxEventContext; 117 | }; 118 | } 119 | 120 | export interface AjaxPromiseEvent extends Event { 121 | detail: { 122 | context: AjaxEventContext; 123 | } 124 | } 125 | 126 | export interface AjaxFailEvent extends AjaxRequestResponseEvent { 127 | 128 | } 129 | 130 | export interface AjaxDoneEvent extends AjaxRequestResponseEvent { 131 | 132 | } 133 | 134 | export interface AjaxAlwaysEvent extends AjaxRequestResponseEvent { 135 | 136 | } 137 | 138 | export interface AjaxInvalidFieldEvent extends Event { 139 | detail: { 140 | element: HTMLElement; 141 | fieldName: string; 142 | errorMsg: string; 143 | isFirst: boolean; 144 | }; 145 | } 146 | 147 | export interface ObserveControlBase { 148 | init: () => void; 149 | connect: () => void; 150 | disconnect: () => void; 151 | } 152 | 153 | declare function ajax(handler: string, options: RequestOptions): void; 154 | declare function request(element: HTMLElement | string, handler: string, options: RequestOptions): void; 155 | declare function parseJson(json: string): void; 156 | declare function flashMsg(options: { text: string, class: string, interval?: number }): void; 157 | declare function useTurbo(): boolean; 158 | declare function visit(location: string, options?: { scroll?: boolean; action: string }): void; 159 | declare function registerControl(id: string, control: Constructor): void; 160 | declare function importControl(id: string): void; 161 | declare function fetchControl(element: HTMLElement | string): ObserveControlBase; 162 | declare function fetchControls(element: HTMLElement | string): ObserveControlBase[]; 163 | declare var progressBar: ProgressBar; 164 | 165 | export { 166 | ajax, 167 | request, 168 | parseJson, 169 | flashMsg, 170 | progressBar, 171 | useTurbo, 172 | visit, 173 | registerControl, 174 | importControl, 175 | fetchControl, 176 | fetchControls 177 | }; 178 | 179 | declare global { 180 | interface Window { 181 | oc: { 182 | ajax: typeof ajax; 183 | request: typeof request; 184 | parseJson: typeof parseJson; 185 | flashMsg: typeof flashMsg; 186 | progressBar?: typeof progressBar; // Optional, only available with extra's 187 | useTurbo?: typeof useTurbo; // Optional, only available with turbo 188 | visit?: typeof visit; // Optional, only available with turbo 189 | ControlBase?: typeof ObserveControlBase; // Optional, only available with observe 190 | registerControl?: typeof registerControl; // Optional, only available with observe 191 | importControl?: typeof importControl; // Optional, only available with observe 192 | fetchControl?: typeof fetchControl; // Optional, only available with observe 193 | fetchControls?: typeof fetchControls; // Optional, only available with observe 194 | }, 195 | } 196 | 197 | interface GlobalEventHandlersEventMap { 198 | 'ajax:before-send': AjaxBeforeSendEvent; 199 | 'ajax:before-update': AjaxBeforeUpdateEvent; 200 | 'ajax:update': AjaxUpdateEvent; 201 | 'ajax:update-complete': AjaxUpdateCompleteEvent; 202 | 'ajax:request-success': AjaxRequestSuccessEvent; 203 | 'ajax:request-error': AjaxRequestErrorEvent; 204 | 'ajax:error-message': AjaxErrorMessageEvent; 205 | 'ajax:confirm-message': AjaxConfirmMessageEvent; 206 | 'ajax:setup': AjaxSetupEvent; 207 | 'ajax:promise': AjaxPromiseEvent; 208 | 'ajax:fail': AjaxFailEvent; 209 | 'ajax:done': AjaxDoneEvent; 210 | 'ajax:always': AjaxAlwaysEvent; 211 | 'ajax:invalid-field': AjaxInvalidFieldEvent; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | plugins: [], 5 | externals: {} 6 | }; 7 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | const mix = require('laravel-mix'); 2 | const webpackConfig = require('./webpack.config'); 3 | 4 | /* 5 | |-------------------------------------------------------------------------- 6 | | Mix Asset Management 7 | |-------------------------------------------------------------------------- 8 | | 9 | | Mix provides a clean, fluent API for defining some Webpack build steps 10 | | for your theme assets. By default, we are compiling the CSS 11 | | file for the application as well as bundling up all the JS files. 12 | | 13 | */ 14 | 15 | mix 16 | .webpackConfig(webpackConfig) 17 | .options({ 18 | manifest: false, 19 | }); 20 | 21 | mix.js('src/framework-bundle.js', 'dist/framework-bundle.min.js'); 22 | mix.js('src/framework-extras.js', 'dist/framework-extras.min.js'); 23 | mix.js('src/framework-turbo.js', 'dist/framework-turbo.min.js'); 24 | mix.js('src/framework.js', 'dist/framework.min.js'); 25 | 26 | if (!mix.inProduction()) { 27 | mix.js('src/framework-bundle.js', 'dist/framework-bundle.js'); 28 | mix.js('src/framework-extras.js', 'dist/framework-extras.js'); 29 | mix.js('src/framework-turbo.js', 'dist/framework-turbo.js'); 30 | mix.js('src/framework.js', 'dist/framework.js'); 31 | } 32 | --------------------------------------------------------------------------------