diff --git a/Gemfile.lock b/Gemfile.lock
index 6f64ebab..dd6bbe2c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -2,6 +2,7 @@ PATH
remote: .
specs:
hotwire_spark (0.1.0)
+ faye-websocket
listen
rails (>= 8.0.0)
turbo-rails
@@ -89,9 +90,13 @@ GEM
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
- date (3.4.0)
+ date (3.4.1)
drb (2.2.1)
erubi (1.13.0)
+ eventmachine (1.2.7)
+ faye-websocket (0.11.3)
+ eventmachine (>= 0.12.0)
+ websocket-driver (>= 0.5.1)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
globalid (1.2.1)
@@ -102,15 +107,16 @@ GEM
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
- io-console (0.7.2)
+ io-console (0.8.0)
irb (1.14.1)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json (2.8.2)
+ json (2.9.0)
+ language_server-protocol (3.17.0.3)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
- logger (1.6.1)
+ logger (1.6.2)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@@ -121,7 +127,7 @@ GEM
net-smtp
marcel (1.0.4)
mini_mime (1.1.5)
- minitest (5.25.1)
+ minitest (5.25.4)
net-imap (0.5.1)
date
net-protocol
@@ -132,9 +138,9 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
- nokogiri (1.16.7-arm64-darwin)
+ nokogiri (1.16.8-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.16.7-x86_64-linux)
+ nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4)
parallel (1.26.3)
parser (3.3.6.0)
@@ -145,9 +151,10 @@ GEM
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
- psych (5.2.0)
+ psych (5.2.1)
+ date
stringio
- puma (6.4.3)
+ puma (6.5.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.8)
@@ -175,9 +182,9 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.0)
+ rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
- nokogiri (~> 1.14)
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.0)
actionpack (= 8.0.0)
activesupport (= 8.0.0)
@@ -193,25 +200,24 @@ GEM
ffi (~> 1.0)
rdoc (6.8.1)
psych (>= 4.0.0)
- regexp_parser (2.9.2)
- reline (0.5.11)
+ regexp_parser (2.9.3)
+ reline (0.5.12)
io-console (~> 0.5)
- rexml (3.3.9)
- rubocop (1.52.1)
+ rubocop (1.69.1)
json (~> 2.3)
+ language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
- parser (>= 3.2.2.3)
+ parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 1.8, < 3.0)
- rexml (>= 3.2.5, < 4.0)
- rubocop-ast (>= 1.28.0, < 2.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
- unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.36.1)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.36.2)
parser (>= 3.3.1.0)
- rubocop-minitest (0.34.5)
- rubocop (>= 1.39, < 2.0)
- rubocop-ast (>= 1.30.0, < 2.0)
+ rubocop-minitest (0.36.0)
+ rubocop (>= 1.61, < 2.0)
+ rubocop-ast (>= 1.31.1, < 2.0)
rubocop-performance (1.23.0)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
@@ -226,14 +232,14 @@ GEM
rubocop-performance
rubocop-rails
ruby-progressbar (1.13.0)
- securerandom (0.3.2)
+ securerandom (0.4.0)
solid_cable (3.0.2)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
- sqlite3 (2.2.0-arm64-darwin)
- sqlite3 (2.2.0-x86_64-linux-gnu)
+ sqlite3 (2.4.0-arm64-darwin)
+ sqlite3 (2.4.0-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.2)
@@ -244,7 +250,9 @@ GEM
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- unicode-display_width (2.6.0)
+ unicode-display_width (3.1.2)
+ unicode-emoji (~> 4.0, >= 4.0.4)
+ unicode-emoji (4.0.4)
uri (1.0.2)
useragent (0.16.10)
websocket-driver (0.7.6)
diff --git a/app/assets/javascripts/hotwire_spark.js b/app/assets/javascripts/hotwire_spark.js
index 141a3ef0..49ff7fe9 100644
--- a/app/assets/javascripts/hotwire_spark.js
+++ b/app/assets/javascripts/hotwire_spark.js
@@ -1,10747 +1,3175 @@
(function (exports) {
- 'use strict';
-
- /*!
- Turbo 8.0.12
- Copyright © 2024 37signals LLC
- */
- /**
- * The MIT License (MIT)
- *
- * Copyright (c) 2019 Javan Makhmali
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in
- * all copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
- * THE SOFTWARE.
- */
-
- (function (prototype) {
- if (typeof prototype.requestSubmit == "function") return
-
- prototype.requestSubmit = function (submitter) {
- if (submitter) {
- validateSubmitter(submitter, this);
- submitter.click();
- } else {
- submitter = document.createElement("input");
- submitter.type = "submit";
- submitter.hidden = true;
- this.appendChild(submitter);
- submitter.click();
- this.removeChild(submitter);
- }
- };
-
- function validateSubmitter(submitter, form) {
- submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'");
- submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button");
- submitter.form == form ||
- raise(DOMException, "The specified element is not owned by this form element", "NotFoundError");
- }
-
- function raise(errorConstructor, message, name) {
- throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name)
- }
- })(HTMLFormElement.prototype);
-
- const submittersByForm = new WeakMap();
-
- function findSubmitterFromClickTarget(target) {
- const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
- const candidate = element ? element.closest("input, button") : null;
- return candidate?.type == "submit" ? candidate : null
- }
-
- function clickCaptured(event) {
- const submitter = findSubmitterFromClickTarget(event.target);
-
- if (submitter && submitter.form) {
- submittersByForm.set(submitter.form, submitter);
- }
- }
-
- (function () {
- if ("submitter" in Event.prototype) return
-
- let prototype = window.Event.prototype;
- // Certain versions of Safari 15 have a bug where they won't
- // populate the submitter. This hurts TurboDrive's enable/disable detection.
- // See https://bugs.webkit.org/show_bug.cgi?id=229660
- if ("SubmitEvent" in window) {
- const prototypeOfSubmitEvent = window.SubmitEvent.prototype;
-
- if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) {
- prototype = prototypeOfSubmitEvent;
- } else {
- return // polyfill not needed
- }
- }
-
- addEventListener("click", clickCaptured, true);
-
- Object.defineProperty(prototype, "submitter", {
- get() {
- if (this.type == "submit" && this.target instanceof HTMLFormElement) {
- return submittersByForm.get(this.target)
- }
- }
- });
- })();
-
- const FrameLoadingStyle = {
- eager: "eager",
- lazy: "lazy"
- };
-
- /**
- * Contains a fragment of HTML which is updated based on navigation within
- * it (e.g. via links or form submissions).
- *
- * @customElement turbo-frame
- * @example
- *
- *
- * Show all expanded messages in this frame.
- *
- *
- *
- *
- */
- class FrameElement extends HTMLElement {
- static delegateConstructor = undefined
-
- loaded = Promise.resolve()
-
- static get observedAttributes() {
- return ["disabled", "loading", "src"]
- }
-
- constructor() {
- super();
- this.delegate = new FrameElement.delegateConstructor(this);
- }
-
- connectedCallback() {
- this.delegate.connect();
- }
-
- disconnectedCallback() {
- this.delegate.disconnect();
- }
-
- reload() {
- return this.delegate.sourceURLReloaded()
- }
-
- attributeChangedCallback(name) {
- if (name == "loading") {
- this.delegate.loadingStyleChanged();
- } else if (name == "src") {
- this.delegate.sourceURLChanged();
- } else if (name == "disabled") {
- this.delegate.disabledChanged();
- }
- }
-
- /**
- * Gets the URL to lazily load source HTML from
- */
- get src() {
- return this.getAttribute("src")
- }
-
- /**
- * Sets the URL to lazily load source HTML from
- */
- set src(value) {
- if (value) {
- this.setAttribute("src", value);
- } else {
- this.removeAttribute("src");
- }
- }
-
- /**
- * Gets the refresh mode for the frame.
- */
- get refresh() {
- return this.getAttribute("refresh")
- }
-
- /**
- * Sets the refresh mode for the frame.
- */
- set refresh(value) {
- if (value) {
- this.setAttribute("refresh", value);
- } else {
- this.removeAttribute("refresh");
- }
- }
-
- get shouldReloadWithMorph() {
- return this.src && this.refresh === "morph"
- }
-
- /**
- * Determines if the element is loading
- */
- get loading() {
- return frameLoadingStyleFromString(this.getAttribute("loading") || "")
- }
-
- /**
- * Sets the value of if the element is loading
- */
- set loading(value) {
- if (value) {
- this.setAttribute("loading", value);
- } else {
- this.removeAttribute("loading");
- }
- }
-
- /**
- * Gets the disabled state of the frame.
- *
- * If disabled, no requests will be intercepted by the frame.
- */
- get disabled() {
- return this.hasAttribute("disabled")
- }
-
- /**
- * Sets the disabled state of the frame.
- *
- * If disabled, no requests will be intercepted by the frame.
- */
- set disabled(value) {
- if (value) {
- this.setAttribute("disabled", "");
- } else {
- this.removeAttribute("disabled");
- }
- }
-
- /**
- * Gets the autoscroll state of the frame.
- *
- * If true, the frame will be scrolled into view automatically on update.
- */
- get autoscroll() {
- return this.hasAttribute("autoscroll")
- }
-
- /**
- * Sets the autoscroll state of the frame.
- *
- * If true, the frame will be scrolled into view automatically on update.
- */
- set autoscroll(value) {
- if (value) {
- this.setAttribute("autoscroll", "");
- } else {
- this.removeAttribute("autoscroll");
- }
- }
+ 'use strict';
+
+ // base IIFE to define idiomorph
+ var Idiomorph = (function () {
+
+ //=============================================================================
+ // AND NOW IT BEGINS...
+ //=============================================================================
+ let EMPTY_SET = new Set();
+
+ // default configuration values, updatable by users now
+ let defaults = {
+ morphStyle: "outerHTML",
+ callbacks : {
+ beforeNodeAdded: noOp,
+ afterNodeAdded: noOp,
+ beforeNodeMorphed: noOp,
+ afterNodeMorphed: noOp,
+ beforeNodeRemoved: noOp,
+ afterNodeRemoved: noOp,
+ beforeAttributeUpdated: noOp,
+
+ },
+ head: {
+ style: 'merge',
+ shouldPreserve: function (elt) {
+ return elt.getAttribute("im-preserve") === "true";
+ },
+ shouldReAppend: function (elt) {
+ return elt.getAttribute("im-re-append") === "true";
+ },
+ shouldRemove: noOp,
+ afterHeadMorphed: noOp,
+ }
+ };
- /**
- * Determines if the element has finished loading
- */
- get complete() {
- return !this.delegate.isLoading
- }
+ //=============================================================================
+ // Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
+ //=============================================================================
+ function morph(oldNode, newContent, config = {}) {
- /**
- * Gets the active state of the frame.
- *
- * If inactive, source changes will not be observed.
- */
- get isActive() {
- return this.ownerDocument === document && !this.isPreview
- }
+ if (oldNode instanceof Document) {
+ oldNode = oldNode.documentElement;
+ }
- /**
- * Sets the active state of the frame.
- *
- * If inactive, source changes will not be observed.
- */
- get isPreview() {
- return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
- }
- }
-
- function frameLoadingStyleFromString(style) {
- switch (style.toLowerCase()) {
- case "lazy":
- return FrameLoadingStyle.lazy
- default:
- return FrameLoadingStyle.eager
- }
- }
-
- const drive = {
- enabled: true,
- progressBarDelay: 500,
- unvisitableExtensions: new Set(
- [
- ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc",
- ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg",
- ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi",
- ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf",
- ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv",
- ".xls", ".xlsx", ".xml", ".zip"
- ]
- )
- };
-
- function activateScriptElement(element) {
- if (element.getAttribute("data-turbo-eval") == "false") {
- return element
- } else {
- const createdScriptElement = document.createElement("script");
- const cspNonce = getCspNonce();
- if (cspNonce) {
- createdScriptElement.nonce = cspNonce;
- }
- createdScriptElement.textContent = element.textContent;
- createdScriptElement.async = false;
- copyElementAttributes(createdScriptElement, element);
- return createdScriptElement
- }
- }
+ if (typeof newContent === 'string') {
+ newContent = parseContent(newContent);
+ }
- function copyElementAttributes(destinationElement, sourceElement) {
- for (const { name, value } of sourceElement.attributes) {
- destinationElement.setAttribute(name, value);
- }
- }
-
- function createDocumentFragment(html) {
- const template = document.createElement("template");
- template.innerHTML = html;
- return template.content
- }
-
- function dispatch(eventName, { target, cancelable, detail } = {}) {
- const event = new CustomEvent(eventName, {
- cancelable,
- bubbles: true,
- composed: true,
- detail
- });
-
- if (target && target.isConnected) {
- target.dispatchEvent(event);
- } else {
- document.documentElement.dispatchEvent(event);
- }
+ let normalizedContent = normalizeContent(newContent);
- return event
- }
+ let ctx = createMorphContext(oldNode, normalizedContent, config);
- function cancelEvent(event) {
- event.preventDefault();
- event.stopImmediatePropagation();
- }
+ return morphNormalizedContent(oldNode, normalizedContent, ctx);
+ }
- function nextRepaint() {
- if (document.visibilityState === "hidden") {
- return nextEventLoopTick()
- } else {
- return nextAnimationFrame()
- }
- }
-
- function nextAnimationFrame() {
- return new Promise((resolve) => requestAnimationFrame(() => resolve()))
- }
-
- function nextEventLoopTick() {
- return new Promise((resolve) => setTimeout(() => resolve(), 0))
- }
-
- function nextMicrotask() {
- return Promise.resolve()
- }
-
- function parseHTMLDocument(html = "") {
- return new DOMParser().parseFromString(html, "text/html")
- }
-
- function unindent(strings, ...values) {
- const lines = interpolate(strings, values).replace(/^\n/, "").split("\n");
- const match = lines[0].match(/^\s+/);
- const indent = match ? match[0].length : 0;
- return lines.map((line) => line.slice(indent)).join("\n")
- }
-
- function interpolate(strings, values) {
- return strings.reduce((result, string, i) => {
- const value = values[i] == undefined ? "" : values[i];
- return result + string + value
- }, "")
- }
-
- function uuid() {
- return Array.from({ length: 36 })
- .map((_, i) => {
- if (i == 8 || i == 13 || i == 18 || i == 23) {
- return "-"
- } else if (i == 14) {
- return "4"
- } else if (i == 19) {
- return (Math.floor(Math.random() * 4) + 8).toString(16)
- } else {
- return Math.floor(Math.random() * 15).toString(16)
- }
- })
- .join("")
- }
+ function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
+ if (ctx.head.block) {
+ let oldHead = oldNode.querySelector('head');
+ let newHead = normalizedNewContent.querySelector('head');
+ if (oldHead && newHead) {
+ let promises = handleHeadElement(newHead, oldHead, ctx);
+ // when head promises resolve, call morph again, ignoring the head tag
+ Promise.all(promises).then(function () {
+ morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
+ head: {
+ block: false,
+ ignore: true
+ }
+ }));
+ });
+ return;
+ }
+ }
+
+ if (ctx.morphStyle === "innerHTML") {
+
+ // innerHTML, so we are only updating the children
+ morphChildren(normalizedNewContent, oldNode, ctx);
+ return oldNode.children;
+
+ } else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
+ // otherwise find the best element match in the new content, morph that, and merge its siblings
+ // into either side of the best match
+ let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
+
+ // stash the siblings that will need to be inserted on either side of the best match
+ let previousSibling = bestMatch?.previousSibling;
+ let nextSibling = bestMatch?.nextSibling;
+
+ // morph it
+ let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
+
+ if (bestMatch) {
+ // if there was a best match, merge the siblings in too and return the
+ // whole bunch
+ return insertSiblings(previousSibling, morphedNode, nextSibling);
+ } else {
+ // otherwise nothing was added to the DOM
+ return []
+ }
+ } else {
+ throw "Do not understand how to morph style " + ctx.morphStyle;
+ }
+ }
- function getAttribute(attributeName, ...elements) {
- for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
- if (typeof value == "string") return value
- }
- return null
- }
+ /**
+ * @param possibleActiveElement
+ * @param ctx
+ * @returns {boolean}
+ */
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
+ return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
+ }
- function hasAttribute(attributeName, ...elements) {
- return elements.some((element) => element && element.hasAttribute(attributeName))
- }
+ /**
+ * @param oldNode root node to merge content into
+ * @param newContent new content to merge
+ * @param ctx the merge context
+ * @returns {Element} the element that ended up in the DOM
+ */
+ function morphOldNodeTo(oldNode, newContent, ctx) {
+ if (ctx.ignoreActive && oldNode === document.activeElement) ; else if (newContent == null) {
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
+
+ oldNode.remove();
+ ctx.callbacks.afterNodeRemoved(oldNode);
+ return null;
+ } else if (!isSoftMatch(oldNode, newContent)) {
+ if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
+ if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
+
+ oldNode.parentElement.replaceChild(newContent, oldNode);
+ ctx.callbacks.afterNodeAdded(newContent);
+ ctx.callbacks.afterNodeRemoved(oldNode);
+ return newContent;
+ } else {
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
+
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
+ handleHeadElement(newContent, oldNode, ctx);
+ } else {
+ syncNodeFrom(newContent, oldNode, ctx);
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
+ morphChildren(newContent, oldNode, ctx);
+ }
+ }
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
+ return oldNode;
+ }
+ }
- function markAsBusy(...elements) {
- for (const element of elements) {
- if (element.localName == "turbo-frame") {
- element.setAttribute("busy", "");
- }
- element.setAttribute("aria-busy", "true");
- }
- }
+ /**
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
+ * by using id sets, we are able to better match up with content deeper in the DOM.
+ *
+ * Basic algorithm is, for each node in the new content:
+ *
+ * - if we have reached the end of the old parent, append the new content
+ * - if the new content has an id set match with the current insertion point, morph
+ * - search for an id set match
+ * - if id set match found, morph
+ * - otherwise search for a "soft" match
+ * - if a soft match is found, morph
+ * - otherwise, prepend the new node before the current insertion point
+ *
+ * The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
+ * with the current node. See findIdSetMatch() and findSoftMatch() for details.
+ *
+ * @param {Element} newParent the parent element of the new content
+ * @param {Element } oldParent the old content that we are merging the new content into
+ * @param ctx the merge context
+ */
+ function morphChildren(newParent, oldParent, ctx) {
+
+ let nextNewChild = newParent.firstChild;
+ let insertionPoint = oldParent.firstChild;
+ let newChild;
+
+ // run through all the new content
+ while (nextNewChild) {
+
+ newChild = nextNewChild;
+ nextNewChild = newChild.nextSibling;
+
+ // if we are at the end of the exiting parent's children, just append
+ if (insertionPoint == null) {
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
+
+ oldParent.appendChild(newChild);
+ ctx.callbacks.afterNodeAdded(newChild);
+ removeIdsFromConsideration(ctx, newChild);
+ continue;
+ }
+
+ // if the current node has an id set match then morph
+ if (isIdSetMatch(newChild, insertionPoint, ctx)) {
+ morphOldNodeTo(insertionPoint, newChild, ctx);
+ insertionPoint = insertionPoint.nextSibling;
+ removeIdsFromConsideration(ctx, newChild);
+ continue;
+ }
+
+ // otherwise search forward in the existing old children for an id set match
+ let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
+
+ // if we found a potential match, remove the nodes until that point and morph
+ if (idSetMatch) {
+ insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
+ morphOldNodeTo(idSetMatch, newChild, ctx);
+ removeIdsFromConsideration(ctx, newChild);
+ continue;
+ }
+
+ // no id set match found, so scan forward for a soft match for the current node
+ let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
+
+ // if we found a soft match for the current node, morph
+ if (softMatch) {
+ insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
+ morphOldNodeTo(softMatch, newChild, ctx);
+ removeIdsFromConsideration(ctx, newChild);
+ continue;
+ }
+
+ // abandon all hope of morphing, just insert the new child before the insertion point
+ // and move on
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
+
+ oldParent.insertBefore(newChild, insertionPoint);
+ ctx.callbacks.afterNodeAdded(newChild);
+ removeIdsFromConsideration(ctx, newChild);
+ }
+
+ // remove any remaining old nodes that didn't match up with new content
+ while (insertionPoint !== null) {
+
+ let tempNode = insertionPoint;
+ insertionPoint = insertionPoint.nextSibling;
+ removeNode(tempNode, ctx);
+ }
+ }
- function clearBusyState(...elements) {
- for (const element of elements) {
- if (element.localName == "turbo-frame") {
- element.removeAttribute("busy");
- }
+ //=============================================================================
+ // Attribute Syncing Code
+ //=============================================================================
+
+ /**
+ * @param attr {String} the attribute to be mutated
+ * @param to {Element} the element that is going to be updated
+ * @param updateType {("update"|"remove")}
+ * @param ctx the merge context
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
+ */
+ function ignoreAttribute(attr, to, updateType, ctx) {
+ if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
+ return true;
+ }
+ return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
+ }
- element.removeAttribute("aria-busy");
- }
- }
-
- function waitForLoad(element, timeoutInMilliseconds = 2000) {
- return new Promise((resolve) => {
- const onComplete = () => {
- element.removeEventListener("error", onComplete);
- element.removeEventListener("load", onComplete);
- resolve();
- };
-
- element.addEventListener("load", onComplete, { once: true });
- element.addEventListener("error", onComplete, { once: true });
- setTimeout(resolve, timeoutInMilliseconds);
- })
- }
-
- function getHistoryMethodForAction(action) {
- switch (action) {
- case "replace":
- return history.replaceState
- case "advance":
- case "restore":
- return history.pushState
- }
- }
+ /**
+ * syncs a given node with another node, copying over all attributes and
+ * inner element state from the 'from' node to the 'to' node
+ *
+ * @param {Element} from the element to copy attributes & state from
+ * @param {Element} to the element to copy attributes & state to
+ * @param ctx the merge context
+ */
+ function syncNodeFrom(from, to, ctx) {
+ let type = from.nodeType;
+
+ // if is an element type, sync the attributes from the
+ // new node into the new node
+ if (type === 1 /* element type */) {
+ const fromAttributes = from.attributes;
+ const toAttributes = to.attributes;
+ for (const fromAttribute of fromAttributes) {
+ if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
+ continue;
+ }
+ if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
+ to.setAttribute(fromAttribute.name, fromAttribute.value);
+ }
+ }
+ // iterate backwards to avoid skipping over items when a delete occurs
+ for (let i = toAttributes.length - 1; 0 <= i; i--) {
+ const toAttribute = toAttributes[i];
+ if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
+ continue;
+ }
+ if (!from.hasAttribute(toAttribute.name)) {
+ to.removeAttribute(toAttribute.name);
+ }
+ }
+ }
+
+ // sync text nodes
+ if (type === 8 /* comment */ || type === 3 /* text */) {
+ if (to.nodeValue !== from.nodeValue) {
+ to.nodeValue = from.nodeValue;
+ }
+ }
+
+ if (!ignoreValueOfActiveElement(to, ctx)) {
+ // sync input values
+ syncInputValue(from, to, ctx);
+ }
+ }
- function isAction(action) {
- return action == "advance" || action == "replace" || action == "restore"
- }
+ /**
+ * @param from {Element} element to sync the value from
+ * @param to {Element} element to sync the value to
+ * @param attributeName {String} the attribute name
+ * @param ctx the merge context
+ */
+ function syncBooleanAttribute(from, to, attributeName, ctx) {
+ if (from[attributeName] !== to[attributeName]) {
+ let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
+ if (!ignoreUpdate) {
+ to[attributeName] = from[attributeName];
+ }
+ if (from[attributeName]) {
+ if (!ignoreUpdate) {
+ to.setAttribute(attributeName, from[attributeName]);
+ }
+ } else {
+ if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
+ to.removeAttribute(attributeName);
+ }
+ }
+ }
+ }
- function getVisitAction(...elements) {
- const action = getAttribute("data-turbo-action", ...elements);
+ /**
+ * NB: many bothans died to bring us information:
+ *
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
+ *
+ * @param from {Element} the element to sync the input value from
+ * @param to {Element} the element to sync the input value to
+ * @param ctx the merge context
+ */
+ function syncInputValue(from, to, ctx) {
+ if (from instanceof HTMLInputElement &&
+ to instanceof HTMLInputElement &&
+ from.type !== 'file') {
+
+ let fromValue = from.value;
+ let toValue = to.value;
+
+ // sync boolean attributes
+ syncBooleanAttribute(from, to, 'checked', ctx);
+ syncBooleanAttribute(from, to, 'disabled', ctx);
+
+ if (!from.hasAttribute('value')) {
+ if (!ignoreAttribute('value', to, 'remove', ctx)) {
+ to.value = '';
+ to.removeAttribute('value');
+ }
+ } else if (fromValue !== toValue) {
+ if (!ignoreAttribute('value', to, 'update', ctx)) {
+ to.setAttribute('value', fromValue);
+ to.value = fromValue;
+ }
+ }
+ } else if (from instanceof HTMLOptionElement) {
+ syncBooleanAttribute(from, to, 'selected', ctx);
+ } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
+ let fromValue = from.value;
+ let toValue = to.value;
+ if (ignoreAttribute('value', to, 'update', ctx)) {
+ return;
+ }
+ if (fromValue !== toValue) {
+ to.value = fromValue;
+ }
+ if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
+ to.firstChild.nodeValue = fromValue;
+ }
+ }
+ }
- return isAction(action) ? action : null
- }
+ //=============================================================================
+ // the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
+ //=============================================================================
+ function handleHeadElement(newHeadTag, currentHead, ctx) {
+
+ let added = [];
+ let removed = [];
+ let preserved = [];
+ let nodesToAppend = [];
+
+ let headMergeStyle = ctx.head.style;
+
+ // put all new head elements into a Map, by their outerHTML
+ let srcToNewHeadNodes = new Map();
+ for (const newHeadChild of newHeadTag.children) {
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
+ }
+
+ // for each elt in the current head
+ for (const currentHeadElt of currentHead.children) {
+
+ // If the current head element is in the map
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
+ if (inNewContent || isPreserved) {
+ if (isReAppended) {
+ // remove the current version and let the new version replace it and re-execute
+ removed.push(currentHeadElt);
+ } else {
+ // this element already exists and should not be re-appended, so remove it from
+ // the new content map, preserving it in the DOM
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
+ preserved.push(currentHeadElt);
+ }
+ } else {
+ if (headMergeStyle === "append") {
+ // we are appending and this existing element is not new content
+ // so if and only if it is marked for re-append do we do anything
+ if (isReAppended) {
+ removed.push(currentHeadElt);
+ nodesToAppend.push(currentHeadElt);
+ }
+ } else {
+ // if this is a merge, we remove this content since it is not in the new head
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
+ removed.push(currentHeadElt);
+ }
+ }
+ }
+ }
+
+ // Push the remaining new head elements in the Map into the
+ // nodes to append to the head tag
+ nodesToAppend.push(...srcToNewHeadNodes.values());
+
+ let promises = [];
+ for (const newNode of nodesToAppend) {
+ let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
+ if (newElt.href || newElt.src) {
+ let resolve = null;
+ let promise = new Promise(function (_resolve) {
+ resolve = _resolve;
+ });
+ newElt.addEventListener('load', function () {
+ resolve();
+ });
+ promises.push(promise);
+ }
+ currentHead.appendChild(newElt);
+ ctx.callbacks.afterNodeAdded(newElt);
+ added.push(newElt);
+ }
+ }
+
+ // remove all removed elements, after we have appended the new elements to avoid
+ // additional network requests for things like style sheets
+ for (const removedElement of removed) {
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
+ currentHead.removeChild(removedElement);
+ ctx.callbacks.afterNodeRemoved(removedElement);
+ }
+ }
+
+ ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
+ return promises;
+ }
- function getMetaElement(name) {
- return document.querySelector(`meta[name="${name}"]`)
- }
+ function noOp() {
+ }
- function getMetaContent(name) {
- const element = getMetaElement(name);
- return element && element.content
- }
+ /*
+ Deep merges the config object and the Idiomoroph.defaults object to
+ produce a final configuration object
+ */
+ function mergeDefaults(config) {
+ let finalConfig = {};
+ // copy top level stuff into final config
+ Object.assign(finalConfig, defaults);
+ Object.assign(finalConfig, config);
+
+ // copy callbacks into final config (do this to deep merge the callbacks)
+ finalConfig.callbacks = {};
+ Object.assign(finalConfig.callbacks, defaults.callbacks);
+ Object.assign(finalConfig.callbacks, config.callbacks);
+
+ // copy head config into final config (do this to deep merge the head)
+ finalConfig.head = {};
+ Object.assign(finalConfig.head, defaults.head);
+ Object.assign(finalConfig.head, config.head);
+ return finalConfig;
+ }
- function getCspNonce() {
- const element = getMetaElement("csp-nonce");
+ function createMorphContext(oldNode, newContent, config) {
+ config = mergeDefaults(config);
+ return {
+ target: oldNode,
+ newContent: newContent,
+ config: config,
+ morphStyle: config.morphStyle,
+ ignoreActive: config.ignoreActive,
+ ignoreActiveValue: config.ignoreActiveValue,
+ idMap: createIdMap(oldNode, newContent),
+ deadIds: new Set(),
+ callbacks: config.callbacks,
+ head: config.head
+ }
+ }
- if (element) {
- const { nonce, content } = element;
- return nonce == "" ? content : nonce
- }
- }
+ function isIdSetMatch(node1, node2, ctx) {
+ if (node1 == null || node2 == null) {
+ return false;
+ }
+ if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
+ if (node1.id !== "" && node1.id === node2.id) {
+ return true;
+ } else {
+ return getIdIntersectionCount(ctx, node1, node2) > 0;
+ }
+ }
+ return false;
+ }
- function setMetaContent(name, content) {
- let element = getMetaElement(name);
+ function isSoftMatch(node1, node2) {
+ if (node1 == null || node2 == null) {
+ return false;
+ }
+ return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
+ }
- if (!element) {
- element = document.createElement("meta");
- element.setAttribute("name", name);
+ function removeNodesBetween(startInclusive, endExclusive, ctx) {
+ while (startInclusive !== endExclusive) {
+ let tempNode = startInclusive;
+ startInclusive = startInclusive.nextSibling;
+ removeNode(tempNode, ctx);
+ }
+ removeIdsFromConsideration(ctx, endExclusive);
+ return endExclusive.nextSibling;
+ }
- document.head.appendChild(element);
- }
+ //=============================================================================
+ // Scans forward from the insertionPoint in the old parent looking for a potential id match
+ // for the newChild. We stop if we find a potential id match for the new child OR
+ // if the number of potential id matches we are discarding is greater than the
+ // potential id matches for the new child
+ //=============================================================================
+ function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
+
+ // max id matches we are willing to discard in our search
+ let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
+
+ let potentialMatch = null;
+
+ // only search forward if there is a possibility of an id match
+ if (newChildPotentialIdCount > 0) {
+ let potentialMatch = insertionPoint;
+ // if there is a possibility of an id match, scan forward
+ // keep track of the potential id match count we are discarding (the
+ // newChildPotentialIdCount must be greater than this to make it likely
+ // worth it)
+ let otherMatchCount = 0;
+ while (potentialMatch != null) {
+
+ // If we have an id match, return the current potential match
+ if (isIdSetMatch(newChild, potentialMatch, ctx)) {
+ return potentialMatch;
+ }
+
+ // computer the other potential matches of this new content
+ otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
+ if (otherMatchCount > newChildPotentialIdCount) {
+ // if we have more potential id matches in _other_ content, we
+ // do not have a good candidate for an id match, so return null
+ return null;
+ }
+
+ // advanced to the next old content child
+ potentialMatch = potentialMatch.nextSibling;
+ }
+ }
+ return potentialMatch;
+ }
- element.setAttribute("content", content);
+ //=============================================================================
+ // Scans forward from the insertionPoint in the old parent looking for a potential soft match
+ // for the newChild. We stop if we find a potential soft match for the new child OR
+ // if we find a potential id match in the old parents children OR if we find two
+ // potential soft matches for the next two pieces of new content
+ //=============================================================================
+ function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
+
+ let potentialSoftMatch = insertionPoint;
+ let nextSibling = newChild.nextSibling;
+ let siblingSoftMatchCount = 0;
+
+ while (potentialSoftMatch != null) {
+
+ if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
+ // the current potential soft match has a potential id set match with the remaining new
+ // content so bail out of looking
+ return null;
+ }
+
+ // if we have a soft match with the current node, return it
+ if (isSoftMatch(newChild, potentialSoftMatch)) {
+ return potentialSoftMatch;
+ }
+
+ if (isSoftMatch(nextSibling, potentialSoftMatch)) {
+ // the next new node has a soft match with this node, so
+ // increment the count of future soft matches
+ siblingSoftMatchCount++;
+ nextSibling = nextSibling.nextSibling;
+
+ // If there are two future soft matches, bail to allow the siblings to soft match
+ // so that we don't consume future soft matches for the sake of the current node
+ if (siblingSoftMatchCount >= 2) {
+ return null;
+ }
+ }
+
+ // advanced to the next old content child
+ potentialSoftMatch = potentialSoftMatch.nextSibling;
+ }
+
+ return potentialSoftMatch;
+ }
- return element
- }
+ function parseContent(newContent) {
+ let parser = new DOMParser();
+
+ // remove svgs to avoid false-positive matches on head, etc.
+ let contentWithSvgsRemoved = newContent.replace(/