diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1277f63 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + + + +## [3.0.0] - 2023-07-26 + +- Allow returning Promises and async/await animation functions +- Breaking: the first matching rule is used +- Update for swup 4 compatibility + +## [2.0.0] - 2023-03-13 + +- Switch to microbundle +- Export native ESM module + +## [1.0.5] - 2022-08-21 + +- Bail early if no animation found + +## [1.0.4] - 2022-08-15 + +- Remove reference to global swup instance + +## [1.0.3] - 2019-05-23 + +- Improve how animations are ranked + +## [1.0.2] - 2019-05-13 + +- Fix plugin name + +## [1.0.1] - 2019-05-02 + +- Update readme + +## [1.0.0] - 2019-05-02 + +- Initial release + +[Unreleased]: https://github.com/swup/js-plugin/compare/3.0.0...HEAD + +[3.0.0]: https://github.com/swup/js-plugin/releases/tag/3.0.0 +[2.0.0]: https://github.com/swup/js-plugin/releases/tag/2.0.0 +[1.0.5]: https://github.com/swup/js-plugin/releases/tag/1.0.5 +[1.0.4]: https://github.com/swup/js-plugin/releases/tag/1.0.4 +[1.0.3]: https://github.com/swup/js-plugin/releases/tag/1.0.3 +[1.0.2]: https://github.com/swup/js-plugin/releases/tag/1.0.2 +[1.0.1]: https://github.com/swup/js-plugin/releases/tag/1.0.1 +[1.0.0]: https://github.com/swup/js-plugin/releases/tag/1.0.0 diff --git a/README.md b/README.md new file mode 100755 index 0000000..490bfbe --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +# Swup JS Plugin + +A [swup](https://swup.js.org) plugin for managing animations in JS. + +- Use JavaScript for timing animations instead of CSS +- Successor to the deprecated [swupjs](https://github.com/swup/swupjs) library + +## Installation + +Install the plugin from npm and import it into your bundle. + +```bash +npm install @swup/js-plugin +``` + +```js +import SwupJsPlugin from '@swup/js-plugin'; +``` + +Or include the minified production file from a CDN: + +```html + +``` + +## Usage + +To run this plugin, include an instance in the swup options. + +```js +const swup = new Swup({ + plugins: [ + new SwupJsPlugin({ animations: [ /* your custom animation functions */ ] }) + ] +}); +``` + +## Options + +The plugin expects an `array` of animation objects. +The example below is the default setup and defines two animations, where `out` is the +animation function being executed before the content is being replaced, and `in` is +the animation being executed after the content is replaced: + +```js +{ + animations: [ + { + from: '(.*)', // matches any route + to: '(.*)', // matches any route + out: done => done(), // immediately continues + in: done => done() // immediately continues + } + ] +} +``` + +This is also the fallback animation in case no other matching animations were found. + +Animations are chosen based on the `from` and `to` properties of the object, which are +compared against the current visit (urls of current and next page). +Learn more on [choosing the animation](#choosing-the-animation) below. + +## Animation function + +The animation function is executed for each corresponding animation phase. Inside the animation +function, you manage the animation yourself and signal when it has finished. It receives two +arguments: a `done` function and a `data` object. + +```js +out: (done, data) => { + // Signal the end of the animation by calling done() + // Access info about the animation inside the data argument +} +``` + +### Signaling the end of an animation + +Calling the `done()` function signals to swup that the animation has finished and it can proceed +to the next step: replacing the content or finishing the visit. You can pass along the `done()` +function as a callback to your animation library. The example below will wait for two seconds before replacing the content. + +```js +out: (done) => { + setTimeout(done, 2000); +} +``` + +#### Promises and async/await + +If your animation library returns Promises, you can also return the Promise directly from your +animation function. Swup will consider the animation to be finished when the Promise resolves. +The `done` function is then no longer required. + +```js +out: () => { + return myAnimationLibrary.animate(/* */).then(() => {}); +} +``` + +This also allows `async/await` syntax for convenience. + +```js +out: async () => { + await myAnimationLibrary.animate(/* */); +} +``` + +### Data object + +The second parameter is an object that contains useful data about the animation, such as the visit +object (containing actual before/after routes), the `from` and `to` parameters of the +animation object, and the route params. + +```js +{ + visit: { /* */ }, // swup global visit object + direction: 'in', + from: { + url: '/', + pattern: '(.*)', + params: {} + }, + to: { + url: '/about', + pattern: '(.*)', + params: {} + } +} +``` + +## Examples + +Basic usage examples for a fade transition implemented in popular animation libraries: + +### [Web Animations API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Animations_API) + +```js +{ + from: '(.*)', + to: '(.*)', + in: async () => { + const container = document.querySelector('#swup'); + await container.animate([{ opacity: 0 }, { opacity: 1 }], 500).finished; + }, + out: async () => { + const container = document.querySelector('#swup'); + await container.animate([{ opacity: 1 }, { opacity: 0 }], 500).finished; + } +} +``` + +### [GSAP](https://greensock.com/gsap/) + +```js +{ + from: '(.*)', + to: '(.*)', + out: (done) => { + const container = document.querySelector('#swup'); + container.style.opacity = 1; + gsap.to(container, { opacity: 0, duration: 0.5, onComplete: done }); + }, + in: (done) => { + const container = document.querySelector('#swup'); + container.style.opacity = 0; + gsap.to(container, { opacity: 1, duration: 0.5, onComplete: done }); + } +} +``` + +### [anime.js](https://animejs.com/) + +```js +{ + from: '(.*)', + to: '(.*)', + out: (done) => { + const container = document.querySelector('#swup'); + container.style.opacity = 1; + anime({ targets: container, opacity: 0, duration: 500, complete: done }); + }, + in: (done) => { + const container = document.querySelector('#swup'); + container.style.opacity = 0; + anime({ targets: container, opacity: 1, duration: 500, complete: done }); + } +} +``` + +## Choosing the animation + +As mentioned above, the animation is chosen based on the `from` and `to` properties of the animation object. +Those properties can take several forms: + +- a string (matching a route exactly) +- a regular expression +- A route pattern like `/foo/:bar`) parsed by [path-to-regexp](https://github.com/pillarjs/path-to-regexp) +- a custom animation name taken from the `data-swup-animation` attribute of the clicked link + +The most fitting route is always chosen. +Keep in mind, that two routes can be evaluated as "same fit". +In this case, the first one defined in the options is used, so usually you would like to define the more specific routes first. +See the example below for more info. + +```js +[ + // animation 1 + { from: '/', to: 'custom' }, + // animation 2 + { from: '/', to: '/post' }, + // animation 3 + { from: '/', to: '/post/:id' }, + // animation 4 + { from: '/', to: /pos(.*)/ }, + // animation 5 + { from: '(.*)', to: '(.*)' }, +]; +``` + +- from `/` to `/post` → animation **2** +- from `/` to `/posting` → animation **4** +- from `/` to `/post/12` → animation **3** +- from `/` to `/some-route` → animation **5** +- from `/` to `/post` with `data-swup-animation="custom"` → animation **1** diff --git a/dist/SwupJsPlugin.js b/dist/SwupJsPlugin.js deleted file mode 100644 index 91024b4..0000000 --- a/dist/SwupJsPlugin.js +++ /dev/null @@ -1,751 +0,0 @@ -(function webpackUniversalModuleDefinition(root, factory) { - if(typeof exports === 'object' && typeof module === 'object') - module.exports = factory(); - else if(typeof define === 'function' && define.amd) - define([], factory); - else if(typeof exports === 'object') - exports["SwupJsPlugin"] = factory(); - else - root["SwupJsPlugin"] = factory(); -})(window, function() { -return /******/ (function(modules) { // webpackBootstrap -/******/ // The module cache -/******/ var installedModules = {}; -/******/ -/******/ // The require function -/******/ function __webpack_require__(moduleId) { -/******/ -/******/ // Check if module is in cache -/******/ if(installedModules[moduleId]) { -/******/ return installedModules[moduleId].exports; -/******/ } -/******/ // Create a new module (and put it into the cache) -/******/ var module = installedModules[moduleId] = { -/******/ i: moduleId, -/******/ l: false, -/******/ exports: {} -/******/ }; -/******/ -/******/ // Execute the module function -/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); -/******/ -/******/ // Flag the module as loaded -/******/ module.l = true; -/******/ -/******/ // Return the exports of the module -/******/ return module.exports; -/******/ } -/******/ -/******/ -/******/ // expose the modules object (__webpack_modules__) -/******/ __webpack_require__.m = modules; -/******/ -/******/ // expose the module cache -/******/ __webpack_require__.c = installedModules; -/******/ -/******/ // define getter function for harmony exports -/******/ __webpack_require__.d = function(exports, name, getter) { -/******/ if(!__webpack_require__.o(exports, name)) { -/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter }); -/******/ } -/******/ }; -/******/ -/******/ // define __esModule on exports -/******/ __webpack_require__.r = function(exports) { -/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) { -/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); -/******/ } -/******/ Object.defineProperty(exports, '__esModule', { value: true }); -/******/ }; -/******/ -/******/ // create a fake namespace object -/******/ // mode & 1: value is a module id, require it -/******/ // mode & 2: merge all properties of value into the ns -/******/ // mode & 4: return value when already ns object -/******/ // mode & 8|1: behave like require -/******/ __webpack_require__.t = function(value, mode) { -/******/ if(mode & 1) value = __webpack_require__(value); -/******/ if(mode & 8) return value; -/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value; -/******/ var ns = Object.create(null); -/******/ __webpack_require__.r(ns); -/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value }); -/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key)); -/******/ return ns; -/******/ }; -/******/ -/******/ // getDefaultExport function for compatibility with non-harmony modules -/******/ __webpack_require__.n = function(module) { -/******/ var getter = module && module.__esModule ? -/******/ function getDefault() { return module['default']; } : -/******/ function getModuleExports() { return module; }; -/******/ __webpack_require__.d(getter, 'a', getter); -/******/ return getter; -/******/ }; -/******/ -/******/ // Object.prototype.hasOwnProperty.call -/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; -/******/ -/******/ // __webpack_public_path__ -/******/ __webpack_require__.p = ""; -/******/ -/******/ -/******/ // Load entry module and return exports -/******/ return __webpack_require__(__webpack_require__.s = 0); -/******/ }) -/************************************************************************/ -/******/ ([ -/* 0 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -var _index = __webpack_require__(1); - -var _index2 = _interopRequireDefault(_index); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -module.exports = _index2.default; // this is here for webpack to expose SwupPlugin as window.SwupPlugin - -/***/ }), -/* 1 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; - -var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); - -var _plugin = __webpack_require__(2); - -var _plugin2 = _interopRequireDefault(_plugin); - -var _pathToRegexp = __webpack_require__(3); - -var _pathToRegexp2 = _interopRequireDefault(_pathToRegexp); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } - -function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; } - -function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } - -var JsPlugin = function (_Plugin) { - _inherits(JsPlugin, _Plugin); - - function JsPlugin() { - var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - - _classCallCheck(this, JsPlugin); - - var _this = _possibleConstructorReturn(this, (JsPlugin.__proto__ || Object.getPrototypeOf(JsPlugin)).call(this)); - - _this.name = 'JsPlugin'; - _this.currentAnimation = null; - - _this.getAnimationPromises = function (type) { - var animationIndex = _this.getAnimationIndex(type); - return [_this.createAnimationPromise(animationIndex, type)]; - }; - - _this.createAnimationPromise = function (index, type) { - var currentTransitionRoutes = _this.swup.transition; - var animation = _this.options[index]; - - if (!(animation && animation[type])) { - console.warn('No animation found'); - return Promise.resolve(); - } - - return new Promise(function (resolve) { - animation[type](resolve, { - paramsFrom: animation.regFrom.exec(currentTransitionRoutes.from), - paramsTo: animation.regTo.exec(currentTransitionRoutes.to), - transition: currentTransitionRoutes, - from: animation.from, - to: animation.to - }); - }); - }; - - _this.getAnimationIndex = function (type) { - // already saved from out animation - if (type === 'in') { - return _this.currentAnimation; - } - - var animations = _this.options; - var animationIndex = 0; - var topRating = 0; - - Object.keys(animations).forEach(function (key, index) { - var animation = animations[key]; - var rating = _this.rateAnimation(animation); - - if (rating >= topRating) { - animationIndex = index; - topRating = rating; - } - }); - - _this.currentAnimation = animationIndex; - return _this.currentAnimation; - }; - - _this.rateAnimation = function (animation) { - var currentTransitionRoutes = _this.swup.transition; - var rating = 0; - - // run regex - var fromMatched = animation.regFrom.test(currentTransitionRoutes.from); - var toMatched = animation.regTo.test(currentTransitionRoutes.to); - - // check if regex passes - rating += fromMatched ? 1 : 0; - rating += toMatched ? 1 : 0; - - // beat all other if custom parameter fits - rating += fromMatched && animation.to === currentTransitionRoutes.custom ? 2 : 0; - - return rating; - }; - - var defaultOptions = [{ - from: '(.*)', - to: '(.*)', - out: function out(next) { - return next(); - }, - in: function _in(next) { - return next(); - } - }]; - - _this.options = _extends({}, defaultOptions, options); - - _this.generateRegex(); - return _this; - } - - _createClass(JsPlugin, [{ - key: 'mount', - value: function mount() { - var swup = this.swup; - - swup._getAnimationPromises = swup.getAnimationPromises; - swup.getAnimationPromises = this.getAnimationPromises; - } - }, { - key: 'unmount', - value: function unmount() { - var swup = this.swup; - - swup.getAnimationPromises = swup._getAnimationPromises; - swup._getAnimationPromises = null; - } - }, { - key: 'generateRegex', - value: function generateRegex() { - var _this2 = this; - - var isRegex = function isRegex(str) { - return str instanceof RegExp; - }; - - this.options = Object.keys(this.options).map(function (key) { - return _extends({}, _this2.options[key], { - regFrom: isRegex(_this2.options[key].from) ? _this2.options[key].from : (0, _pathToRegexp2.default)(_this2.options[key].from), - regTo: isRegex(_this2.options[key].to) ? _this2.options[key].to : (0, _pathToRegexp2.default)(_this2.options[key].to) - }); - }); - } - }]); - - return JsPlugin; -}(_plugin2.default); - -exports.default = JsPlugin; - -/***/ }), -/* 2 */ -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -Object.defineProperty(exports, "__esModule", { - value: true -}); - -var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); - -function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } - -var Plugin = function () { - function Plugin() { - _classCallCheck(this, Plugin); - - this.isSwupPlugin = true; - } - - _createClass(Plugin, [{ - key: "mount", - value: function mount() { - // this is mount method rewritten by class extending - // and is executed when swup is enabled with plugin - } - }, { - key: "unmount", - value: function unmount() { - // this is unmount method rewritten by class extending - // and is executed when swup with plugin is disabled - } - }, { - key: "_beforeMount", - value: function _beforeMount() { - // here for any future hidden auto init - } - }, { - key: "_afterUnmount", - value: function _afterUnmount() {} - // here for any future hidden auto-cleanup - - - // this is here so we can tell if plugin was created by extending this class - - }]); - - return Plugin; -}(); - -exports.default = Plugin; - -/***/ }), -/* 3 */ -/***/ (function(module, exports) { - -/** - * Expose `pathToRegexp`. - */ -module.exports = pathToRegexp -module.exports.match = match -module.exports.regexpToFunction = regexpToFunction -module.exports.parse = parse -module.exports.compile = compile -module.exports.tokensToFunction = tokensToFunction -module.exports.tokensToRegExp = tokensToRegExp - -/** - * Default configs. - */ -var DEFAULT_DELIMITER = '/' - -/** - * The main path matching regexp utility. - * - * @type {RegExp} - */ -var PATH_REGEXP = new RegExp([ - // Match escaped characters that would otherwise appear in future matches. - // This allows the user to escape special characters that won't transform. - '(\\\\.)', - // Match Express-style parameters and un-named parameters with a prefix - // and optional suffixes. Matches appear as: - // - // ":test(\\d+)?" => ["test", "\d+", undefined, "?"] - // "(\\d+)" => [undefined, undefined, "\d+", undefined] - '(?:\\:(\\w+)(?:\\(((?:\\\\.|[^\\\\()])+)\\))?|\\(((?:\\\\.|[^\\\\()])+)\\))([+*?])?' -].join('|'), 'g') - -/** - * Parse a string for the raw tokens. - * - * @param {string} str - * @param {Object=} options - * @return {!Array} - */ -function parse (str, options) { - var tokens = [] - var key = 0 - var index = 0 - var path = '' - var defaultDelimiter = (options && options.delimiter) || DEFAULT_DELIMITER - var whitelist = (options && options.whitelist) || undefined - var pathEscaped = false - var res - - while ((res = PATH_REGEXP.exec(str)) !== null) { - var m = res[0] - var escaped = res[1] - var offset = res.index - path += str.slice(index, offset) - index = offset + m.length - - // Ignore already escaped sequences. - if (escaped) { - path += escaped[1] - pathEscaped = true - continue - } - - var prev = '' - var name = res[2] - var capture = res[3] - var group = res[4] - var modifier = res[5] - - if (!pathEscaped && path.length) { - var k = path.length - 1 - var c = path[k] - var matches = whitelist ? whitelist.indexOf(c) > -1 : true - - if (matches) { - prev = c - path = path.slice(0, k) - } - } - - // Push the current path onto the tokens. - if (path) { - tokens.push(path) - path = '' - pathEscaped = false - } - - var repeat = modifier === '+' || modifier === '*' - var optional = modifier === '?' || modifier === '*' - var pattern = capture || group - var delimiter = prev || defaultDelimiter - - tokens.push({ - name: name || key++, - prefix: prev, - delimiter: delimiter, - optional: optional, - repeat: repeat, - pattern: pattern - ? escapeGroup(pattern) - : '[^' + escapeString(delimiter === defaultDelimiter ? delimiter : (delimiter + defaultDelimiter)) + ']+?' - }) - } - - // Push any remaining characters. - if (path || index < str.length) { - tokens.push(path + str.substr(index)) - } - - return tokens -} - -/** - * Compile a string to a template function for the path. - * - * @param {string} str - * @param {Object=} options - * @return {!function(Object=, Object=)} - */ -function compile (str, options) { - return tokensToFunction(parse(str, options), options) -} - -/** - * Create path match function from `path-to-regexp` spec. - */ -function match (str, options) { - var keys = [] - var re = pathToRegexp(str, keys, options) - return regexpToFunction(re, keys) -} - -/** - * Create a path match function from `path-to-regexp` output. - */ -function regexpToFunction (re, keys) { - return function (pathname, options) { - var m = re.exec(pathname) - if (!m) return false - - var path = m[0] - var index = m.index - var params = {} - var decode = (options && options.decode) || decodeURIComponent - - for (var i = 1; i < m.length; i++) { - if (m[i] === undefined) continue - - var key = keys[i - 1] - - if (key.repeat) { - params[key.name] = m[i].split(key.delimiter).map(function (value) { - return decode(value, key) - }) - } else { - params[key.name] = decode(m[i], key) - } - } - - return { path: path, index: index, params: params } - } -} - -/** - * Expose a method for transforming tokens into the path function. - */ -function tokensToFunction (tokens, options) { - // Compile all the tokens into regexps. - var matches = new Array(tokens.length) - - // Compile all the patterns before compilation. - for (var i = 0; i < tokens.length; i++) { - if (typeof tokens[i] === 'object') { - matches[i] = new RegExp('^(?:' + tokens[i].pattern + ')$', flags(options)) - } - } - - return function (data, options) { - var path = '' - var encode = (options && options.encode) || encodeURIComponent - var validate = options ? options.validate !== false : true - - for (var i = 0; i < tokens.length; i++) { - var token = tokens[i] - - if (typeof token === 'string') { - path += token - continue - } - - var value = data ? data[token.name] : undefined - var segment - - if (Array.isArray(value)) { - if (!token.repeat) { - throw new TypeError('Expected "' + token.name + '" to not repeat, but got array') - } - - if (value.length === 0) { - if (token.optional) continue - - throw new TypeError('Expected "' + token.name + '" to not be empty') - } - - for (var j = 0; j < value.length; j++) { - segment = encode(value[j], token) - - if (validate && !matches[i].test(segment)) { - throw new TypeError('Expected all "' + token.name + '" to match "' + token.pattern + '"') - } - - path += (j === 0 ? token.prefix : token.delimiter) + segment - } - - continue - } - - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { - segment = encode(String(value), token) - - if (validate && !matches[i].test(segment)) { - throw new TypeError('Expected "' + token.name + '" to match "' + token.pattern + '", but got "' + segment + '"') - } - - path += token.prefix + segment - continue - } - - if (token.optional) continue - - throw new TypeError('Expected "' + token.name + '" to be ' + (token.repeat ? 'an array' : 'a string')) - } - - return path - } -} - -/** - * Escape a regular expression string. - * - * @param {string} str - * @return {string} - */ -function escapeString (str) { - return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1') -} - -/** - * Escape the capturing group by escaping special characters and meaning. - * - * @param {string} group - * @return {string} - */ -function escapeGroup (group) { - return group.replace(/([=!:$/()])/g, '\\$1') -} - -/** - * Get the flags for a regexp from the options. - * - * @param {Object} options - * @return {string} - */ -function flags (options) { - return options && options.sensitive ? '' : 'i' -} - -/** - * Pull out keys from a regexp. - * - * @param {!RegExp} path - * @param {Array=} keys - * @return {!RegExp} - */ -function regexpToRegexp (path, keys) { - if (!keys) return path - - // Use a negative lookahead to match only capturing groups. - var groups = path.source.match(/\((?!\?)/g) - - if (groups) { - for (var i = 0; i < groups.length; i++) { - keys.push({ - name: i, - prefix: null, - delimiter: null, - optional: false, - repeat: false, - pattern: null - }) - } - } - - return path -} - -/** - * Transform an array into a regexp. - * - * @param {!Array} path - * @param {Array=} keys - * @param {Object=} options - * @return {!RegExp} - */ -function arrayToRegexp (path, keys, options) { - var parts = [] - - for (var i = 0; i < path.length; i++) { - parts.push(pathToRegexp(path[i], keys, options).source) - } - - return new RegExp('(?:' + parts.join('|') + ')', flags(options)) -} - -/** - * Create a path regexp from string input. - * - * @param {string} path - * @param {Array=} keys - * @param {Object=} options - * @return {!RegExp} - */ -function stringToRegexp (path, keys, options) { - return tokensToRegExp(parse(path, options), keys, options) -} - -/** - * Expose a function for taking tokens and returning a RegExp. - * - * @param {!Array} tokens - * @param {Array=} keys - * @param {Object=} options - * @return {!RegExp} - */ -function tokensToRegExp (tokens, keys, options) { - options = options || {} - - var strict = options.strict - var start = options.start !== false - var end = options.end !== false - var delimiter = options.delimiter || DEFAULT_DELIMITER - var endsWith = [].concat(options.endsWith || []).map(escapeString).concat('$').join('|') - var route = start ? '^' : '' - - // Iterate over the tokens and create our regexp string. - for (var i = 0; i < tokens.length; i++) { - var token = tokens[i] - - if (typeof token === 'string') { - route += escapeString(token) - } else { - var capture = token.repeat - ? '(?:' + token.pattern + ')(?:' + escapeString(token.delimiter) + '(?:' + token.pattern + '))*' - : token.pattern - - if (keys) keys.push(token) - - if (token.optional) { - if (!token.prefix) { - route += '(' + capture + ')?' - } else { - route += '(?:' + escapeString(token.prefix) + '(' + capture + '))?' - } - } else { - route += escapeString(token.prefix) + '(' + capture + ')' - } - } - } - - if (end) { - if (!strict) route += '(?:' + escapeString(delimiter) + ')?' - - route += endsWith === '$' ? '$' : '(?=' + endsWith + ')' - } else { - var endToken = tokens[tokens.length - 1] - var isEndDelimited = typeof endToken === 'string' - ? endToken[endToken.length - 1] === delimiter - : endToken === undefined - - if (!strict) route += '(?:' + escapeString(delimiter) + '(?=' + endsWith + '))?' - if (!isEndDelimited) route += '(?=' + escapeString(delimiter) + '|' + endsWith + ')' - } - - return new RegExp(route, flags(options)) -} - -/** - * Normalize the given path string, returning a regular expression. - * - * An empty array can be passed in for the keys, which will hold the - * placeholder key descriptions. For example, using `/user/:id`, `keys` will - * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. - * - * @param {(string|RegExp|Array)} path - * @param {Array=} keys - * @param {Object=} options - * @return {!RegExp} - */ -function pathToRegexp (path, keys, options) { - if (path instanceof RegExp) { - return regexpToRegexp(path, keys) - } - - if (Array.isArray(path)) { - return arrayToRegexp(/** @type {!Array} */ (path), keys, options) - } - - return stringToRegexp(/** @type {string} */ (path), keys, options) -} - - -/***/ }) -/******/ ]); -}); \ No newline at end of file diff --git a/dist/SwupJsPlugin.min.js b/dist/SwupJsPlugin.min.js deleted file mode 100644 index 050ca10..0000000 --- a/dist/SwupJsPlugin.min.js +++ /dev/null @@ -1 +0,0 @@ -(function e(t,r){if(typeof exports==="object"&&typeof module==="object")module.exports=r();else if(typeof define==="function"&&define.amd)define([],r);else if(typeof exports==="object")exports["SwupJsPlugin"]=r();else t["SwupJsPlugin"]=r()})(window,function(){return function(e){var t={};function r(n){if(t[n]){return t[n].exports}var o=t[n]={i:n,l:false,exports:{}};e[n].call(o.exports,o,o.exports,r);o.l=true;return o.exports}r.m=e;r.c=t;r.d=function(e,t,n){if(!r.o(e,t)){Object.defineProperty(e,t,{enumerable:true,get:n})}};r.r=function(e){if(typeof Symbol!=="undefined"&&Symbol.toStringTag){Object.defineProperty(e,Symbol.toStringTag,{value:"Module"})}Object.defineProperty(e,"__esModule",{value:true})};r.t=function(e,t){if(t&1)e=r(e);if(t&8)return e;if(t&4&&typeof e==="object"&&e&&e.__esModule)return e;var n=Object.create(null);r.r(n);Object.defineProperty(n,"default",{enumerable:true,value:e});if(t&2&&typeof e!="string")for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n};r.n=function(e){var t=e&&e.__esModule?function t(){return e["default"]}:function t(){return e};r.d(t,"a",t);return t};r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};r.p="";return r(r.s=0)}([function(e,t,r){"use strict";var n=r(1);var o=i(n);function i(e){return e&&e.__esModule?e:{default:e}}e.exports=o.default},function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:true});var n=Object.assign||function(e){for(var t=1;t0&&arguments[0]!==undefined?arguments[0]:{};c(this,t);var r=l(this,(t.__proto__||Object.getPrototypeOf(t)).call(this));r.name="JsPlugin";r.currentAnimation=null;r.getAnimationPromises=function(e){var t=r.getAnimationIndex(e);return[r.createAnimationPromise(t,e)]};r.createAnimationPromise=function(e,t){var n=r.swup.transition;var o=r.options[e];if(!(o&&o[t])){console.warn("No animation found");return Promise.resolve()}return new Promise(function(e){o[t](e,{paramsFrom:o.regFrom.exec(n.from),paramsTo:o.regTo.exec(n.to),transition:n,from:o.from,to:o.to})})};r.getAnimationIndex=function(e){if(e==="in"){return r.currentAnimation}var t=r.options;var n=0;var o=0;Object.keys(t).forEach(function(e,i){var a=t[e];var u=r.rateAnimation(a);if(u>=o){n=i;o=u}});r.currentAnimation=n;return r.currentAnimation};r.rateAnimation=function(e){var t=r.swup.transition;var n=0;var o=e.regFrom.test(t.from);var i=e.regTo.test(t.to);n+=o?1:0;n+=i?1:0;n+=o&&e.to===t.custom?2:0;return n};var o=[{from:"(.*)",to:"(.*)",out:function e(t){return t()},in:function e(t){return t()}}];r.options=n({},o,e);r.generateRegex();return r}o(t,[{key:"mount",value:function e(){var t=this.swup;t._getAnimationPromises=t.getAnimationPromises;t.getAnimationPromises=this.getAnimationPromises}},{key:"unmount",value:function e(){var t=this.swup;t.getAnimationPromises=t._getAnimationPromises;t._getAnimationPromises=null}},{key:"generateRegex",value:function e(){var t=this;var r=function e(t){return t instanceof RegExp};this.options=Object.keys(this.options).map(function(e){return n({},t.options[e],{regFrom:r(t.options[e].from)?t.options[e].from:(0,f.default)(t.options[e].from),regTo:r(t.options[e].to)?t.options[e].to:(0,f.default)(t.options[e].to)})})}}]);return t}(a.default);t.default=v},function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:true});var n=function(){function e(e,t){for(var r=0;r-1:true;if(_){y=j;u=u.slice(0,P)}}if(u){o.push(u);u="";p=false}var O=w==="+"||w==="*";var A=w==="?"||w==="*";var E=b||x;var T=y||f;o.push({name:h||i++,prefix:y,delimiter:T,optional:A,repeat:O,pattern:E?c(E):"[^"+s(T===f?T:T+f)+"]+?"})}if(u||a -``` - -## Usage - -To run this plugin, include an instance in the swup options. - -```javascript -const swup = new Swup({ - plugins: [new SwupJsPlugin([ - // your custom transition objects - ])] -}); -``` - -## Options - -The plugin expects a single argument in the form of an `array` of animation objects. -The example below is the default setup and defines two animations, where `out` is the -animation (function) being executed before the content is being replaced, and `in` is -the animation being executed after the content is replaced: - -```javascript -const options = [ - { - from: '(.*)', // matches any route - to: '(.*)', // matches any route - out: next => next(), // immediately continues - in: next => next() // immediately continues - } -]; -``` - -This is also the animation object that swup will fall-back to in case no other fitting -animation object is found. - -Animations are chosen based on the `from` and `to` properties of the object, which are -compared against the current transition (routes of current and next page). -More on that [here](#choosing-the-animation). - -## Animation Function - -The animation function receives two parameters: -- The `next()` function -- an object that contains information about the current animation. - -The `next()` function must be called once and serves as an indicator that the animation -is done and swup can proceed with replacing the content. -In a real world example, `next()` would be called as a callback of the animation. -By default no animation is being executed and `next()` is called right away. - -The second parameter is an object that contains some useful data, like the transition -object (containing actual before/after routes), the `from` and `to` parameters of the -animation object, and the result of executing the Regex with the routes (`array`). - -In the example below, the `next` function is called after two seconds, -which means that swup would wait at least two seconds (or any time necessary -to load the new page content), before continuing to replacing the content. - -```javascript -///... -out: (next) => { - setTimeout(next, 2000); -}; -// ... -``` - -Basic usage with tools like [GSAP](https://greensock.com/gsap/) would look something like this: - -```javascript -const options = [ - { - from: '(.*)', - to: '(.*)', - in: (next, infos) => { - document.querySelector('#swup').style.opacity = 0; - gsap.to(document.querySelector('#swup'), { - duration: 0.5, - opacity: 1, - onComplete: next - }); - }, - out: (next, infos) => { - document.querySelector('#swup').style.opacity = 1; - gsap.to(document.querySelector('#swup'), 0.5, { - duration: 0.5, - opacity: 0, - onComplete: next - }); - } - } -]; - -const swup = new Swup({ - plugins: [new SwupJsPlugin(options)] -}); -``` - -## Choosing the animation - -As mentioned above, the animation is chosen based on the `from` and `to` properties of the animation object. -Those properties can take several forms: - -- A String (matching a route exactly). -- A Regex. -- A Path route definition which you may know from things like [Express](https://expressjs.com/) (eg. `/foo/:bar`). The [Path-to-RegExp](https://github.com/pillarjs/path-to-regexp) library is used for this purpose, so refer to their documentation. -- A String of custom transitions (taken from the `data-swup-transition` attribute of the clicked link). - -The most fitting route is always chosen. -Keep in mind, that two routes can be evaluated as "same fit". -In this case, the later one defined in the options is used, so usually you would like to define the more specific routes later. -See the example below for more info. - -```javascript -[ - // animation 1 - { from: '(.*)', to: '(.*)' }, - - // animation 2 - { from: '/', to: /pos(.*)/ }, - - // animation 3 - { from: '/', to: '/post/:id' }, - - // animation 4 - { from: '/', to: '/post' }, - - // animation 5 - { from: '/', to: 'custom-transition' } -]; -``` - -- from `/` to `/post` → animation **4** -- from `/` to `/posting` → animation **2** -- from `/` to `/post/12` → animation **3** -- from `/` to `/some-route` → animation **1** -- from `/` to `/post` with click having `data-swup-transition="custom-transition"` → animation **5** diff --git a/src/index.js b/src/index.js index 990d845..31112f3 100755 --- a/src/index.js +++ b/src/index.js @@ -1,124 +1,131 @@ import Plugin from '@swup/plugin'; -import pathToRegexp from 'path-to-regexp'; +import { matchPath, isPromise } from 'swup'; -export default class JsPlugin extends Plugin { - name = 'JsPlugin'; +export default class SwupJsPlugin extends Plugin { + name = 'SwupJsPlugin'; - currentAnimation = null; + requires = { swup: '>=4' }; - constructor(options = {}) { - super(); - const defaultOptions = [ + defaults = { + animations: [ { from: '(.*)', to: '(.*)', - out: (next) => next(), - in: (next) => next() + out: (done) => done(), + in: (done) => done() } - ]; + ] + }; - this.options = { - ...defaultOptions, - ...options - }; + animations = []; - this.generateRegex(); - } + constructor(options = {}) { + super(); - mount() { - const swup = this.swup; + // Backward compatibility + if (Array.isArray(options)) { + options = { animations: options }; + } - swup._getAnimationPromises = swup.getAnimationPromises; - swup.getAnimationPromises = this.getAnimationPromises; + this.options = { ...this.defaults, ...options }; + this.animations = this.compileAnimations(); } - unmount() { - const swup = this.swup; - - swup.getAnimationPromises = swup._getAnimationPromises; - swup._getAnimationPromises = null; + mount() { + this.replace('animation:in:await', this.awaitInAnimation, { priority: -1 }); + this.replace('animation:out:await', this.awaitOutAnimation, { priority: -1 }); } - generateRegex() { - const isRegex = (str) => str instanceof RegExp; - - this.options = Object.keys(this.options).map((key) => { - return { - ...this.options[key], - regFrom: isRegex(this.options[key].from) - ? this.options[key].from - : pathToRegexp(this.options[key].from), - regTo: isRegex(this.options[key].to) - ? this.options[key].to - : pathToRegexp(this.options[key].to) - }; + // Compile path patterns to match functions and transitions + compileAnimations() { + return this.options.animations.map((animation) => { + const matchesFrom = matchPath(animation.from, this.options.matchOptions); + const matchesTo = matchPath(animation.to, this.options.matchOptions); + return { ...animation, matchesFrom, matchesTo }; }); } - getAnimationPromises = (type) => { - const animationIndex = this.getAnimationIndex(type); - return [this.createAnimationPromise(animationIndex, type)]; - }; + async awaitInAnimation(visit, { skip }) { + if (skip) return; + const animation = this.getBestAnimationMatch(visit); + await this.createAnimationPromise(animation, visit, 'in'); + } - createAnimationPromise = (index, type) => { - const currentTransitionRoutes = this.swup.transition; - const animation = this.options[index]; + async awaitOutAnimation(visit, { skip }) { + if (skip) return; + const animation = this.getBestAnimationMatch(visit); + await this.createAnimationPromise(animation, visit, 'out'); + } - if (!(animation && animation[type])) { + createAnimationPromise(animation, visit, direction) { + const animationFn = animation?.[direction]; + if (!animationFn) { console.warn('No animation found'); return Promise.resolve(); } + const matchFrom = animation.matchesFrom(visit.from.url); + const matchTo = animation.matchesTo(visit.to.url); + + const data = { + visit, + direction, + from: { + url: visit.from.url, + pattern: animation.from, + params: matchFrom?.params + }, + to: { + url: visit.to.url, + pattern: animation.to, + params: matchTo?.params + } + }; + return new Promise((resolve) => { - animation[type](resolve, { - paramsFrom: animation.regFrom.exec(currentTransitionRoutes.from), - paramsTo: animation.regTo.exec(currentTransitionRoutes.to), - transition: currentTransitionRoutes, - from: animation.from, - to: animation.to - }); + const result = animationFn(resolve, data); + if (isPromise(result)) { + result.then(resolve); + } }); - }; - - getAnimationIndex = (type) => { - // already saved from out animation - if (type === 'in') { - return this.currentAnimation; - } + } - const animations = this.options; - let animationIndex = 0; + getBestAnimationMatch(visit) { let topRating = 0; - Object.keys(animations).forEach((key, index) => { - const animation = animations[key]; - const rating = this.rateAnimation(animation); - + return this.animations.reduceRight((bestMatch, animation) => { + const rating = this.rateAnimation(visit, animation); if (rating >= topRating) { - animationIndex = index; topRating = rating; + return animation; + } else { + return bestMatch; } - }); + }, null); + } - this.currentAnimation = animationIndex; - return this.currentAnimation; - }; + rateAnimation(visit, animation) { + const from = visit.from.url; + const to = visit.to.url; + const name = visit.animation.name; - rateAnimation = (animation) => { - const currentTransitionRoutes = this.swup.transition; let rating = 0; - // run regex - const fromMatched = animation.regFrom.test(currentTransitionRoutes.from); - const toMatched = animation.regTo.test(currentTransitionRoutes.to); - - // check if regex passes - rating += fromMatched ? 1 : 0; - rating += toMatched ? 1 : 0; + // check if route patterns match + const fromMatched = animation.matchesFrom(from); + const toMatched = animation.matchesTo(to); + if (fromMatched) { + rating += 1; + } + if (toMatched) { + rating += 1; + } - // beat all other if custom parameter fits - rating += fromMatched && animation.to === currentTransitionRoutes.custom ? 2 : 0; + // beat all others if custom name fits + if (fromMatched && animation.to === name) { + rating += 2; + } return rating; - }; + } }