mirror of
https://github.com/Combodo/iTop.git
synced 2026-02-13 07:24:13 +01:00
- Form SDK implementation - Basic Forms - Dynamics Forms - Basic Blocks + Data Model Block - Form Compilation - Turbo integration
6619 lines
195 KiB
JavaScript
6619 lines
195 KiB
JavaScript
/*!
|
||
Turbo 8.0.5
|
||
Copyright © 2024 37signals LLC
|
||
*/
|
||
(function (global, factory) {
|
||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Turbo = {}));
|
||
})(this, (function (exports) { 'use strict';
|
||
|
||
/**
|
||
* 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
|
||
* <turbo-frame id="messages">
|
||
* <a href="/messages/expanded">
|
||
* Show all expanded messages in this frame.
|
||
* </a>
|
||
*
|
||
* <form action="/messages">
|
||
* Show response from this form within this frame.
|
||
* </form>
|
||
* </turbo-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");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Determines if the element has finished loading
|
||
*/
|
||
get complete() {
|
||
return !this.delegate.isLoading
|
||
}
|
||
|
||
/**
|
||
* Gets the active state of the frame.
|
||
*
|
||
* If inactive, source changes will not be observed.
|
||
*/
|
||
get isActive() {
|
||
return this.ownerDocument === document && !this.isPreview
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
}
|
||
|
||
function expandURL(locatable) {
|
||
return new URL(locatable.toString(), document.baseURI)
|
||
}
|
||
|
||
function getAnchor(url) {
|
||
let anchorMatch;
|
||
if (url.hash) {
|
||
return url.hash.slice(1)
|
||
// eslint-disable-next-line no-cond-assign
|
||
} else if ((anchorMatch = url.href.match(/#(.*)$/))) {
|
||
return anchorMatch[1]
|
||
}
|
||
}
|
||
|
||
function getAction$1(form, submitter) {
|
||
const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action;
|
||
|
||
return expandURL(action)
|
||
}
|
||
|
||
function getExtension(url) {
|
||
return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""
|
||
}
|
||
|
||
function isHTML(url) {
|
||
return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/)
|
||
}
|
||
|
||
function isPrefixedBy(baseURL, url) {
|
||
const prefix = getPrefix(url);
|
||
return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
|
||
}
|
||
|
||
function locationIsVisitable(location, rootLocation) {
|
||
return isPrefixedBy(location, rootLocation) && isHTML(location)
|
||
}
|
||
|
||
function getRequestURL(url) {
|
||
const anchor = getAnchor(url);
|
||
return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
|
||
}
|
||
|
||
function toCacheKey(url) {
|
||
return getRequestURL(url)
|
||
}
|
||
|
||
function urlsAreEqual(left, right) {
|
||
return expandURL(left).href == expandURL(right).href
|
||
}
|
||
|
||
function getPathComponents(url) {
|
||
return url.pathname.split("/").slice(1)
|
||
}
|
||
|
||
function getLastPathComponent(url) {
|
||
return getPathComponents(url).slice(-1)[0]
|
||
}
|
||
|
||
function getPrefix(url) {
|
||
return addTrailingSlash(url.origin + url.pathname)
|
||
}
|
||
|
||
function addTrailingSlash(value) {
|
||
return value.endsWith("/") ? value : value + "/"
|
||
}
|
||
|
||
class FetchResponse {
|
||
constructor(response) {
|
||
this.response = response;
|
||
}
|
||
|
||
get succeeded() {
|
||
return this.response.ok
|
||
}
|
||
|
||
get failed() {
|
||
return !this.succeeded
|
||
}
|
||
|
||
get clientError() {
|
||
return this.statusCode >= 400 && this.statusCode <= 499
|
||
}
|
||
|
||
get serverError() {
|
||
return this.statusCode >= 500 && this.statusCode <= 599
|
||
}
|
||
|
||
get redirected() {
|
||
return this.response.redirected
|
||
}
|
||
|
||
get location() {
|
||
return expandURL(this.response.url)
|
||
}
|
||
|
||
get isHTML() {
|
||
return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
|
||
}
|
||
|
||
get statusCode() {
|
||
return this.response.status
|
||
}
|
||
|
||
get contentType() {
|
||
return this.header("Content-Type")
|
||
}
|
||
|
||
get responseText() {
|
||
return this.response.clone().text()
|
||
}
|
||
|
||
get responseHTML() {
|
||
if (this.isHTML) {
|
||
return this.response.clone().text()
|
||
} else {
|
||
return Promise.resolve(undefined)
|
||
}
|
||
}
|
||
|
||
header(name) {
|
||
return this.response.headers.get(name)
|
||
}
|
||
}
|
||
|
||
function activateScriptElement(element) {
|
||
if (element.getAttribute("data-turbo-eval") == "false") {
|
||
return element
|
||
} else {
|
||
const createdScriptElement = document.createElement("script");
|
||
const cspNonce = getMetaContent("csp-nonce");
|
||
if (cspNonce) {
|
||
createdScriptElement.nonce = cspNonce;
|
||
}
|
||
createdScriptElement.textContent = element.textContent;
|
||
createdScriptElement.async = false;
|
||
copyElementAttributes(createdScriptElement, element);
|
||
return createdScriptElement
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
return event
|
||
}
|
||
|
||
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 getAttribute(attributeName, ...elements) {
|
||
for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
|
||
if (typeof value == "string") return value
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function hasAttribute(attributeName, ...elements) {
|
||
return elements.some((element) => element && element.hasAttribute(attributeName))
|
||
}
|
||
|
||
function markAsBusy(...elements) {
|
||
for (const element of elements) {
|
||
if (element.localName == "turbo-frame") {
|
||
element.setAttribute("busy", "");
|
||
}
|
||
element.setAttribute("aria-busy", "true");
|
||
}
|
||
}
|
||
|
||
function clearBusyState(...elements) {
|
||
for (const element of elements) {
|
||
if (element.localName == "turbo-frame") {
|
||
element.removeAttribute("busy");
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
function isAction(action) {
|
||
return action == "advance" || action == "replace" || action == "restore"
|
||
}
|
||
|
||
function getVisitAction(...elements) {
|
||
const action = getAttribute("data-turbo-action", ...elements);
|
||
|
||
return isAction(action) ? action : null
|
||
}
|
||
|
||
function getMetaElement(name) {
|
||
return document.querySelector(`meta[name="${name}"]`)
|
||
}
|
||
|
||
function getMetaContent(name) {
|
||
const element = getMetaElement(name);
|
||
return element && element.content
|
||
}
|
||
|
||
function setMetaContent(name, content) {
|
||
let element = getMetaElement(name);
|
||
|
||
if (!element) {
|
||
element = document.createElement("meta");
|
||
element.setAttribute("name", name);
|
||
|
||
document.head.appendChild(element);
|
||
}
|
||
|
||
element.setAttribute("content", content);
|
||
|
||
return element
|
||
}
|
||
|
||
function findClosestRecursively(element, selector) {
|
||
if (element instanceof Element) {
|
||
return (
|
||
element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
|
||
)
|
||
}
|
||
}
|
||
|
||
function elementIsFocusable(element) {
|
||
const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])";
|
||
|
||
return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
|
||
}
|
||
|
||
function queryAutofocusableElement(elementOrDocumentFragment) {
|
||
return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable)
|
||
}
|
||
|
||
async function around(callback, reader) {
|
||
const before = reader();
|
||
|
||
callback();
|
||
|
||
await nextAnimationFrame();
|
||
|
||
const after = reader();
|
||
|
||
return [before, after]
|
||
}
|
||
|
||
function doesNotTargetIFrame(name) {
|
||
if (name === "_blank") {
|
||
return false
|
||
} else if (name) {
|
||
for (const element of document.getElementsByName(name)) {
|
||
if (element instanceof HTMLIFrameElement) return false
|
||
}
|
||
|
||
return true
|
||
} else {
|
||
return true
|
||
}
|
||
}
|
||
|
||
function findLinkFromClickTarget(target) {
|
||
return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
|
||
}
|
||
|
||
function getLocationForLink(link) {
|
||
return expandURL(link.getAttribute("href") || "")
|
||
}
|
||
|
||
function debounce(fn, delay) {
|
||
let timeoutId = null;
|
||
|
||
return (...args) => {
|
||
const callback = () => fn.apply(this, args);
|
||
clearTimeout(timeoutId);
|
||
timeoutId = setTimeout(callback, delay);
|
||
}
|
||
}
|
||
|
||
class LimitedSet extends Set {
|
||
constructor(maxSize) {
|
||
super();
|
||
this.maxSize = maxSize;
|
||
}
|
||
|
||
add(value) {
|
||
if (this.size >= this.maxSize) {
|
||
const iterator = this.values();
|
||
const oldestValue = iterator.next().value;
|
||
this.delete(oldestValue);
|
||
}
|
||
super.add(value);
|
||
}
|
||
}
|
||
|
||
const recentRequests = new LimitedSet(20);
|
||
|
||
const nativeFetch = window.fetch;
|
||
|
||
function fetchWithTurboHeaders(url, options = {}) {
|
||
const modifiedHeaders = new Headers(options.headers || {});
|
||
const requestUID = uuid();
|
||
recentRequests.add(requestUID);
|
||
modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
|
||
|
||
return nativeFetch(url, {
|
||
...options,
|
||
headers: modifiedHeaders
|
||
})
|
||
}
|
||
|
||
function fetchMethodFromString(method) {
|
||
switch (method.toLowerCase()) {
|
||
case "get":
|
||
return FetchMethod.get
|
||
case "post":
|
||
return FetchMethod.post
|
||
case "put":
|
||
return FetchMethod.put
|
||
case "patch":
|
||
return FetchMethod.patch
|
||
case "delete":
|
||
return FetchMethod.delete
|
||
}
|
||
}
|
||
|
||
const FetchMethod = {
|
||
get: "get",
|
||
post: "post",
|
||
put: "put",
|
||
patch: "patch",
|
||
delete: "delete"
|
||
};
|
||
|
||
function fetchEnctypeFromString(encoding) {
|
||
switch (encoding.toLowerCase()) {
|
||
case FetchEnctype.multipart:
|
||
return FetchEnctype.multipart
|
||
case FetchEnctype.plain:
|
||
return FetchEnctype.plain
|
||
default:
|
||
return FetchEnctype.urlEncoded
|
||
}
|
||
}
|
||
|
||
const FetchEnctype = {
|
||
urlEncoded: "application/x-www-form-urlencoded",
|
||
multipart: "multipart/form-data",
|
||
plain: "text/plain"
|
||
};
|
||
|
||
class FetchRequest {
|
||
abortController = new AbortController()
|
||
#resolveRequestPromise = (_value) => {}
|
||
|
||
constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
|
||
const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype);
|
||
|
||
this.delegate = delegate;
|
||
this.url = url;
|
||
this.target = target;
|
||
this.fetchOptions = {
|
||
credentials: "same-origin",
|
||
redirect: "follow",
|
||
method: method.toUpperCase(),
|
||
headers: { ...this.defaultHeaders },
|
||
body: body,
|
||
signal: this.abortSignal,
|
||
referrer: this.delegate.referrer?.href
|
||
};
|
||
this.enctype = enctype;
|
||
}
|
||
|
||
get method() {
|
||
return this.fetchOptions.method
|
||
}
|
||
|
||
set method(value) {
|
||
const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData();
|
||
const fetchMethod = fetchMethodFromString(value) || FetchMethod.get;
|
||
|
||
this.url.search = "";
|
||
|
||
const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype);
|
||
|
||
this.url = url;
|
||
this.fetchOptions.body = body;
|
||
this.fetchOptions.method = fetchMethod.toUpperCase();
|
||
}
|
||
|
||
get headers() {
|
||
return this.fetchOptions.headers
|
||
}
|
||
|
||
set headers(value) {
|
||
this.fetchOptions.headers = value;
|
||
}
|
||
|
||
get body() {
|
||
if (this.isSafe) {
|
||
return this.url.searchParams
|
||
} else {
|
||
return this.fetchOptions.body
|
||
}
|
||
}
|
||
|
||
set body(value) {
|
||
this.fetchOptions.body = value;
|
||
}
|
||
|
||
get location() {
|
||
return this.url
|
||
}
|
||
|
||
get params() {
|
||
return this.url.searchParams
|
||
}
|
||
|
||
get entries() {
|
||
return this.body ? Array.from(this.body.entries()) : []
|
||
}
|
||
|
||
cancel() {
|
||
this.abortController.abort();
|
||
}
|
||
|
||
async perform() {
|
||
const { fetchOptions } = this;
|
||
this.delegate.prepareRequest(this);
|
||
const event = await this.#allowRequestToBeIntercepted(fetchOptions);
|
||
try {
|
||
this.delegate.requestStarted(this);
|
||
|
||
if (event.detail.fetchRequest) {
|
||
this.response = event.detail.fetchRequest.response;
|
||
} else {
|
||
this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
|
||
}
|
||
|
||
const response = await this.response;
|
||
return await this.receive(response)
|
||
} catch (error) {
|
||
if (error.name !== "AbortError") {
|
||
if (this.#willDelegateErrorHandling(error)) {
|
||
this.delegate.requestErrored(this, error);
|
||
}
|
||
throw error
|
||
}
|
||
} finally {
|
||
this.delegate.requestFinished(this);
|
||
}
|
||
}
|
||
|
||
async receive(response) {
|
||
const fetchResponse = new FetchResponse(response);
|
||
const event = dispatch("turbo:before-fetch-response", {
|
||
cancelable: true,
|
||
detail: { fetchResponse },
|
||
target: this.target
|
||
});
|
||
if (event.defaultPrevented) {
|
||
this.delegate.requestPreventedHandlingResponse(this, fetchResponse);
|
||
} else if (fetchResponse.succeeded) {
|
||
this.delegate.requestSucceededWithResponse(this, fetchResponse);
|
||
} else {
|
||
this.delegate.requestFailedWithResponse(this, fetchResponse);
|
||
}
|
||
return fetchResponse
|
||
}
|
||
|
||
get defaultHeaders() {
|
||
return {
|
||
Accept: "text/html, application/xhtml+xml"
|
||
}
|
||
}
|
||
|
||
get isSafe() {
|
||
return isSafe(this.method)
|
||
}
|
||
|
||
get abortSignal() {
|
||
return this.abortController.signal
|
||
}
|
||
|
||
acceptResponseType(mimeType) {
|
||
this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ");
|
||
}
|
||
|
||
async #allowRequestToBeIntercepted(fetchOptions) {
|
||
const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve));
|
||
const event = dispatch("turbo:before-fetch-request", {
|
||
cancelable: true,
|
||
detail: {
|
||
fetchOptions,
|
||
url: this.url,
|
||
resume: this.#resolveRequestPromise
|
||
},
|
||
target: this.target
|
||
});
|
||
this.url = event.detail.url;
|
||
if (event.defaultPrevented) await requestInterception;
|
||
|
||
return event
|
||
}
|
||
|
||
#willDelegateErrorHandling(error) {
|
||
const event = dispatch("turbo:fetch-request-error", {
|
||
target: this.target,
|
||
cancelable: true,
|
||
detail: { request: this, error: error }
|
||
});
|
||
|
||
return !event.defaultPrevented
|
||
}
|
||
}
|
||
|
||
function isSafe(fetchMethod) {
|
||
return fetchMethodFromString(fetchMethod) == FetchMethod.get
|
||
}
|
||
|
||
function buildResourceAndBody(resource, method, requestBody, enctype) {
|
||
const searchParams =
|
||
Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams;
|
||
|
||
if (isSafe(method)) {
|
||
return [mergeIntoURLSearchParams(resource, searchParams), null]
|
||
} else if (enctype == FetchEnctype.urlEncoded) {
|
||
return [resource, searchParams]
|
||
} else {
|
||
return [resource, requestBody]
|
||
}
|
||
}
|
||
|
||
function entriesExcludingFiles(requestBody) {
|
||
const entries = [];
|
||
|
||
for (const [name, value] of requestBody) {
|
||
if (value instanceof File) continue
|
||
else entries.push([name, value]);
|
||
}
|
||
|
||
return entries
|
||
}
|
||
|
||
function mergeIntoURLSearchParams(url, requestBody) {
|
||
const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody));
|
||
|
||
url.search = searchParams.toString();
|
||
|
||
return url
|
||
}
|
||
|
||
class AppearanceObserver {
|
||
started = false
|
||
|
||
constructor(delegate, element) {
|
||
this.delegate = delegate;
|
||
this.element = element;
|
||
this.intersectionObserver = new IntersectionObserver(this.intersect);
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
this.started = true;
|
||
this.intersectionObserver.observe(this.element);
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
this.started = false;
|
||
this.intersectionObserver.unobserve(this.element);
|
||
}
|
||
}
|
||
|
||
intersect = (entries) => {
|
||
const lastEntry = entries.slice(-1)[0];
|
||
if (lastEntry?.isIntersecting) {
|
||
this.delegate.elementAppearedInViewport(this.element);
|
||
}
|
||
}
|
||
}
|
||
|
||
class StreamMessage {
|
||
static contentType = "text/vnd.turbo-stream.html"
|
||
|
||
static wrap(message) {
|
||
if (typeof message == "string") {
|
||
return new this(createDocumentFragment(message))
|
||
} else {
|
||
return message
|
||
}
|
||
}
|
||
|
||
constructor(fragment) {
|
||
this.fragment = importStreamElements(fragment);
|
||
}
|
||
}
|
||
|
||
function importStreamElements(fragment) {
|
||
for (const element of fragment.querySelectorAll("turbo-stream")) {
|
||
const streamElement = document.importNode(element, true);
|
||
|
||
for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
|
||
inertScriptElement.replaceWith(activateScriptElement(inertScriptElement));
|
||
}
|
||
|
||
element.replaceWith(streamElement);
|
||
}
|
||
|
||
return fragment
|
||
}
|
||
|
||
const PREFETCH_DELAY = 100;
|
||
|
||
class PrefetchCache {
|
||
#prefetchTimeout = null
|
||
#prefetched = null
|
||
|
||
get(url) {
|
||
if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
|
||
return this.#prefetched.request
|
||
}
|
||
}
|
||
|
||
setLater(url, request, ttl) {
|
||
this.clear();
|
||
|
||
this.#prefetchTimeout = setTimeout(() => {
|
||
request.perform();
|
||
this.set(url, request, ttl);
|
||
this.#prefetchTimeout = null;
|
||
}, PREFETCH_DELAY);
|
||
}
|
||
|
||
set(url, request, ttl) {
|
||
this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
|
||
}
|
||
|
||
clear() {
|
||
if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
|
||
this.#prefetched = null;
|
||
}
|
||
}
|
||
|
||
const cacheTtl = 10 * 1000;
|
||
const prefetchCache = new PrefetchCache();
|
||
|
||
const FormSubmissionState = {
|
||
initialized: "initialized",
|
||
requesting: "requesting",
|
||
waiting: "waiting",
|
||
receiving: "receiving",
|
||
stopping: "stopping",
|
||
stopped: "stopped"
|
||
};
|
||
|
||
class FormSubmission {
|
||
state = FormSubmissionState.initialized
|
||
|
||
static confirmMethod(message, _element, _submitter) {
|
||
return Promise.resolve(confirm(message))
|
||
}
|
||
|
||
constructor(delegate, formElement, submitter, mustRedirect = false) {
|
||
const method = getMethod(formElement, submitter);
|
||
const action = getAction(getFormAction(formElement, submitter), method);
|
||
const body = buildFormData(formElement, submitter);
|
||
const enctype = getEnctype(formElement, submitter);
|
||
|
||
this.delegate = delegate;
|
||
this.formElement = formElement;
|
||
this.submitter = submitter;
|
||
this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype);
|
||
this.mustRedirect = mustRedirect;
|
||
}
|
||
|
||
get method() {
|
||
return this.fetchRequest.method
|
||
}
|
||
|
||
set method(value) {
|
||
this.fetchRequest.method = value;
|
||
}
|
||
|
||
get action() {
|
||
return this.fetchRequest.url.toString()
|
||
}
|
||
|
||
set action(value) {
|
||
this.fetchRequest.url = expandURL(value);
|
||
}
|
||
|
||
get body() {
|
||
return this.fetchRequest.body
|
||
}
|
||
|
||
get enctype() {
|
||
return this.fetchRequest.enctype
|
||
}
|
||
|
||
get isSafe() {
|
||
return this.fetchRequest.isSafe
|
||
}
|
||
|
||
get location() {
|
||
return this.fetchRequest.url
|
||
}
|
||
|
||
// The submission process
|
||
|
||
async start() {
|
||
const { initialized, requesting } = FormSubmissionState;
|
||
const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
|
||
|
||
if (typeof confirmationMessage === "string") {
|
||
const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter);
|
||
if (!answer) {
|
||
return
|
||
}
|
||
}
|
||
|
||
if (this.state == initialized) {
|
||
this.state = requesting;
|
||
return this.fetchRequest.perform()
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
const { stopping, stopped } = FormSubmissionState;
|
||
if (this.state != stopping && this.state != stopped) {
|
||
this.state = stopping;
|
||
this.fetchRequest.cancel();
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Fetch request delegate
|
||
|
||
prepareRequest(request) {
|
||
if (!request.isSafe) {
|
||
const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
|
||
if (token) {
|
||
request.headers["X-CSRF-Token"] = token;
|
||
}
|
||
}
|
||
|
||
if (this.requestAcceptsTurboStreamResponse(request)) {
|
||
request.acceptResponseType(StreamMessage.contentType);
|
||
}
|
||
}
|
||
|
||
requestStarted(_request) {
|
||
this.state = FormSubmissionState.waiting;
|
||
this.submitter?.setAttribute("disabled", "");
|
||
this.setSubmitsWith();
|
||
markAsBusy(this.formElement);
|
||
dispatch("turbo:submit-start", {
|
||
target: this.formElement,
|
||
detail: { formSubmission: this }
|
||
});
|
||
this.delegate.formSubmissionStarted(this);
|
||
}
|
||
|
||
requestPreventedHandlingResponse(request, response) {
|
||
prefetchCache.clear();
|
||
|
||
this.result = { success: response.succeeded, fetchResponse: response };
|
||
}
|
||
|
||
requestSucceededWithResponse(request, response) {
|
||
if (response.clientError || response.serverError) {
|
||
this.delegate.formSubmissionFailedWithResponse(this, response);
|
||
return
|
||
}
|
||
|
||
prefetchCache.clear();
|
||
|
||
if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
|
||
const error = new Error("Form responses must redirect to another location");
|
||
this.delegate.formSubmissionErrored(this, error);
|
||
} else {
|
||
this.state = FormSubmissionState.receiving;
|
||
this.result = { success: true, fetchResponse: response };
|
||
this.delegate.formSubmissionSucceededWithResponse(this, response);
|
||
}
|
||
}
|
||
|
||
requestFailedWithResponse(request, response) {
|
||
this.result = { success: false, fetchResponse: response };
|
||
this.delegate.formSubmissionFailedWithResponse(this, response);
|
||
}
|
||
|
||
requestErrored(request, error) {
|
||
this.result = { success: false, error };
|
||
this.delegate.formSubmissionErrored(this, error);
|
||
}
|
||
|
||
requestFinished(_request) {
|
||
this.state = FormSubmissionState.stopped;
|
||
this.submitter?.removeAttribute("disabled");
|
||
this.resetSubmitterText();
|
||
clearBusyState(this.formElement);
|
||
dispatch("turbo:submit-end", {
|
||
target: this.formElement,
|
||
detail: { formSubmission: this, ...this.result }
|
||
});
|
||
this.delegate.formSubmissionFinished(this);
|
||
}
|
||
|
||
// Private
|
||
|
||
setSubmitsWith() {
|
||
if (!this.submitter || !this.submitsWith) return
|
||
|
||
if (this.submitter.matches("button")) {
|
||
this.originalSubmitText = this.submitter.innerHTML;
|
||
this.submitter.innerHTML = this.submitsWith;
|
||
} else if (this.submitter.matches("input")) {
|
||
const input = this.submitter;
|
||
this.originalSubmitText = input.value;
|
||
input.value = this.submitsWith;
|
||
}
|
||
}
|
||
|
||
resetSubmitterText() {
|
||
if (!this.submitter || !this.originalSubmitText) return
|
||
|
||
if (this.submitter.matches("button")) {
|
||
this.submitter.innerHTML = this.originalSubmitText;
|
||
} else if (this.submitter.matches("input")) {
|
||
const input = this.submitter;
|
||
input.value = this.originalSubmitText;
|
||
}
|
||
}
|
||
|
||
requestMustRedirect(request) {
|
||
return !request.isSafe && this.mustRedirect
|
||
}
|
||
|
||
requestAcceptsTurboStreamResponse(request) {
|
||
return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement)
|
||
}
|
||
|
||
get submitsWith() {
|
||
return this.submitter?.getAttribute("data-turbo-submits-with")
|
||
}
|
||
}
|
||
|
||
function buildFormData(formElement, submitter) {
|
||
const formData = new FormData(formElement);
|
||
const name = submitter?.getAttribute("name");
|
||
const value = submitter?.getAttribute("value");
|
||
|
||
if (name) {
|
||
formData.append(name, value || "");
|
||
}
|
||
|
||
return formData
|
||
}
|
||
|
||
function getCookieValue(cookieName) {
|
||
if (cookieName != null) {
|
||
const cookies = document.cookie ? document.cookie.split("; ") : [];
|
||
const cookie = cookies.find((cookie) => cookie.startsWith(cookieName));
|
||
if (cookie) {
|
||
const value = cookie.split("=").slice(1).join("=");
|
||
return value ? decodeURIComponent(value) : undefined
|
||
}
|
||
}
|
||
}
|
||
|
||
function responseSucceededWithoutRedirect(response) {
|
||
return response.statusCode == 200 && !response.redirected
|
||
}
|
||
|
||
function getFormAction(formElement, submitter) {
|
||
const formElementAction = typeof formElement.action === "string" ? formElement.action : null;
|
||
|
||
if (submitter?.hasAttribute("formaction")) {
|
||
return submitter.getAttribute("formaction") || ""
|
||
} else {
|
||
return formElement.getAttribute("action") || formElementAction || ""
|
||
}
|
||
}
|
||
|
||
function getAction(formAction, fetchMethod) {
|
||
const action = expandURL(formAction);
|
||
|
||
if (isSafe(fetchMethod)) {
|
||
action.search = "";
|
||
}
|
||
|
||
return action
|
||
}
|
||
|
||
function getMethod(formElement, submitter) {
|
||
const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "";
|
||
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
|
||
}
|
||
|
||
function getEnctype(formElement, submitter) {
|
||
return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
|
||
}
|
||
|
||
class Snapshot {
|
||
constructor(element) {
|
||
this.element = element;
|
||
}
|
||
|
||
get activeElement() {
|
||
return this.element.ownerDocument.activeElement
|
||
}
|
||
|
||
get children() {
|
||
return [...this.element.children]
|
||
}
|
||
|
||
hasAnchor(anchor) {
|
||
return this.getElementForAnchor(anchor) != null
|
||
}
|
||
|
||
getElementForAnchor(anchor) {
|
||
return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
|
||
}
|
||
|
||
get isConnected() {
|
||
return this.element.isConnected
|
||
}
|
||
|
||
get firstAutofocusableElement() {
|
||
return queryAutofocusableElement(this.element)
|
||
}
|
||
|
||
get permanentElements() {
|
||
return queryPermanentElementsAll(this.element)
|
||
}
|
||
|
||
getPermanentElementById(id) {
|
||
return getPermanentElementById(this.element, id)
|
||
}
|
||
|
||
getPermanentElementMapForSnapshot(snapshot) {
|
||
const permanentElementMap = {};
|
||
|
||
for (const currentPermanentElement of this.permanentElements) {
|
||
const { id } = currentPermanentElement;
|
||
const newPermanentElement = snapshot.getPermanentElementById(id);
|
||
if (newPermanentElement) {
|
||
permanentElementMap[id] = [currentPermanentElement, newPermanentElement];
|
||
}
|
||
}
|
||
|
||
return permanentElementMap
|
||
}
|
||
}
|
||
|
||
function getPermanentElementById(node, id) {
|
||
return node.querySelector(`#${id}[data-turbo-permanent]`)
|
||
}
|
||
|
||
function queryPermanentElementsAll(node) {
|
||
return node.querySelectorAll("[id][data-turbo-permanent]")
|
||
}
|
||
|
||
class FormSubmitObserver {
|
||
started = false
|
||
|
||
constructor(delegate, eventTarget) {
|
||
this.delegate = delegate;
|
||
this.eventTarget = eventTarget;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
this.eventTarget.addEventListener("submit", this.submitCaptured, true);
|
||
this.started = true;
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
this.eventTarget.removeEventListener("submit", this.submitCaptured, true);
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
submitCaptured = () => {
|
||
this.eventTarget.removeEventListener("submit", this.submitBubbled, false);
|
||
this.eventTarget.addEventListener("submit", this.submitBubbled, false);
|
||
}
|
||
|
||
submitBubbled = (event) => {
|
||
if (!event.defaultPrevented) {
|
||
const form = event.target instanceof HTMLFormElement ? event.target : undefined;
|
||
const submitter = event.submitter || undefined;
|
||
|
||
if (
|
||
form &&
|
||
submissionDoesNotDismissDialog(form, submitter) &&
|
||
submissionDoesNotTargetIFrame(form, submitter) &&
|
||
this.delegate.willSubmitForm(form, submitter)
|
||
) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
this.delegate.formSubmitted(form, submitter);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function submissionDoesNotDismissDialog(form, submitter) {
|
||
const method = submitter?.getAttribute("formmethod") || form.getAttribute("method");
|
||
|
||
return method != "dialog"
|
||
}
|
||
|
||
function submissionDoesNotTargetIFrame(form, submitter) {
|
||
const target = submitter?.getAttribute("formtarget") || form.getAttribute("target");
|
||
|
||
return doesNotTargetIFrame(target)
|
||
}
|
||
|
||
class View {
|
||
#resolveRenderPromise = (_value) => {}
|
||
#resolveInterceptionPromise = (_value) => {}
|
||
|
||
constructor(delegate, element) {
|
||
this.delegate = delegate;
|
||
this.element = element;
|
||
}
|
||
|
||
// Scrolling
|
||
|
||
scrollToAnchor(anchor) {
|
||
const element = this.snapshot.getElementForAnchor(anchor);
|
||
if (element) {
|
||
this.scrollToElement(element);
|
||
this.focusElement(element);
|
||
} else {
|
||
this.scrollToPosition({ x: 0, y: 0 });
|
||
}
|
||
}
|
||
|
||
scrollToAnchorFromLocation(location) {
|
||
this.scrollToAnchor(getAnchor(location));
|
||
}
|
||
|
||
scrollToElement(element) {
|
||
element.scrollIntoView();
|
||
}
|
||
|
||
focusElement(element) {
|
||
if (element instanceof HTMLElement) {
|
||
if (element.hasAttribute("tabindex")) {
|
||
element.focus();
|
||
} else {
|
||
element.setAttribute("tabindex", "-1");
|
||
element.focus();
|
||
element.removeAttribute("tabindex");
|
||
}
|
||
}
|
||
}
|
||
|
||
scrollToPosition({ x, y }) {
|
||
this.scrollRoot.scrollTo(x, y);
|
||
}
|
||
|
||
scrollToTop() {
|
||
this.scrollToPosition({ x: 0, y: 0 });
|
||
}
|
||
|
||
get scrollRoot() {
|
||
return window
|
||
}
|
||
|
||
// Rendering
|
||
|
||
async render(renderer) {
|
||
const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer;
|
||
|
||
// A workaround to ignore tracked element mismatch reloads when performing
|
||
// a promoted Visit from a frame navigation
|
||
const shouldInvalidate = willRender;
|
||
|
||
if (shouldRender) {
|
||
try {
|
||
this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve));
|
||
this.renderer = renderer;
|
||
await this.prepareToRenderSnapshot(renderer);
|
||
|
||
const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
|
||
const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod };
|
||
const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
|
||
if (!immediateRender) await renderInterception;
|
||
|
||
await this.renderSnapshot(renderer);
|
||
this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod);
|
||
this.delegate.preloadOnLoadLinksForView(this.element);
|
||
this.finishRenderingSnapshot(renderer);
|
||
} finally {
|
||
delete this.renderer;
|
||
this.#resolveRenderPromise(undefined);
|
||
delete this.renderPromise;
|
||
}
|
||
} else if (shouldInvalidate) {
|
||
this.invalidate(renderer.reloadReason);
|
||
}
|
||
}
|
||
|
||
invalidate(reason) {
|
||
this.delegate.viewInvalidated(reason);
|
||
}
|
||
|
||
async prepareToRenderSnapshot(renderer) {
|
||
this.markAsPreview(renderer.isPreview);
|
||
await renderer.prepareToRender();
|
||
}
|
||
|
||
markAsPreview(isPreview) {
|
||
if (isPreview) {
|
||
this.element.setAttribute("data-turbo-preview", "");
|
||
} else {
|
||
this.element.removeAttribute("data-turbo-preview");
|
||
}
|
||
}
|
||
|
||
markVisitDirection(direction) {
|
||
this.element.setAttribute("data-turbo-visit-direction", direction);
|
||
}
|
||
|
||
unmarkVisitDirection() {
|
||
this.element.removeAttribute("data-turbo-visit-direction");
|
||
}
|
||
|
||
async renderSnapshot(renderer) {
|
||
await renderer.render();
|
||
}
|
||
|
||
finishRenderingSnapshot(renderer) {
|
||
renderer.finishRendering();
|
||
}
|
||
}
|
||
|
||
class FrameView extends View {
|
||
missing() {
|
||
this.element.innerHTML = `<strong class="turbo-frame-error">Content missing</strong>`;
|
||
}
|
||
|
||
get snapshot() {
|
||
return new Snapshot(this.element)
|
||
}
|
||
}
|
||
|
||
class LinkInterceptor {
|
||
constructor(delegate, element) {
|
||
this.delegate = delegate;
|
||
this.element = element;
|
||
}
|
||
|
||
start() {
|
||
this.element.addEventListener("click", this.clickBubbled);
|
||
document.addEventListener("turbo:click", this.linkClicked);
|
||
document.addEventListener("turbo:before-visit", this.willVisit);
|
||
}
|
||
|
||
stop() {
|
||
this.element.removeEventListener("click", this.clickBubbled);
|
||
document.removeEventListener("turbo:click", this.linkClicked);
|
||
document.removeEventListener("turbo:before-visit", this.willVisit);
|
||
}
|
||
|
||
clickBubbled = (event) => {
|
||
if (this.clickEventIsSignificant(event)) {
|
||
this.clickEvent = event;
|
||
} else {
|
||
delete this.clickEvent;
|
||
}
|
||
}
|
||
|
||
linkClicked = (event) => {
|
||
if (this.clickEvent && this.clickEventIsSignificant(event)) {
|
||
if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
|
||
this.clickEvent.preventDefault();
|
||
event.preventDefault();
|
||
this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent);
|
||
}
|
||
}
|
||
delete this.clickEvent;
|
||
}
|
||
|
||
willVisit = (_event) => {
|
||
delete this.clickEvent;
|
||
}
|
||
|
||
clickEventIsSignificant(event) {
|
||
const target = event.composed ? event.target?.parentElement : event.target;
|
||
const element = findLinkFromClickTarget(target) || target;
|
||
|
||
return element instanceof Element && element.closest("turbo-frame, html") == this.element
|
||
}
|
||
}
|
||
|
||
class LinkClickObserver {
|
||
started = false
|
||
|
||
constructor(delegate, eventTarget) {
|
||
this.delegate = delegate;
|
||
this.eventTarget = eventTarget;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
this.eventTarget.addEventListener("click", this.clickCaptured, true);
|
||
this.started = true;
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
this.eventTarget.removeEventListener("click", this.clickCaptured, true);
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
clickCaptured = () => {
|
||
this.eventTarget.removeEventListener("click", this.clickBubbled, false);
|
||
this.eventTarget.addEventListener("click", this.clickBubbled, false);
|
||
}
|
||
|
||
clickBubbled = (event) => {
|
||
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
|
||
const target = (event.composedPath && event.composedPath()[0]) || event.target;
|
||
const link = findLinkFromClickTarget(target);
|
||
if (link && doesNotTargetIFrame(link.target)) {
|
||
const location = getLocationForLink(link);
|
||
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
|
||
event.preventDefault();
|
||
this.delegate.followedLinkToLocation(link, location);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
clickEventIsSignificant(event) {
|
||
return !(
|
||
(event.target && event.target.isContentEditable) ||
|
||
event.defaultPrevented ||
|
||
event.which > 1 ||
|
||
event.altKey ||
|
||
event.ctrlKey ||
|
||
event.metaKey ||
|
||
event.shiftKey
|
||
)
|
||
}
|
||
}
|
||
|
||
class FormLinkClickObserver {
|
||
constructor(delegate, element) {
|
||
this.delegate = delegate;
|
||
this.linkInterceptor = new LinkClickObserver(this, element);
|
||
}
|
||
|
||
start() {
|
||
this.linkInterceptor.start();
|
||
}
|
||
|
||
stop() {
|
||
this.linkInterceptor.stop();
|
||
}
|
||
|
||
// Link hover observer delegate
|
||
|
||
canPrefetchRequestToLocation(link, location) {
|
||
return false
|
||
}
|
||
|
||
prefetchAndCacheRequestToLocation(link, location) {
|
||
return
|
||
}
|
||
|
||
// Link click observer delegate
|
||
|
||
willFollowLinkToLocation(link, location, originalEvent) {
|
||
return (
|
||
this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
|
||
(link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
|
||
)
|
||
}
|
||
|
||
followedLinkToLocation(link, location) {
|
||
const form = document.createElement("form");
|
||
|
||
const type = "hidden";
|
||
for (const [name, value] of location.searchParams) {
|
||
form.append(Object.assign(document.createElement("input"), { type, name, value }));
|
||
}
|
||
|
||
const action = Object.assign(location, { search: "" });
|
||
form.setAttribute("data-turbo", "true");
|
||
form.setAttribute("action", action.href);
|
||
form.setAttribute("hidden", "");
|
||
|
||
const method = link.getAttribute("data-turbo-method");
|
||
if (method) form.setAttribute("method", method);
|
||
|
||
const turboFrame = link.getAttribute("data-turbo-frame");
|
||
if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame);
|
||
|
||
const turboAction = getVisitAction(link);
|
||
if (turboAction) form.setAttribute("data-turbo-action", turboAction);
|
||
|
||
const turboConfirm = link.getAttribute("data-turbo-confirm");
|
||
if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm);
|
||
|
||
const turboStream = link.hasAttribute("data-turbo-stream");
|
||
if (turboStream) form.setAttribute("data-turbo-stream", "");
|
||
|
||
this.delegate.submittedFormLinkToLocation(link, location, form);
|
||
|
||
document.body.appendChild(form);
|
||
form.addEventListener("turbo:submit-end", () => form.remove(), { once: true });
|
||
requestAnimationFrame(() => form.requestSubmit());
|
||
}
|
||
}
|
||
|
||
class Bardo {
|
||
static async preservingPermanentElements(delegate, permanentElementMap, callback) {
|
||
const bardo = new this(delegate, permanentElementMap);
|
||
bardo.enter();
|
||
await callback();
|
||
bardo.leave();
|
||
}
|
||
|
||
constructor(delegate, permanentElementMap) {
|
||
this.delegate = delegate;
|
||
this.permanentElementMap = permanentElementMap;
|
||
}
|
||
|
||
enter() {
|
||
for (const id in this.permanentElementMap) {
|
||
const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
|
||
this.delegate.enteringBardo(currentPermanentElement, newPermanentElement);
|
||
this.replaceNewPermanentElementWithPlaceholder(newPermanentElement);
|
||
}
|
||
}
|
||
|
||
leave() {
|
||
for (const id in this.permanentElementMap) {
|
||
const [currentPermanentElement] = this.permanentElementMap[id];
|
||
this.replaceCurrentPermanentElementWithClone(currentPermanentElement);
|
||
this.replacePlaceholderWithPermanentElement(currentPermanentElement);
|
||
this.delegate.leavingBardo(currentPermanentElement);
|
||
}
|
||
}
|
||
|
||
replaceNewPermanentElementWithPlaceholder(permanentElement) {
|
||
const placeholder = createPlaceholderForPermanentElement(permanentElement);
|
||
permanentElement.replaceWith(placeholder);
|
||
}
|
||
|
||
replaceCurrentPermanentElementWithClone(permanentElement) {
|
||
const clone = permanentElement.cloneNode(true);
|
||
permanentElement.replaceWith(clone);
|
||
}
|
||
|
||
replacePlaceholderWithPermanentElement(permanentElement) {
|
||
const placeholder = this.getPlaceholderById(permanentElement.id);
|
||
placeholder?.replaceWith(permanentElement);
|
||
}
|
||
|
||
getPlaceholderById(id) {
|
||
return this.placeholders.find((element) => element.content == id)
|
||
}
|
||
|
||
get placeholders() {
|
||
return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]
|
||
}
|
||
}
|
||
|
||
function createPlaceholderForPermanentElement(permanentElement) {
|
||
const element = document.createElement("meta");
|
||
element.setAttribute("name", "turbo-permanent-placeholder");
|
||
element.setAttribute("content", permanentElement.id);
|
||
return element
|
||
}
|
||
|
||
class Renderer {
|
||
#activeElement = null
|
||
|
||
constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
|
||
this.currentSnapshot = currentSnapshot;
|
||
this.newSnapshot = newSnapshot;
|
||
this.isPreview = isPreview;
|
||
this.willRender = willRender;
|
||
this.renderElement = renderElement;
|
||
this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }));
|
||
}
|
||
|
||
get shouldRender() {
|
||
return true
|
||
}
|
||
|
||
get shouldAutofocus() {
|
||
return true
|
||
}
|
||
|
||
get reloadReason() {
|
||
return
|
||
}
|
||
|
||
prepareToRender() {
|
||
return
|
||
}
|
||
|
||
render() {
|
||
// Abstract method
|
||
}
|
||
|
||
finishRendering() {
|
||
if (this.resolvingFunctions) {
|
||
this.resolvingFunctions.resolve();
|
||
delete this.resolvingFunctions;
|
||
}
|
||
}
|
||
|
||
async preservingPermanentElements(callback) {
|
||
await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
|
||
}
|
||
|
||
focusFirstAutofocusableElement() {
|
||
if (this.shouldAutofocus) {
|
||
const element = this.connectedSnapshot.firstAutofocusableElement;
|
||
if (element) {
|
||
element.focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bardo delegate
|
||
|
||
enteringBardo(currentPermanentElement) {
|
||
if (this.#activeElement) return
|
||
|
||
if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) {
|
||
this.#activeElement = this.currentSnapshot.activeElement;
|
||
}
|
||
}
|
||
|
||
leavingBardo(currentPermanentElement) {
|
||
if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) {
|
||
this.#activeElement.focus();
|
||
|
||
this.#activeElement = null;
|
||
}
|
||
}
|
||
|
||
get connectedSnapshot() {
|
||
return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot
|
||
}
|
||
|
||
get currentElement() {
|
||
return this.currentSnapshot.element
|
||
}
|
||
|
||
get newElement() {
|
||
return this.newSnapshot.element
|
||
}
|
||
|
||
get permanentElementMap() {
|
||
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
|
||
}
|
||
|
||
get renderMethod() {
|
||
return "replace"
|
||
}
|
||
}
|
||
|
||
class FrameRenderer extends Renderer {
|
||
static renderElement(currentElement, newElement) {
|
||
const destinationRange = document.createRange();
|
||
destinationRange.selectNodeContents(currentElement);
|
||
destinationRange.deleteContents();
|
||
|
||
const frameElement = newElement;
|
||
const sourceRange = frameElement.ownerDocument?.createRange();
|
||
if (sourceRange) {
|
||
sourceRange.selectNodeContents(frameElement);
|
||
currentElement.appendChild(sourceRange.extractContents());
|
||
}
|
||
}
|
||
|
||
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
|
||
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
get shouldRender() {
|
||
return true
|
||
}
|
||
|
||
async render() {
|
||
await nextRepaint();
|
||
this.preservingPermanentElements(() => {
|
||
this.loadFrameElement();
|
||
});
|
||
this.scrollFrameIntoView();
|
||
await nextRepaint();
|
||
this.focusFirstAutofocusableElement();
|
||
await nextRepaint();
|
||
this.activateScriptElements();
|
||
}
|
||
|
||
loadFrameElement() {
|
||
this.delegate.willRenderFrame(this.currentElement, this.newElement);
|
||
this.renderElement(this.currentElement, this.newElement);
|
||
}
|
||
|
||
scrollFrameIntoView() {
|
||
if (this.currentElement.autoscroll || this.newElement.autoscroll) {
|
||
const element = this.currentElement.firstElementChild;
|
||
const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end");
|
||
const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto");
|
||
|
||
if (element) {
|
||
element.scrollIntoView({ block, behavior });
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
activateScriptElements() {
|
||
for (const inertScriptElement of this.newScriptElements) {
|
||
const activatedScriptElement = activateScriptElement(inertScriptElement);
|
||
inertScriptElement.replaceWith(activatedScriptElement);
|
||
}
|
||
}
|
||
|
||
get newScriptElements() {
|
||
return this.currentElement.querySelectorAll("script")
|
||
}
|
||
}
|
||
|
||
function readScrollLogicalPosition(value, defaultValue) {
|
||
if (value == "end" || value == "start" || value == "center" || value == "nearest") {
|
||
return value
|
||
} else {
|
||
return defaultValue
|
||
}
|
||
}
|
||
|
||
function readScrollBehavior(value, defaultValue) {
|
||
if (value == "auto" || value == "smooth") {
|
||
return value
|
||
} else {
|
||
return defaultValue
|
||
}
|
||
}
|
||
|
||
class ProgressBar {
|
||
static animationDuration = 300 /*ms*/
|
||
|
||
static get defaultCSS() {
|
||
return unindent`
|
||
.turbo-progress-bar {
|
||
position: fixed;
|
||
display: block;
|
||
top: 0;
|
||
left: 0;
|
||
height: 3px;
|
||
background: #0076ff;
|
||
z-index: 2147483647;
|
||
transition:
|
||
width ${ProgressBar.animationDuration}ms ease-out,
|
||
opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
|
||
transform: translate3d(0, 0, 0);
|
||
}
|
||
`
|
||
}
|
||
|
||
hiding = false
|
||
value = 0
|
||
visible = false
|
||
|
||
constructor() {
|
||
this.stylesheetElement = this.createStylesheetElement();
|
||
this.progressElement = this.createProgressElement();
|
||
this.installStylesheetElement();
|
||
this.setValue(0);
|
||
}
|
||
|
||
show() {
|
||
if (!this.visible) {
|
||
this.visible = true;
|
||
this.installProgressElement();
|
||
this.startTrickling();
|
||
}
|
||
}
|
||
|
||
hide() {
|
||
if (this.visible && !this.hiding) {
|
||
this.hiding = true;
|
||
this.fadeProgressElement(() => {
|
||
this.uninstallProgressElement();
|
||
this.stopTrickling();
|
||
this.visible = false;
|
||
this.hiding = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
setValue(value) {
|
||
this.value = value;
|
||
this.refresh();
|
||
}
|
||
|
||
// Private
|
||
|
||
installStylesheetElement() {
|
||
document.head.insertBefore(this.stylesheetElement, document.head.firstChild);
|
||
}
|
||
|
||
installProgressElement() {
|
||
this.progressElement.style.width = "0";
|
||
this.progressElement.style.opacity = "1";
|
||
document.documentElement.insertBefore(this.progressElement, document.body);
|
||
this.refresh();
|
||
}
|
||
|
||
fadeProgressElement(callback) {
|
||
this.progressElement.style.opacity = "0";
|
||
setTimeout(callback, ProgressBar.animationDuration * 1.5);
|
||
}
|
||
|
||
uninstallProgressElement() {
|
||
if (this.progressElement.parentNode) {
|
||
document.documentElement.removeChild(this.progressElement);
|
||
}
|
||
}
|
||
|
||
startTrickling() {
|
||
if (!this.trickleInterval) {
|
||
this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration);
|
||
}
|
||
}
|
||
|
||
stopTrickling() {
|
||
window.clearInterval(this.trickleInterval);
|
||
delete this.trickleInterval;
|
||
}
|
||
|
||
trickle = () => {
|
||
this.setValue(this.value + Math.random() / 100);
|
||
}
|
||
|
||
refresh() {
|
||
requestAnimationFrame(() => {
|
||
this.progressElement.style.width = `${10 + this.value * 90}%`;
|
||
});
|
||
}
|
||
|
||
createStylesheetElement() {
|
||
const element = document.createElement("style");
|
||
element.type = "text/css";
|
||
element.textContent = ProgressBar.defaultCSS;
|
||
if (this.cspNonce) {
|
||
element.nonce = this.cspNonce;
|
||
}
|
||
return element
|
||
}
|
||
|
||
createProgressElement() {
|
||
const element = document.createElement("div");
|
||
element.className = "turbo-progress-bar";
|
||
return element
|
||
}
|
||
|
||
get cspNonce() {
|
||
return getMetaContent("csp-nonce")
|
||
}
|
||
}
|
||
|
||
class HeadSnapshot extends Snapshot {
|
||
detailsByOuterHTML = this.children
|
||
.filter((element) => !elementIsNoscript(element))
|
||
.map((element) => elementWithoutNonce(element))
|
||
.reduce((result, element) => {
|
||
const { outerHTML } = element;
|
||
const details =
|
||
outerHTML in result
|
||
? result[outerHTML]
|
||
: {
|
||
type: elementType(element),
|
||
tracked: elementIsTracked(element),
|
||
elements: []
|
||
};
|
||
return {
|
||
...result,
|
||
[outerHTML]: {
|
||
...details,
|
||
elements: [...details.elements, element]
|
||
}
|
||
}
|
||
}, {})
|
||
|
||
get trackedElementSignature() {
|
||
return Object.keys(this.detailsByOuterHTML)
|
||
.filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked)
|
||
.join("")
|
||
}
|
||
|
||
getScriptElementsNotInSnapshot(snapshot) {
|
||
return this.getElementsMatchingTypeNotInSnapshot("script", snapshot)
|
||
}
|
||
|
||
getStylesheetElementsNotInSnapshot(snapshot) {
|
||
return this.getElementsMatchingTypeNotInSnapshot("stylesheet", snapshot)
|
||
}
|
||
|
||
getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) {
|
||
return Object.keys(this.detailsByOuterHTML)
|
||
.filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML))
|
||
.map((outerHTML) => this.detailsByOuterHTML[outerHTML])
|
||
.filter(({ type }) => type == matchedType)
|
||
.map(({ elements: [element] }) => element)
|
||
}
|
||
|
||
get provisionalElements() {
|
||
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
|
||
const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML];
|
||
if (type == null && !tracked) {
|
||
return [...result, ...elements]
|
||
} else if (elements.length > 1) {
|
||
return [...result, ...elements.slice(1)]
|
||
} else {
|
||
return result
|
||
}
|
||
}, [])
|
||
}
|
||
|
||
getMetaValue(name) {
|
||
const element = this.findMetaElementByName(name);
|
||
return element ? element.getAttribute("content") : null
|
||
}
|
||
|
||
findMetaElementByName(name) {
|
||
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
|
||
const {
|
||
elements: [element]
|
||
} = this.detailsByOuterHTML[outerHTML];
|
||
return elementIsMetaElementWithName(element, name) ? element : result
|
||
}, undefined | undefined)
|
||
}
|
||
}
|
||
|
||
function elementType(element) {
|
||
if (elementIsScript(element)) {
|
||
return "script"
|
||
} else if (elementIsStylesheet(element)) {
|
||
return "stylesheet"
|
||
}
|
||
}
|
||
|
||
function elementIsTracked(element) {
|
||
return element.getAttribute("data-turbo-track") == "reload"
|
||
}
|
||
|
||
function elementIsScript(element) {
|
||
const tagName = element.localName;
|
||
return tagName == "script"
|
||
}
|
||
|
||
function elementIsNoscript(element) {
|
||
const tagName = element.localName;
|
||
return tagName == "noscript"
|
||
}
|
||
|
||
function elementIsStylesheet(element) {
|
||
const tagName = element.localName;
|
||
return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet")
|
||
}
|
||
|
||
function elementIsMetaElementWithName(element, name) {
|
||
const tagName = element.localName;
|
||
return tagName == "meta" && element.getAttribute("name") == name
|
||
}
|
||
|
||
function elementWithoutNonce(element) {
|
||
if (element.hasAttribute("nonce")) {
|
||
element.setAttribute("nonce", "");
|
||
}
|
||
|
||
return element
|
||
}
|
||
|
||
class PageSnapshot extends Snapshot {
|
||
static fromHTMLString(html = "") {
|
||
return this.fromDocument(parseHTMLDocument(html))
|
||
}
|
||
|
||
static fromElement(element) {
|
||
return this.fromDocument(element.ownerDocument)
|
||
}
|
||
|
||
static fromDocument({ documentElement, body, head }) {
|
||
return new this(documentElement, body, new HeadSnapshot(head))
|
||
}
|
||
|
||
constructor(documentElement, body, headSnapshot) {
|
||
super(body);
|
||
this.documentElement = documentElement;
|
||
this.headSnapshot = headSnapshot;
|
||
}
|
||
|
||
clone() {
|
||
const clonedElement = this.element.cloneNode(true);
|
||
|
||
const selectElements = this.element.querySelectorAll("select");
|
||
const clonedSelectElements = clonedElement.querySelectorAll("select");
|
||
|
||
for (const [index, source] of selectElements.entries()) {
|
||
const clone = clonedSelectElements[index];
|
||
for (const option of clone.selectedOptions) option.selected = false;
|
||
for (const option of source.selectedOptions) clone.options[option.index].selected = true;
|
||
}
|
||
|
||
for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) {
|
||
clonedPasswordInput.value = "";
|
||
}
|
||
|
||
return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot)
|
||
}
|
||
|
||
get lang() {
|
||
return this.documentElement.getAttribute("lang")
|
||
}
|
||
|
||
get headElement() {
|
||
return this.headSnapshot.element
|
||
}
|
||
|
||
get rootLocation() {
|
||
const root = this.getSetting("root") ?? "/";
|
||
return expandURL(root)
|
||
}
|
||
|
||
get cacheControlValue() {
|
||
return this.getSetting("cache-control")
|
||
}
|
||
|
||
get isPreviewable() {
|
||
return this.cacheControlValue != "no-preview"
|
||
}
|
||
|
||
get isCacheable() {
|
||
return this.cacheControlValue != "no-cache"
|
||
}
|
||
|
||
get isVisitable() {
|
||
return this.getSetting("visit-control") != "reload"
|
||
}
|
||
|
||
get prefersViewTransitions() {
|
||
return this.headSnapshot.getMetaValue("view-transition") === "same-origin"
|
||
}
|
||
|
||
get shouldMorphPage() {
|
||
return this.getSetting("refresh-method") === "morph"
|
||
}
|
||
|
||
get shouldPreserveScrollPosition() {
|
||
return this.getSetting("refresh-scroll") === "preserve"
|
||
}
|
||
|
||
// Private
|
||
|
||
getSetting(name) {
|
||
return this.headSnapshot.getMetaValue(`turbo-${name}`)
|
||
}
|
||
}
|
||
|
||
class ViewTransitioner {
|
||
#viewTransitionStarted = false
|
||
#lastOperation = Promise.resolve()
|
||
|
||
renderChange(useViewTransition, render) {
|
||
if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) {
|
||
this.#viewTransitionStarted = true;
|
||
this.#lastOperation = this.#lastOperation.then(async () => {
|
||
await document.startViewTransition(render).finished;
|
||
});
|
||
} else {
|
||
this.#lastOperation = this.#lastOperation.then(render);
|
||
}
|
||
|
||
return this.#lastOperation
|
||
}
|
||
|
||
get viewTransitionsAvailable() {
|
||
return document.startViewTransition
|
||
}
|
||
}
|
||
|
||
const defaultOptions = {
|
||
action: "advance",
|
||
historyChanged: false,
|
||
visitCachedSnapshot: () => {},
|
||
willRender: true,
|
||
updateHistory: true,
|
||
shouldCacheSnapshot: true,
|
||
acceptsStreamResponse: false
|
||
};
|
||
|
||
const TimingMetric = {
|
||
visitStart: "visitStart",
|
||
requestStart: "requestStart",
|
||
requestEnd: "requestEnd",
|
||
visitEnd: "visitEnd"
|
||
};
|
||
|
||
const VisitState = {
|
||
initialized: "initialized",
|
||
started: "started",
|
||
canceled: "canceled",
|
||
failed: "failed",
|
||
completed: "completed"
|
||
};
|
||
|
||
const SystemStatusCode = {
|
||
networkFailure: 0,
|
||
timeoutFailure: -1,
|
||
contentTypeMismatch: -2
|
||
};
|
||
|
||
const Direction = {
|
||
advance: "forward",
|
||
restore: "back",
|
||
replace: "none"
|
||
};
|
||
|
||
class Visit {
|
||
identifier = uuid() // Required by turbo-ios
|
||
timingMetrics = {}
|
||
|
||
followedRedirect = false
|
||
historyChanged = false
|
||
scrolled = false
|
||
shouldCacheSnapshot = true
|
||
acceptsStreamResponse = false
|
||
snapshotCached = false
|
||
state = VisitState.initialized
|
||
viewTransitioner = new ViewTransitioner()
|
||
|
||
constructor(delegate, location, restorationIdentifier, options = {}) {
|
||
this.delegate = delegate;
|
||
this.location = location;
|
||
this.restorationIdentifier = restorationIdentifier || uuid();
|
||
|
||
const {
|
||
action,
|
||
historyChanged,
|
||
referrer,
|
||
snapshot,
|
||
snapshotHTML,
|
||
response,
|
||
visitCachedSnapshot,
|
||
willRender,
|
||
updateHistory,
|
||
shouldCacheSnapshot,
|
||
acceptsStreamResponse,
|
||
direction
|
||
} = {
|
||
...defaultOptions,
|
||
...options
|
||
};
|
||
this.action = action;
|
||
this.historyChanged = historyChanged;
|
||
this.referrer = referrer;
|
||
this.snapshot = snapshot;
|
||
this.snapshotHTML = snapshotHTML;
|
||
this.response = response;
|
||
this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action);
|
||
this.isPageRefresh = this.view.isPageRefresh(this);
|
||
this.visitCachedSnapshot = visitCachedSnapshot;
|
||
this.willRender = willRender;
|
||
this.updateHistory = updateHistory;
|
||
this.scrolled = !willRender;
|
||
this.shouldCacheSnapshot = shouldCacheSnapshot;
|
||
this.acceptsStreamResponse = acceptsStreamResponse;
|
||
this.direction = direction || Direction[action];
|
||
}
|
||
|
||
get adapter() {
|
||
return this.delegate.adapter
|
||
}
|
||
|
||
get view() {
|
||
return this.delegate.view
|
||
}
|
||
|
||
get history() {
|
||
return this.delegate.history
|
||
}
|
||
|
||
get restorationData() {
|
||
return this.history.getRestorationDataForIdentifier(this.restorationIdentifier)
|
||
}
|
||
|
||
get silent() {
|
||
return this.isSamePage
|
||
}
|
||
|
||
start() {
|
||
if (this.state == VisitState.initialized) {
|
||
this.recordTimingMetric(TimingMetric.visitStart);
|
||
this.state = VisitState.started;
|
||
this.adapter.visitStarted(this);
|
||
this.delegate.visitStarted(this);
|
||
}
|
||
}
|
||
|
||
cancel() {
|
||
if (this.state == VisitState.started) {
|
||
if (this.request) {
|
||
this.request.cancel();
|
||
}
|
||
this.cancelRender();
|
||
this.state = VisitState.canceled;
|
||
}
|
||
}
|
||
|
||
complete() {
|
||
if (this.state == VisitState.started) {
|
||
this.recordTimingMetric(TimingMetric.visitEnd);
|
||
this.adapter.visitCompleted(this);
|
||
this.state = VisitState.completed;
|
||
this.followRedirect();
|
||
|
||
if (!this.followedRedirect) {
|
||
this.delegate.visitCompleted(this);
|
||
}
|
||
}
|
||
}
|
||
|
||
fail() {
|
||
if (this.state == VisitState.started) {
|
||
this.state = VisitState.failed;
|
||
this.adapter.visitFailed(this);
|
||
this.delegate.visitCompleted(this);
|
||
}
|
||
}
|
||
|
||
changeHistory() {
|
||
if (!this.historyChanged && this.updateHistory) {
|
||
const actionForHistory = this.location.href === this.referrer?.href ? "replace" : this.action;
|
||
const method = getHistoryMethodForAction(actionForHistory);
|
||
this.history.update(method, this.location, this.restorationIdentifier);
|
||
this.historyChanged = true;
|
||
}
|
||
}
|
||
|
||
issueRequest() {
|
||
if (this.hasPreloadedResponse()) {
|
||
this.simulateRequest();
|
||
} else if (this.shouldIssueRequest() && !this.request) {
|
||
this.request = new FetchRequest(this, FetchMethod.get, this.location);
|
||
this.request.perform();
|
||
}
|
||
}
|
||
|
||
simulateRequest() {
|
||
if (this.response) {
|
||
this.startRequest();
|
||
this.recordResponse();
|
||
this.finishRequest();
|
||
}
|
||
}
|
||
|
||
startRequest() {
|
||
this.recordTimingMetric(TimingMetric.requestStart);
|
||
this.adapter.visitRequestStarted(this);
|
||
}
|
||
|
||
recordResponse(response = this.response) {
|
||
this.response = response;
|
||
if (response) {
|
||
const { statusCode } = response;
|
||
if (isSuccessful(statusCode)) {
|
||
this.adapter.visitRequestCompleted(this);
|
||
} else {
|
||
this.adapter.visitRequestFailedWithStatusCode(this, statusCode);
|
||
}
|
||
}
|
||
}
|
||
|
||
finishRequest() {
|
||
this.recordTimingMetric(TimingMetric.requestEnd);
|
||
this.adapter.visitRequestFinished(this);
|
||
}
|
||
|
||
loadResponse() {
|
||
if (this.response) {
|
||
const { statusCode, responseHTML } = this.response;
|
||
this.render(async () => {
|
||
if (this.shouldCacheSnapshot) this.cacheSnapshot();
|
||
if (this.view.renderPromise) await this.view.renderPromise;
|
||
|
||
if (isSuccessful(statusCode) && responseHTML != null) {
|
||
const snapshot = PageSnapshot.fromHTMLString(responseHTML);
|
||
await this.renderPageSnapshot(snapshot, false);
|
||
|
||
this.adapter.visitRendered(this);
|
||
this.complete();
|
||
} else {
|
||
await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this);
|
||
this.adapter.visitRendered(this);
|
||
this.fail();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
getCachedSnapshot() {
|
||
const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot();
|
||
|
||
if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) {
|
||
if (this.action == "restore" || snapshot.isPreviewable) {
|
||
return snapshot
|
||
}
|
||
}
|
||
}
|
||
|
||
getPreloadedSnapshot() {
|
||
if (this.snapshotHTML) {
|
||
return PageSnapshot.fromHTMLString(this.snapshotHTML)
|
||
}
|
||
}
|
||
|
||
hasCachedSnapshot() {
|
||
return this.getCachedSnapshot() != null
|
||
}
|
||
|
||
loadCachedSnapshot() {
|
||
const snapshot = this.getCachedSnapshot();
|
||
if (snapshot) {
|
||
const isPreview = this.shouldIssueRequest();
|
||
this.render(async () => {
|
||
this.cacheSnapshot();
|
||
if (this.isSamePage || this.isPageRefresh) {
|
||
this.adapter.visitRendered(this);
|
||
} else {
|
||
if (this.view.renderPromise) await this.view.renderPromise;
|
||
|
||
await this.renderPageSnapshot(snapshot, isPreview);
|
||
|
||
this.adapter.visitRendered(this);
|
||
if (!isPreview) {
|
||
this.complete();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
followRedirect() {
|
||
if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) {
|
||
this.adapter.visitProposedToLocation(this.redirectedToLocation, {
|
||
action: "replace",
|
||
response: this.response,
|
||
shouldCacheSnapshot: false,
|
||
willRender: false
|
||
});
|
||
this.followedRedirect = true;
|
||
}
|
||
}
|
||
|
||
goToSamePageAnchor() {
|
||
if (this.isSamePage) {
|
||
this.render(async () => {
|
||
this.cacheSnapshot();
|
||
this.performScroll();
|
||
this.changeHistory();
|
||
this.adapter.visitRendered(this);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Fetch request delegate
|
||
|
||
prepareRequest(request) {
|
||
if (this.acceptsStreamResponse) {
|
||
request.acceptResponseType(StreamMessage.contentType);
|
||
}
|
||
}
|
||
|
||
requestStarted() {
|
||
this.startRequest();
|
||
}
|
||
|
||
requestPreventedHandlingResponse(_request, _response) {}
|
||
|
||
async requestSucceededWithResponse(request, response) {
|
||
const responseHTML = await response.responseHTML;
|
||
const { redirected, statusCode } = response;
|
||
if (responseHTML == undefined) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.contentTypeMismatch,
|
||
redirected
|
||
});
|
||
} else {
|
||
this.redirectedToLocation = response.redirected ? response.location : undefined;
|
||
this.recordResponse({ statusCode: statusCode, responseHTML, redirected });
|
||
}
|
||
}
|
||
|
||
async requestFailedWithResponse(request, response) {
|
||
const responseHTML = await response.responseHTML;
|
||
const { redirected, statusCode } = response;
|
||
if (responseHTML == undefined) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.contentTypeMismatch,
|
||
redirected
|
||
});
|
||
} else {
|
||
this.recordResponse({ statusCode: statusCode, responseHTML, redirected });
|
||
}
|
||
}
|
||
|
||
requestErrored(_request, _error) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.networkFailure,
|
||
redirected: false
|
||
});
|
||
}
|
||
|
||
requestFinished() {
|
||
this.finishRequest();
|
||
}
|
||
|
||
// Scrolling
|
||
|
||
performScroll() {
|
||
if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) {
|
||
if (this.action == "restore") {
|
||
this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop();
|
||
} else {
|
||
this.scrollToAnchor() || this.view.scrollToTop();
|
||
}
|
||
if (this.isSamePage) {
|
||
this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location);
|
||
}
|
||
|
||
this.scrolled = true;
|
||
}
|
||
}
|
||
|
||
scrollToRestoredPosition() {
|
||
const { scrollPosition } = this.restorationData;
|
||
if (scrollPosition) {
|
||
this.view.scrollToPosition(scrollPosition);
|
||
return true
|
||
}
|
||
}
|
||
|
||
scrollToAnchor() {
|
||
const anchor = getAnchor(this.location);
|
||
if (anchor != null) {
|
||
this.view.scrollToAnchor(anchor);
|
||
return true
|
||
}
|
||
}
|
||
|
||
// Instrumentation
|
||
|
||
recordTimingMetric(metric) {
|
||
this.timingMetrics[metric] = new Date().getTime();
|
||
}
|
||
|
||
getTimingMetrics() {
|
||
return { ...this.timingMetrics }
|
||
}
|
||
|
||
// Private
|
||
|
||
getHistoryMethodForAction(action) {
|
||
switch (action) {
|
||
case "replace":
|
||
return history.replaceState
|
||
case "advance":
|
||
case "restore":
|
||
return history.pushState
|
||
}
|
||
}
|
||
|
||
hasPreloadedResponse() {
|
||
return typeof this.response == "object"
|
||
}
|
||
|
||
shouldIssueRequest() {
|
||
if (this.isSamePage) {
|
||
return false
|
||
} else if (this.action == "restore") {
|
||
return !this.hasCachedSnapshot()
|
||
} else {
|
||
return this.willRender
|
||
}
|
||
}
|
||
|
||
cacheSnapshot() {
|
||
if (!this.snapshotCached) {
|
||
this.view.cacheSnapshot(this.snapshot).then((snapshot) => snapshot && this.visitCachedSnapshot(snapshot));
|
||
this.snapshotCached = true;
|
||
}
|
||
}
|
||
|
||
async render(callback) {
|
||
this.cancelRender();
|
||
this.frame = await nextRepaint();
|
||
await callback();
|
||
delete this.frame;
|
||
}
|
||
|
||
async renderPageSnapshot(snapshot, isPreview) {
|
||
await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => {
|
||
await this.view.renderPage(snapshot, isPreview, this.willRender, this);
|
||
this.performScroll();
|
||
});
|
||
}
|
||
|
||
cancelRender() {
|
||
if (this.frame) {
|
||
cancelAnimationFrame(this.frame);
|
||
delete this.frame;
|
||
}
|
||
}
|
||
}
|
||
|
||
function isSuccessful(statusCode) {
|
||
return statusCode >= 200 && statusCode < 300
|
||
}
|
||
|
||
class BrowserAdapter {
|
||
progressBar = new ProgressBar()
|
||
|
||
constructor(session) {
|
||
this.session = session;
|
||
}
|
||
|
||
visitProposedToLocation(location, options) {
|
||
if (locationIsVisitable(location, this.navigator.rootLocation)) {
|
||
this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options);
|
||
} else {
|
||
window.location.href = location.toString();
|
||
}
|
||
}
|
||
|
||
visitStarted(visit) {
|
||
this.location = visit.location;
|
||
visit.loadCachedSnapshot();
|
||
visit.issueRequest();
|
||
visit.goToSamePageAnchor();
|
||
}
|
||
|
||
visitRequestStarted(visit) {
|
||
this.progressBar.setValue(0);
|
||
if (visit.hasCachedSnapshot() || visit.action != "restore") {
|
||
this.showVisitProgressBarAfterDelay();
|
||
} else {
|
||
this.showProgressBar();
|
||
}
|
||
}
|
||
|
||
visitRequestCompleted(visit) {
|
||
visit.loadResponse();
|
||
}
|
||
|
||
visitRequestFailedWithStatusCode(visit, statusCode) {
|
||
switch (statusCode) {
|
||
case SystemStatusCode.networkFailure:
|
||
case SystemStatusCode.timeoutFailure:
|
||
case SystemStatusCode.contentTypeMismatch:
|
||
return this.reload({
|
||
reason: "request_failed",
|
||
context: {
|
||
statusCode
|
||
}
|
||
})
|
||
default:
|
||
return visit.loadResponse()
|
||
}
|
||
}
|
||
|
||
visitRequestFinished(_visit) {}
|
||
|
||
visitCompleted(_visit) {
|
||
this.progressBar.setValue(1);
|
||
this.hideVisitProgressBar();
|
||
}
|
||
|
||
pageInvalidated(reason) {
|
||
this.reload(reason);
|
||
}
|
||
|
||
visitFailed(_visit) {
|
||
this.progressBar.setValue(1);
|
||
this.hideVisitProgressBar();
|
||
}
|
||
|
||
visitRendered(_visit) {}
|
||
|
||
// Form Submission Delegate
|
||
|
||
formSubmissionStarted(_formSubmission) {
|
||
this.progressBar.setValue(0);
|
||
this.showFormProgressBarAfterDelay();
|
||
}
|
||
|
||
formSubmissionFinished(_formSubmission) {
|
||
this.progressBar.setValue(1);
|
||
this.hideFormProgressBar();
|
||
}
|
||
|
||
// Private
|
||
|
||
showVisitProgressBarAfterDelay() {
|
||
this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
|
||
}
|
||
|
||
hideVisitProgressBar() {
|
||
this.progressBar.hide();
|
||
if (this.visitProgressBarTimeout != null) {
|
||
window.clearTimeout(this.visitProgressBarTimeout);
|
||
delete this.visitProgressBarTimeout;
|
||
}
|
||
}
|
||
|
||
showFormProgressBarAfterDelay() {
|
||
if (this.formProgressBarTimeout == null) {
|
||
this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay);
|
||
}
|
||
}
|
||
|
||
hideFormProgressBar() {
|
||
this.progressBar.hide();
|
||
if (this.formProgressBarTimeout != null) {
|
||
window.clearTimeout(this.formProgressBarTimeout);
|
||
delete this.formProgressBarTimeout;
|
||
}
|
||
}
|
||
|
||
showProgressBar = () => {
|
||
this.progressBar.show();
|
||
}
|
||
|
||
reload(reason) {
|
||
dispatch("turbo:reload", { detail: reason });
|
||
|
||
window.location.href = this.location?.toString() || window.location.href;
|
||
}
|
||
|
||
get navigator() {
|
||
return this.session.navigator
|
||
}
|
||
}
|
||
|
||
class CacheObserver {
|
||
selector = "[data-turbo-temporary]"
|
||
deprecatedSelector = "[data-turbo-cache=false]"
|
||
|
||
started = false
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
this.started = true;
|
||
addEventListener("turbo:before-cache", this.removeTemporaryElements, false);
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
this.started = false;
|
||
removeEventListener("turbo:before-cache", this.removeTemporaryElements, false);
|
||
}
|
||
}
|
||
|
||
removeTemporaryElements = (_event) => {
|
||
for (const element of this.temporaryElements) {
|
||
element.remove();
|
||
}
|
||
}
|
||
|
||
get temporaryElements() {
|
||
return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]
|
||
}
|
||
|
||
get temporaryElementsWithDeprecation() {
|
||
const elements = document.querySelectorAll(this.deprecatedSelector);
|
||
|
||
if (elements.length) {
|
||
console.warn(
|
||
`The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`
|
||
);
|
||
}
|
||
|
||
return [...elements]
|
||
}
|
||
}
|
||
|
||
class FrameRedirector {
|
||
constructor(session, element) {
|
||
this.session = session;
|
||
this.element = element;
|
||
this.linkInterceptor = new LinkInterceptor(this, element);
|
||
this.formSubmitObserver = new FormSubmitObserver(this, element);
|
||
}
|
||
|
||
start() {
|
||
this.linkInterceptor.start();
|
||
this.formSubmitObserver.start();
|
||
}
|
||
|
||
stop() {
|
||
this.linkInterceptor.stop();
|
||
this.formSubmitObserver.stop();
|
||
}
|
||
|
||
// Link interceptor delegate
|
||
|
||
shouldInterceptLinkClick(element, _location, _event) {
|
||
return this.#shouldRedirect(element)
|
||
}
|
||
|
||
linkClickIntercepted(element, url, event) {
|
||
const frame = this.#findFrameElement(element);
|
||
if (frame) {
|
||
frame.delegate.linkClickIntercepted(element, url, event);
|
||
}
|
||
}
|
||
|
||
// Form submit observer delegate
|
||
|
||
willSubmitForm(element, submitter) {
|
||
return (
|
||
element.closest("turbo-frame") == null &&
|
||
this.#shouldSubmit(element, submitter) &&
|
||
this.#shouldRedirect(element, submitter)
|
||
)
|
||
}
|
||
|
||
formSubmitted(element, submitter) {
|
||
const frame = this.#findFrameElement(element, submitter);
|
||
if (frame) {
|
||
frame.delegate.formSubmitted(element, submitter);
|
||
}
|
||
}
|
||
|
||
#shouldSubmit(form, submitter) {
|
||
const action = getAction$1(form, submitter);
|
||
const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`);
|
||
const rootLocation = expandURL(meta?.content ?? "/");
|
||
|
||
return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation)
|
||
}
|
||
|
||
#shouldRedirect(element, submitter) {
|
||
const isNavigatable =
|
||
element instanceof HTMLFormElement
|
||
? this.session.submissionIsNavigatable(element, submitter)
|
||
: this.session.elementIsNavigatable(element);
|
||
|
||
if (isNavigatable) {
|
||
const frame = this.#findFrameElement(element, submitter);
|
||
return frame ? frame != element.closest("turbo-frame") : false
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
|
||
#findFrameElement(element, submitter) {
|
||
const id = submitter?.getAttribute("data-turbo-frame") || element.getAttribute("data-turbo-frame");
|
||
if (id && id != "_top") {
|
||
const frame = this.element.querySelector(`#${id}:not([disabled])`);
|
||
if (frame instanceof FrameElement) {
|
||
return frame
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
class History {
|
||
location
|
||
restorationIdentifier = uuid()
|
||
restorationData = {}
|
||
started = false
|
||
pageLoaded = false
|
||
currentIndex = 0
|
||
|
||
constructor(delegate) {
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
addEventListener("popstate", this.onPopState, false);
|
||
addEventListener("load", this.onPageLoad, false);
|
||
this.currentIndex = history.state?.turbo?.restorationIndex || 0;
|
||
this.started = true;
|
||
this.replace(new URL(window.location.href));
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
removeEventListener("popstate", this.onPopState, false);
|
||
removeEventListener("load", this.onPageLoad, false);
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
push(location, restorationIdentifier) {
|
||
this.update(history.pushState, location, restorationIdentifier);
|
||
}
|
||
|
||
replace(location, restorationIdentifier) {
|
||
this.update(history.replaceState, location, restorationIdentifier);
|
||
}
|
||
|
||
update(method, location, restorationIdentifier = uuid()) {
|
||
if (method === history.pushState) ++this.currentIndex;
|
||
|
||
const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } };
|
||
method.call(history, state, "", location.href);
|
||
this.location = location;
|
||
this.restorationIdentifier = restorationIdentifier;
|
||
}
|
||
|
||
// Restoration data
|
||
|
||
getRestorationDataForIdentifier(restorationIdentifier) {
|
||
return this.restorationData[restorationIdentifier] || {}
|
||
}
|
||
|
||
updateRestorationData(additionalData) {
|
||
const { restorationIdentifier } = this;
|
||
const restorationData = this.restorationData[restorationIdentifier];
|
||
this.restorationData[restorationIdentifier] = {
|
||
...restorationData,
|
||
...additionalData
|
||
};
|
||
}
|
||
|
||
// Scroll restoration
|
||
|
||
assumeControlOfScrollRestoration() {
|
||
if (!this.previousScrollRestoration) {
|
||
this.previousScrollRestoration = history.scrollRestoration ?? "auto";
|
||
history.scrollRestoration = "manual";
|
||
}
|
||
}
|
||
|
||
relinquishControlOfScrollRestoration() {
|
||
if (this.previousScrollRestoration) {
|
||
history.scrollRestoration = this.previousScrollRestoration;
|
||
delete this.previousScrollRestoration;
|
||
}
|
||
}
|
||
|
||
// Event handlers
|
||
|
||
onPopState = (event) => {
|
||
if (this.shouldHandlePopState()) {
|
||
const { turbo } = event.state || {};
|
||
if (turbo) {
|
||
this.location = new URL(window.location.href);
|
||
const { restorationIdentifier, restorationIndex } = turbo;
|
||
this.restorationIdentifier = restorationIdentifier;
|
||
const direction = restorationIndex > this.currentIndex ? "forward" : "back";
|
||
this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location, restorationIdentifier, direction);
|
||
this.currentIndex = restorationIndex;
|
||
}
|
||
}
|
||
}
|
||
|
||
onPageLoad = async (_event) => {
|
||
await nextMicrotask();
|
||
this.pageLoaded = true;
|
||
}
|
||
|
||
// Private
|
||
|
||
shouldHandlePopState() {
|
||
// Safari dispatches a popstate event after window's load event, ignore it
|
||
return this.pageIsLoaded()
|
||
}
|
||
|
||
pageIsLoaded() {
|
||
return this.pageLoaded || document.readyState == "complete"
|
||
}
|
||
}
|
||
|
||
class LinkPrefetchObserver {
|
||
started = false
|
||
#prefetchedLink = null
|
||
|
||
constructor(delegate, eventTarget) {
|
||
this.delegate = delegate;
|
||
this.eventTarget = eventTarget;
|
||
}
|
||
|
||
start() {
|
||
if (this.started) return
|
||
|
||
if (this.eventTarget.readyState === "loading") {
|
||
this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, { once: true });
|
||
} else {
|
||
this.#enable();
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (!this.started) return
|
||
|
||
this.eventTarget.removeEventListener("mouseenter", this.#tryToPrefetchRequest, {
|
||
capture: true,
|
||
passive: true
|
||
});
|
||
this.eventTarget.removeEventListener("mouseleave", this.#cancelRequestIfObsolete, {
|
||
capture: true,
|
||
passive: true
|
||
});
|
||
|
||
this.eventTarget.removeEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
|
||
this.started = false;
|
||
}
|
||
|
||
#enable = () => {
|
||
this.eventTarget.addEventListener("mouseenter", this.#tryToPrefetchRequest, {
|
||
capture: true,
|
||
passive: true
|
||
});
|
||
this.eventTarget.addEventListener("mouseleave", this.#cancelRequestIfObsolete, {
|
||
capture: true,
|
||
passive: true
|
||
});
|
||
|
||
this.eventTarget.addEventListener("turbo:before-fetch-request", this.#tryToUsePrefetchedRequest, true);
|
||
this.started = true;
|
||
}
|
||
|
||
#tryToPrefetchRequest = (event) => {
|
||
if (getMetaContent("turbo-prefetch") === "false") return
|
||
|
||
const target = event.target;
|
||
const isLink = target.matches && target.matches("a[href]:not([target^=_]):not([download])");
|
||
|
||
if (isLink && this.#isPrefetchable(target)) {
|
||
const link = target;
|
||
const location = getLocationForLink(link);
|
||
|
||
if (this.delegate.canPrefetchRequestToLocation(link, location)) {
|
||
this.#prefetchedLink = link;
|
||
|
||
const fetchRequest = new FetchRequest(
|
||
this,
|
||
FetchMethod.get,
|
||
location,
|
||
new URLSearchParams(),
|
||
target
|
||
);
|
||
|
||
prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl);
|
||
}
|
||
}
|
||
}
|
||
|
||
#cancelRequestIfObsolete = (event) => {
|
||
if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest();
|
||
}
|
||
|
||
#cancelPrefetchRequest = () => {
|
||
prefetchCache.clear();
|
||
this.#prefetchedLink = null;
|
||
}
|
||
|
||
#tryToUsePrefetchedRequest = (event) => {
|
||
if (event.target.tagName !== "FORM" && event.detail.fetchOptions.method === "GET") {
|
||
const cached = prefetchCache.get(event.detail.url.toString());
|
||
|
||
if (cached) {
|
||
// User clicked link, use cache response
|
||
event.detail.fetchRequest = cached;
|
||
}
|
||
|
||
prefetchCache.clear();
|
||
}
|
||
}
|
||
|
||
prepareRequest(request) {
|
||
const link = request.target;
|
||
|
||
request.headers["X-Sec-Purpose"] = "prefetch";
|
||
|
||
const turboFrame = link.closest("turbo-frame");
|
||
const turboFrameTarget = link.getAttribute("data-turbo-frame") || turboFrame?.getAttribute("target") || turboFrame?.id;
|
||
|
||
if (turboFrameTarget && turboFrameTarget !== "_top") {
|
||
request.headers["Turbo-Frame"] = turboFrameTarget;
|
||
}
|
||
}
|
||
|
||
// Fetch request interface
|
||
|
||
requestSucceededWithResponse() {}
|
||
|
||
requestStarted(fetchRequest) {}
|
||
|
||
requestErrored(fetchRequest) {}
|
||
|
||
requestFinished(fetchRequest) {}
|
||
|
||
requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
|
||
|
||
requestFailedWithResponse(fetchRequest, fetchResponse) {}
|
||
|
||
get #cacheTtl() {
|
||
return Number(getMetaContent("turbo-prefetch-cache-time")) || cacheTtl
|
||
}
|
||
|
||
#isPrefetchable(link) {
|
||
const href = link.getAttribute("href");
|
||
|
||
if (!href) return false
|
||
|
||
if (unfetchableLink(link)) return false
|
||
if (linkToTheSamePage(link)) return false
|
||
if (linkOptsOut(link)) return false
|
||
if (nonSafeLink(link)) return false
|
||
if (eventPrevented(link)) return false
|
||
|
||
return true
|
||
}
|
||
}
|
||
|
||
const unfetchableLink = (link) => {
|
||
return link.origin !== document.location.origin || !["http:", "https:"].includes(link.protocol) || link.hasAttribute("target")
|
||
};
|
||
|
||
const linkToTheSamePage = (link) => {
|
||
return (link.pathname + link.search === document.location.pathname + document.location.search) || link.href.startsWith("#")
|
||
};
|
||
|
||
const linkOptsOut = (link) => {
|
||
if (link.getAttribute("data-turbo-prefetch") === "false") return true
|
||
if (link.getAttribute("data-turbo") === "false") return true
|
||
|
||
const turboPrefetchParent = findClosestRecursively(link, "[data-turbo-prefetch]");
|
||
if (turboPrefetchParent && turboPrefetchParent.getAttribute("data-turbo-prefetch") === "false") return true
|
||
|
||
return false
|
||
};
|
||
|
||
const nonSafeLink = (link) => {
|
||
const turboMethod = link.getAttribute("data-turbo-method");
|
||
if (turboMethod && turboMethod.toLowerCase() !== "get") return true
|
||
|
||
if (isUJS(link)) return true
|
||
if (link.hasAttribute("data-turbo-confirm")) return true
|
||
if (link.hasAttribute("data-turbo-stream")) return true
|
||
|
||
return false
|
||
};
|
||
|
||
const isUJS = (link) => {
|
||
return link.hasAttribute("data-remote") || link.hasAttribute("data-behavior") || link.hasAttribute("data-confirm") || link.hasAttribute("data-method")
|
||
};
|
||
|
||
const eventPrevented = (link) => {
|
||
const event = dispatch("turbo:before-prefetch", { target: link, cancelable: true });
|
||
return event.defaultPrevented
|
||
};
|
||
|
||
class Navigator {
|
||
constructor(delegate) {
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
proposeVisit(location, options = {}) {
|
||
if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) {
|
||
this.delegate.visitProposedToLocation(location, options);
|
||
}
|
||
}
|
||
|
||
startVisit(locatable, restorationIdentifier, options = {}) {
|
||
this.stop();
|
||
this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, {
|
||
referrer: this.location,
|
||
...options
|
||
});
|
||
this.currentVisit.start();
|
||
}
|
||
|
||
submitForm(form, submitter) {
|
||
this.stop();
|
||
this.formSubmission = new FormSubmission(this, form, submitter, true);
|
||
|
||
this.formSubmission.start();
|
||
}
|
||
|
||
stop() {
|
||
if (this.formSubmission) {
|
||
this.formSubmission.stop();
|
||
delete this.formSubmission;
|
||
}
|
||
|
||
if (this.currentVisit) {
|
||
this.currentVisit.cancel();
|
||
delete this.currentVisit;
|
||
}
|
||
}
|
||
|
||
get adapter() {
|
||
return this.delegate.adapter
|
||
}
|
||
|
||
get view() {
|
||
return this.delegate.view
|
||
}
|
||
|
||
get rootLocation() {
|
||
return this.view.snapshot.rootLocation
|
||
}
|
||
|
||
get history() {
|
||
return this.delegate.history
|
||
}
|
||
|
||
// Form submission delegate
|
||
|
||
formSubmissionStarted(formSubmission) {
|
||
// Not all adapters implement formSubmissionStarted
|
||
if (typeof this.adapter.formSubmissionStarted === "function") {
|
||
this.adapter.formSubmissionStarted(formSubmission);
|
||
}
|
||
}
|
||
|
||
async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) {
|
||
if (formSubmission == this.formSubmission) {
|
||
const responseHTML = await fetchResponse.responseHTML;
|
||
if (responseHTML) {
|
||
const shouldCacheSnapshot = formSubmission.isSafe;
|
||
if (!shouldCacheSnapshot) {
|
||
this.view.clearSnapshotCache();
|
||
}
|
||
|
||
const { statusCode, redirected } = fetchResponse;
|
||
const action = this.#getActionForFormSubmission(formSubmission, fetchResponse);
|
||
const visitOptions = {
|
||
action,
|
||
shouldCacheSnapshot,
|
||
response: { statusCode, responseHTML, redirected }
|
||
};
|
||
this.proposeVisit(fetchResponse.location, visitOptions);
|
||
}
|
||
}
|
||
}
|
||
|
||
async formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
|
||
const responseHTML = await fetchResponse.responseHTML;
|
||
|
||
if (responseHTML) {
|
||
const snapshot = PageSnapshot.fromHTMLString(responseHTML);
|
||
if (fetchResponse.serverError) {
|
||
await this.view.renderError(snapshot, this.currentVisit);
|
||
} else {
|
||
await this.view.renderPage(snapshot, false, true, this.currentVisit);
|
||
}
|
||
if(!snapshot.shouldPreserveScrollPosition) {
|
||
this.view.scrollToTop();
|
||
}
|
||
this.view.clearSnapshotCache();
|
||
}
|
||
}
|
||
|
||
formSubmissionErrored(formSubmission, error) {
|
||
console.error(error);
|
||
}
|
||
|
||
formSubmissionFinished(formSubmission) {
|
||
// Not all adapters implement formSubmissionFinished
|
||
if (typeof this.adapter.formSubmissionFinished === "function") {
|
||
this.adapter.formSubmissionFinished(formSubmission);
|
||
}
|
||
}
|
||
|
||
// Visit delegate
|
||
|
||
visitStarted(visit) {
|
||
this.delegate.visitStarted(visit);
|
||
}
|
||
|
||
visitCompleted(visit) {
|
||
this.delegate.visitCompleted(visit);
|
||
delete this.currentVisit;
|
||
}
|
||
|
||
locationWithActionIsSamePage(location, action) {
|
||
const anchor = getAnchor(location);
|
||
const currentAnchor = getAnchor(this.view.lastRenderedLocation);
|
||
const isRestorationToTop = action === "restore" && typeof anchor === "undefined";
|
||
|
||
return (
|
||
action !== "replace" &&
|
||
getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
|
||
(isRestorationToTop || (anchor != null && anchor !== currentAnchor))
|
||
)
|
||
}
|
||
|
||
visitScrolledToSamePageLocation(oldURL, newURL) {
|
||
this.delegate.visitScrolledToSamePageLocation(oldURL, newURL);
|
||
}
|
||
|
||
// Visits
|
||
|
||
get location() {
|
||
return this.history.location
|
||
}
|
||
|
||
get restorationIdentifier() {
|
||
return this.history.restorationIdentifier
|
||
}
|
||
|
||
#getActionForFormSubmission(formSubmission, fetchResponse) {
|
||
const { submitter, formElement } = formSubmission;
|
||
return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse)
|
||
}
|
||
|
||
#getDefaultAction(fetchResponse) {
|
||
const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href;
|
||
return sameLocationRedirect ? "replace" : "advance"
|
||
}
|
||
}
|
||
|
||
const PageStage = {
|
||
initial: 0,
|
||
loading: 1,
|
||
interactive: 2,
|
||
complete: 3
|
||
};
|
||
|
||
class PageObserver {
|
||
stage = PageStage.initial
|
||
started = false
|
||
|
||
constructor(delegate) {
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
if (this.stage == PageStage.initial) {
|
||
this.stage = PageStage.loading;
|
||
}
|
||
document.addEventListener("readystatechange", this.interpretReadyState, false);
|
||
addEventListener("pagehide", this.pageWillUnload, false);
|
||
this.started = true;
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
document.removeEventListener("readystatechange", this.interpretReadyState, false);
|
||
removeEventListener("pagehide", this.pageWillUnload, false);
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
interpretReadyState = () => {
|
||
const { readyState } = this;
|
||
if (readyState == "interactive") {
|
||
this.pageIsInteractive();
|
||
} else if (readyState == "complete") {
|
||
this.pageIsComplete();
|
||
}
|
||
}
|
||
|
||
pageIsInteractive() {
|
||
if (this.stage == PageStage.loading) {
|
||
this.stage = PageStage.interactive;
|
||
this.delegate.pageBecameInteractive();
|
||
}
|
||
}
|
||
|
||
pageIsComplete() {
|
||
this.pageIsInteractive();
|
||
if (this.stage == PageStage.interactive) {
|
||
this.stage = PageStage.complete;
|
||
this.delegate.pageLoaded();
|
||
}
|
||
}
|
||
|
||
pageWillUnload = () => {
|
||
this.delegate.pageWillUnload();
|
||
}
|
||
|
||
get readyState() {
|
||
return document.readyState
|
||
}
|
||
}
|
||
|
||
class ScrollObserver {
|
||
started = false
|
||
|
||
constructor(delegate) {
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
addEventListener("scroll", this.onScroll, false);
|
||
this.onScroll();
|
||
this.started = true;
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
removeEventListener("scroll", this.onScroll, false);
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
onScroll = () => {
|
||
this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset });
|
||
}
|
||
|
||
// Private
|
||
|
||
updatePosition(position) {
|
||
this.delegate.scrollPositionChanged(position);
|
||
}
|
||
}
|
||
|
||
class StreamMessageRenderer {
|
||
render({ fragment }) {
|
||
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => {
|
||
withAutofocusFromFragment(fragment, () => {
|
||
withPreservedFocus(() => {
|
||
document.documentElement.appendChild(fragment);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Bardo delegate
|
||
|
||
enteringBardo(currentPermanentElement, newPermanentElement) {
|
||
newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true));
|
||
}
|
||
|
||
leavingBardo() {}
|
||
}
|
||
|
||
function getPermanentElementMapForFragment(fragment) {
|
||
const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement);
|
||
const permanentElementMap = {};
|
||
for (const permanentElementInDocument of permanentElementsInDocument) {
|
||
const { id } = permanentElementInDocument;
|
||
|
||
for (const streamElement of fragment.querySelectorAll("turbo-stream")) {
|
||
const elementInStream = getPermanentElementById(streamElement.templateElement.content, id);
|
||
|
||
if (elementInStream) {
|
||
permanentElementMap[id] = [permanentElementInDocument, elementInStream];
|
||
}
|
||
}
|
||
}
|
||
|
||
return permanentElementMap
|
||
}
|
||
|
||
async function withAutofocusFromFragment(fragment, callback) {
|
||
const generatedID = `turbo-stream-autofocus-${uuid()}`;
|
||
const turboStreams = fragment.querySelectorAll("turbo-stream");
|
||
const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams);
|
||
let willAutofocusId = null;
|
||
|
||
if (elementWithAutofocus) {
|
||
if (elementWithAutofocus.id) {
|
||
willAutofocusId = elementWithAutofocus.id;
|
||
} else {
|
||
willAutofocusId = generatedID;
|
||
}
|
||
|
||
elementWithAutofocus.id = willAutofocusId;
|
||
}
|
||
|
||
callback();
|
||
await nextRepaint();
|
||
|
||
const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body;
|
||
|
||
if (hasNoActiveElement && willAutofocusId) {
|
||
const elementToAutofocus = document.getElementById(willAutofocusId);
|
||
|
||
if (elementIsFocusable(elementToAutofocus)) {
|
||
elementToAutofocus.focus();
|
||
}
|
||
if (elementToAutofocus && elementToAutofocus.id == generatedID) {
|
||
elementToAutofocus.removeAttribute("id");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function withPreservedFocus(callback) {
|
||
const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement);
|
||
|
||
const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id;
|
||
|
||
if (restoreFocusTo) {
|
||
const elementToFocus = document.getElementById(restoreFocusTo);
|
||
|
||
if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) {
|
||
elementToFocus.focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
function firstAutofocusableElementInStreams(nodeListOfStreamElements) {
|
||
for (const streamElement of nodeListOfStreamElements) {
|
||
const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content);
|
||
|
||
if (elementWithAutofocus) return elementWithAutofocus
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
class StreamObserver {
|
||
sources = new Set()
|
||
#started = false
|
||
|
||
constructor(delegate) {
|
||
this.delegate = delegate;
|
||
}
|
||
|
||
start() {
|
||
if (!this.#started) {
|
||
this.#started = true;
|
||
addEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
if (this.#started) {
|
||
this.#started = false;
|
||
removeEventListener("turbo:before-fetch-response", this.inspectFetchResponse, false);
|
||
}
|
||
}
|
||
|
||
connectStreamSource(source) {
|
||
if (!this.streamSourceIsConnected(source)) {
|
||
this.sources.add(source);
|
||
source.addEventListener("message", this.receiveMessageEvent, false);
|
||
}
|
||
}
|
||
|
||
disconnectStreamSource(source) {
|
||
if (this.streamSourceIsConnected(source)) {
|
||
this.sources.delete(source);
|
||
source.removeEventListener("message", this.receiveMessageEvent, false);
|
||
}
|
||
}
|
||
|
||
streamSourceIsConnected(source) {
|
||
return this.sources.has(source)
|
||
}
|
||
|
||
inspectFetchResponse = (event) => {
|
||
const response = fetchResponseFromEvent(event);
|
||
if (response && fetchResponseIsStream(response)) {
|
||
event.preventDefault();
|
||
this.receiveMessageResponse(response);
|
||
}
|
||
}
|
||
|
||
receiveMessageEvent = (event) => {
|
||
if (this.#started && typeof event.data == "string") {
|
||
this.receiveMessageHTML(event.data);
|
||
}
|
||
}
|
||
|
||
async receiveMessageResponse(response) {
|
||
const html = await response.responseHTML;
|
||
if (html) {
|
||
this.receiveMessageHTML(html);
|
||
}
|
||
}
|
||
|
||
receiveMessageHTML(html) {
|
||
this.delegate.receivedMessageFromStream(StreamMessage.wrap(html));
|
||
}
|
||
}
|
||
|
||
function fetchResponseFromEvent(event) {
|
||
const fetchResponse = event.detail?.fetchResponse;
|
||
if (fetchResponse instanceof FetchResponse) {
|
||
return fetchResponse
|
||
}
|
||
}
|
||
|
||
function fetchResponseIsStream(response) {
|
||
const contentType = response.contentType ?? "";
|
||
return contentType.startsWith(StreamMessage.contentType)
|
||
}
|
||
|
||
class ErrorRenderer extends Renderer {
|
||
static renderElement(currentElement, newElement) {
|
||
const { documentElement, body } = document;
|
||
|
||
documentElement.replaceChild(newElement, body);
|
||
}
|
||
|
||
async render() {
|
||
this.replaceHeadAndBody();
|
||
this.activateScriptElements();
|
||
}
|
||
|
||
replaceHeadAndBody() {
|
||
const { documentElement, head } = document;
|
||
documentElement.replaceChild(this.newHead, head);
|
||
this.renderElement(this.currentElement, this.newElement);
|
||
}
|
||
|
||
activateScriptElements() {
|
||
for (const replaceableElement of this.scriptElements) {
|
||
const parentNode = replaceableElement.parentNode;
|
||
if (parentNode) {
|
||
const element = activateScriptElement(replaceableElement);
|
||
parentNode.replaceChild(element, replaceableElement);
|
||
}
|
||
}
|
||
}
|
||
|
||
get newHead() {
|
||
return this.newSnapshot.headSnapshot.element
|
||
}
|
||
|
||
get scriptElements() {
|
||
return document.documentElement.querySelectorAll("script")
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
};
|
||
|
||
//=============================================================================
|
||
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
|
||
//=============================================================================
|
||
function morph(oldNode, newContent, config = {}) {
|
||
|
||
if (oldNode instanceof Document) {
|
||
oldNode = oldNode.documentElement;
|
||
}
|
||
|
||
if (typeof newContent === 'string') {
|
||
newContent = parseContent(newContent);
|
||
}
|
||
|
||
let normalizedContent = normalizeContent(newContent);
|
||
|
||
let ctx = createMorphContext(oldNode, normalizedContent, config);
|
||
|
||
return morphNormalizedContent(oldNode, normalizedContent, ctx);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* @param possibleActiveElement
|
||
* @param ctx
|
||
* @returns {boolean}
|
||
*/
|
||
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
|
||
return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
|
||
}
|
||
|
||
/**
|
||
* @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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
|
||
//=============================================================================
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
//=============================================================================
|
||
// 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 noOp() {
|
||
}
|
||
|
||
/*
|
||
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 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
|
||
}
|
||
}
|
||
|
||
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 isSoftMatch(node1, node2) {
|
||
if (node1 == null || node2 == null) {
|
||
return false;
|
||
}
|
||
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
|
||
}
|
||
|
||
function removeNodesBetween(startInclusive, endExclusive, ctx) {
|
||
while (startInclusive !== endExclusive) {
|
||
let tempNode = startInclusive;
|
||
startInclusive = startInclusive.nextSibling;
|
||
removeNode(tempNode, ctx);
|
||
}
|
||
removeIdsFromConsideration(ctx, endExclusive);
|
||
return endExclusive.nextSibling;
|
||
}
|
||
|
||
//=============================================================================
|
||
// 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;
|
||
}
|
||
|
||
//=============================================================================
|
||
// 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;
|
||
}
|
||
|
||
function parseContent(newContent) {
|
||
let parser = new DOMParser();
|
||
|
||
// remove svgs to avoid false-positive matches on head, etc.
|
||
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||
|
||
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
|
||
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
|
||
let content = parser.parseFromString(newContent, "text/html");
|
||
// if it is a full HTML document, return the document itself as the parent container
|
||
if (contentWithSvgsRemoved.match(/<\/html>/)) {
|
||
content.generatedByIdiomorph = true;
|
||
return content;
|
||
} else {
|
||
// otherwise return the html element as the parent container
|
||
let htmlElement = content.firstChild;
|
||
if (htmlElement) {
|
||
htmlElement.generatedByIdiomorph = true;
|
||
return htmlElement;
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
} else {
|
||
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
|
||
// deal with touchy tags like tr, tbody, etc.
|
||
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
|
||
let content = responseDoc.body.querySelector('template').content;
|
||
content.generatedByIdiomorph = true;
|
||
return content
|
||
}
|
||
}
|
||
|
||
function normalizeContent(newContent) {
|
||
if (newContent == null) {
|
||
// noinspection UnnecessaryLocalVariableJS
|
||
const dummyParent = document.createElement('div');
|
||
return dummyParent;
|
||
} else if (newContent.generatedByIdiomorph) {
|
||
// the template tag created by idiomorph parsing can serve as a dummy parent
|
||
return newContent;
|
||
} else if (newContent instanceof Node) {
|
||
// a single node is added as a child to a dummy parent
|
||
const dummyParent = document.createElement('div');
|
||
dummyParent.append(newContent);
|
||
return dummyParent;
|
||
} else {
|
||
// all nodes in the array or HTMLElement collection are consolidated under
|
||
// a single dummy parent element
|
||
const dummyParent = document.createElement('div');
|
||
for (const elt of [...newContent]) {
|
||
dummyParent.append(elt);
|
||
}
|
||
return dummyParent;
|
||
}
|
||
}
|
||
|
||
function insertSiblings(previousSibling, morphedNode, nextSibling) {
|
||
let stack = [];
|
||
let added = [];
|
||
while (previousSibling != null) {
|
||
stack.push(previousSibling);
|
||
previousSibling = previousSibling.previousSibling;
|
||
}
|
||
while (stack.length > 0) {
|
||
let node = stack.pop();
|
||
added.push(node); // push added preceding siblings on in order and insert
|
||
morphedNode.parentElement.insertBefore(node, morphedNode);
|
||
}
|
||
added.push(morphedNode);
|
||
while (nextSibling != null) {
|
||
stack.push(nextSibling);
|
||
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
|
||
nextSibling = nextSibling.nextSibling;
|
||
}
|
||
while (stack.length > 0) {
|
||
morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
|
||
}
|
||
return added;
|
||
}
|
||
|
||
function findBestNodeMatch(newContent, oldNode, ctx) {
|
||
let currentElement;
|
||
currentElement = newContent.firstChild;
|
||
let bestElement = currentElement;
|
||
let score = 0;
|
||
while (currentElement) {
|
||
let newScore = scoreElement(currentElement, oldNode, ctx);
|
||
if (newScore > score) {
|
||
bestElement = currentElement;
|
||
score = newScore;
|
||
}
|
||
currentElement = currentElement.nextSibling;
|
||
}
|
||
return bestElement;
|
||
}
|
||
|
||
function scoreElement(node1, node2, ctx) {
|
||
if (isSoftMatch(node1, node2)) {
|
||
return .5 + getIdIntersectionCount(ctx, node1, node2);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function removeNode(tempNode, ctx) {
|
||
removeIdsFromConsideration(ctx, tempNode);
|
||
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
|
||
|
||
tempNode.remove();
|
||
ctx.callbacks.afterNodeRemoved(tempNode);
|
||
}
|
||
|
||
//=============================================================================
|
||
// ID Set Functions
|
||
//=============================================================================
|
||
|
||
function isIdInConsideration(ctx, id) {
|
||
return !ctx.deadIds.has(id);
|
||
}
|
||
|
||
function idIsWithinNode(ctx, id, targetNode) {
|
||
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
|
||
return idSet.has(id);
|
||
}
|
||
|
||
function removeIdsFromConsideration(ctx, node) {
|
||
let idSet = ctx.idMap.get(node) || EMPTY_SET;
|
||
for (const id of idSet) {
|
||
ctx.deadIds.add(id);
|
||
}
|
||
}
|
||
|
||
function getIdIntersectionCount(ctx, node1, node2) {
|
||
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
|
||
let matchCount = 0;
|
||
for (const id of sourceSet) {
|
||
// a potential match is an id in the source and potentialIdsSet, but
|
||
// that has not already been merged into the DOM
|
||
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
|
||
++matchCount;
|
||
}
|
||
}
|
||
return matchCount;
|
||
}
|
||
|
||
/**
|
||
* A bottom up algorithm that finds all elements with ids inside of the node
|
||
* argument and populates id sets for those nodes and all their parents, generating
|
||
* a set of ids contained within all nodes for the entire hierarchy in the DOM
|
||
*
|
||
* @param node {Element}
|
||
* @param {Map<Node, Set<String>>} idMap
|
||
*/
|
||
function populateIdMapForNode(node, idMap) {
|
||
let nodeParent = node.parentElement;
|
||
// find all elements with an id property
|
||
let idElements = node.querySelectorAll('[id]');
|
||
for (const elt of idElements) {
|
||
let current = elt;
|
||
// walk up the parent hierarchy of that element, adding the id
|
||
// of element to the parent's id set
|
||
while (current !== nodeParent && current != null) {
|
||
let idSet = idMap.get(current);
|
||
// if the id set doesn't exist, create it and insert it in the map
|
||
if (idSet == null) {
|
||
idSet = new Set();
|
||
idMap.set(current, idSet);
|
||
}
|
||
idSet.add(elt.id);
|
||
current = current.parentElement;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This function computes a map of nodes to all ids contained within that node (inclusive of the
|
||
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
|
||
* for a looser definition of "matching" than tradition id matching, and allows child nodes
|
||
* to contribute to a parent nodes matching.
|
||
*
|
||
* @param {Element} oldContent the old content that will be morphed
|
||
* @param {Element} newContent the new content to morph to
|
||
* @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
|
||
*/
|
||
function createIdMap(oldContent, newContent) {
|
||
let idMap = new Map();
|
||
populateIdMapForNode(oldContent, idMap);
|
||
populateIdMapForNode(newContent, idMap);
|
||
return idMap;
|
||
}
|
||
|
||
//=============================================================================
|
||
// This is what ends up becoming the Idiomorph global object
|
||
//=============================================================================
|
||
return {
|
||
morph,
|
||
defaults
|
||
}
|
||
})();
|
||
|
||
function morphElements(currentElement, newElement, { callbacks, ...options } = {}) {
|
||
Idiomorph.morph(currentElement, newElement, {
|
||
...options,
|
||
callbacks: new DefaultIdiomorphCallbacks(callbacks)
|
||
});
|
||
}
|
||
|
||
function morphChildren(currentElement, newElement) {
|
||
morphElements(currentElement, newElement.children, {
|
||
morphStyle: "innerHTML"
|
||
});
|
||
}
|
||
|
||
class DefaultIdiomorphCallbacks {
|
||
#beforeNodeMorphed
|
||
|
||
constructor({ beforeNodeMorphed } = {}) {
|
||
this.#beforeNodeMorphed = beforeNodeMorphed || (() => true);
|
||
}
|
||
|
||
beforeNodeAdded = (node) => {
|
||
return !(node.id && node.hasAttribute("data-turbo-permanent") && document.getElementById(node.id))
|
||
}
|
||
|
||
beforeNodeMorphed = (currentElement, newElement) => {
|
||
if (currentElement instanceof Element) {
|
||
if (!currentElement.hasAttribute("data-turbo-permanent") && this.#beforeNodeMorphed(currentElement, newElement)) {
|
||
const event = dispatch("turbo:before-morph-element", {
|
||
cancelable: true,
|
||
target: currentElement,
|
||
detail: { currentElement, newElement }
|
||
});
|
||
|
||
return !event.defaultPrevented
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
beforeAttributeUpdated = (attributeName, target, mutationType) => {
|
||
const event = dispatch("turbo:before-morph-attribute", {
|
||
cancelable: true,
|
||
target,
|
||
detail: { attributeName, mutationType }
|
||
});
|
||
|
||
return !event.defaultPrevented
|
||
}
|
||
|
||
beforeNodeRemoved = (node) => {
|
||
return this.beforeNodeMorphed(node)
|
||
}
|
||
|
||
afterNodeMorphed = (currentElement, newElement) => {
|
||
if (currentElement instanceof Element) {
|
||
dispatch("turbo:morph-element", {
|
||
target: currentElement,
|
||
detail: { currentElement, newElement }
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
class MorphingFrameRenderer extends FrameRenderer {
|
||
static renderElement(currentElement, newElement) {
|
||
dispatch("turbo:before-frame-morph", {
|
||
target: currentElement,
|
||
detail: { currentElement, newElement }
|
||
});
|
||
|
||
morphChildren(currentElement, newElement);
|
||
}
|
||
}
|
||
|
||
class PageRenderer extends Renderer {
|
||
static renderElement(currentElement, newElement) {
|
||
if (document.body && newElement instanceof HTMLBodyElement) {
|
||
document.body.replaceWith(newElement);
|
||
} else {
|
||
document.documentElement.appendChild(newElement);
|
||
}
|
||
}
|
||
|
||
get shouldRender() {
|
||
return this.newSnapshot.isVisitable && this.trackedElementsAreIdentical
|
||
}
|
||
|
||
get reloadReason() {
|
||
if (!this.newSnapshot.isVisitable) {
|
||
return {
|
||
reason: "turbo_visit_control_is_reload"
|
||
}
|
||
}
|
||
|
||
if (!this.trackedElementsAreIdentical) {
|
||
return {
|
||
reason: "tracked_element_mismatch"
|
||
}
|
||
}
|
||
}
|
||
|
||
async prepareToRender() {
|
||
this.#setLanguage();
|
||
await this.mergeHead();
|
||
}
|
||
|
||
async render() {
|
||
if (this.willRender) {
|
||
await this.replaceBody();
|
||
}
|
||
}
|
||
|
||
finishRendering() {
|
||
super.finishRendering();
|
||
if (!this.isPreview) {
|
||
this.focusFirstAutofocusableElement();
|
||
}
|
||
}
|
||
|
||
get currentHeadSnapshot() {
|
||
return this.currentSnapshot.headSnapshot
|
||
}
|
||
|
||
get newHeadSnapshot() {
|
||
return this.newSnapshot.headSnapshot
|
||
}
|
||
|
||
get newElement() {
|
||
return this.newSnapshot.element
|
||
}
|
||
|
||
#setLanguage() {
|
||
const { documentElement } = this.currentSnapshot;
|
||
const { lang } = this.newSnapshot;
|
||
|
||
if (lang) {
|
||
documentElement.setAttribute("lang", lang);
|
||
} else {
|
||
documentElement.removeAttribute("lang");
|
||
}
|
||
}
|
||
|
||
async mergeHead() {
|
||
const mergedHeadElements = this.mergeProvisionalElements();
|
||
const newStylesheetElements = this.copyNewHeadStylesheetElements();
|
||
this.copyNewHeadScriptElements();
|
||
|
||
await mergedHeadElements;
|
||
await newStylesheetElements;
|
||
|
||
if (this.willRender) {
|
||
this.removeUnusedDynamicStylesheetElements();
|
||
}
|
||
}
|
||
|
||
async replaceBody() {
|
||
await this.preservingPermanentElements(async () => {
|
||
this.activateNewBody();
|
||
await this.assignNewBody();
|
||
});
|
||
}
|
||
|
||
get trackedElementsAreIdentical() {
|
||
return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature
|
||
}
|
||
|
||
async copyNewHeadStylesheetElements() {
|
||
const loadingElements = [];
|
||
|
||
for (const element of this.newHeadStylesheetElements) {
|
||
loadingElements.push(waitForLoad(element));
|
||
|
||
document.head.appendChild(element);
|
||
}
|
||
|
||
await Promise.all(loadingElements);
|
||
}
|
||
|
||
copyNewHeadScriptElements() {
|
||
for (const element of this.newHeadScriptElements) {
|
||
document.head.appendChild(activateScriptElement(element));
|
||
}
|
||
}
|
||
|
||
removeUnusedDynamicStylesheetElements() {
|
||
for (const element of this.unusedDynamicStylesheetElements) {
|
||
document.head.removeChild(element);
|
||
}
|
||
}
|
||
|
||
async mergeProvisionalElements() {
|
||
const newHeadElements = [...this.newHeadProvisionalElements];
|
||
|
||
for (const element of this.currentHeadProvisionalElements) {
|
||
if (!this.isCurrentElementInElementList(element, newHeadElements)) {
|
||
document.head.removeChild(element);
|
||
}
|
||
}
|
||
|
||
for (const element of newHeadElements) {
|
||
document.head.appendChild(element);
|
||
}
|
||
}
|
||
|
||
isCurrentElementInElementList(element, elementList) {
|
||
for (const [index, newElement] of elementList.entries()) {
|
||
// if title element...
|
||
if (element.tagName == "TITLE") {
|
||
if (newElement.tagName != "TITLE") {
|
||
continue
|
||
}
|
||
if (element.innerHTML == newElement.innerHTML) {
|
||
elementList.splice(index, 1);
|
||
return true
|
||
}
|
||
}
|
||
|
||
// if any other element...
|
||
if (newElement.isEqualNode(element)) {
|
||
elementList.splice(index, 1);
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
removeCurrentHeadProvisionalElements() {
|
||
for (const element of this.currentHeadProvisionalElements) {
|
||
document.head.removeChild(element);
|
||
}
|
||
}
|
||
|
||
copyNewHeadProvisionalElements() {
|
||
for (const element of this.newHeadProvisionalElements) {
|
||
document.head.appendChild(element);
|
||
}
|
||
}
|
||
|
||
activateNewBody() {
|
||
document.adoptNode(this.newElement);
|
||
this.activateNewBodyScriptElements();
|
||
}
|
||
|
||
activateNewBodyScriptElements() {
|
||
for (const inertScriptElement of this.newBodyScriptElements) {
|
||
const activatedScriptElement = activateScriptElement(inertScriptElement);
|
||
inertScriptElement.replaceWith(activatedScriptElement);
|
||
}
|
||
}
|
||
|
||
async assignNewBody() {
|
||
await this.renderElement(this.currentElement, this.newElement);
|
||
}
|
||
|
||
get unusedDynamicStylesheetElements() {
|
||
return this.oldHeadStylesheetElements.filter((element) => {
|
||
return element.getAttribute("data-turbo-track") === "dynamic"
|
||
})
|
||
}
|
||
|
||
get oldHeadStylesheetElements() {
|
||
return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)
|
||
}
|
||
|
||
get newHeadStylesheetElements() {
|
||
return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
|
||
}
|
||
|
||
get newHeadScriptElements() {
|
||
return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)
|
||
}
|
||
|
||
get currentHeadProvisionalElements() {
|
||
return this.currentHeadSnapshot.provisionalElements
|
||
}
|
||
|
||
get newHeadProvisionalElements() {
|
||
return this.newHeadSnapshot.provisionalElements
|
||
}
|
||
|
||
get newBodyScriptElements() {
|
||
return this.newElement.querySelectorAll("script")
|
||
}
|
||
}
|
||
|
||
class MorphingPageRenderer extends PageRenderer {
|
||
static renderElement(currentElement, newElement) {
|
||
morphElements(currentElement, newElement, {
|
||
callbacks: {
|
||
beforeNodeMorphed: element => !canRefreshFrame(element)
|
||
}
|
||
});
|
||
|
||
for (const frame of currentElement.querySelectorAll("turbo-frame")) {
|
||
if (canRefreshFrame(frame)) refreshFrame(frame);
|
||
}
|
||
|
||
dispatch("turbo:morph", { detail: { currentElement, newElement } });
|
||
}
|
||
|
||
async preservingPermanentElements(callback) {
|
||
return await callback()
|
||
}
|
||
|
||
get renderMethod() {
|
||
return "morph"
|
||
}
|
||
|
||
get shouldAutofocus() {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function canRefreshFrame(frame) {
|
||
return frame instanceof FrameElement &&
|
||
frame.src &&
|
||
frame.refresh === "morph" &&
|
||
!frame.closest("[data-turbo-permanent]")
|
||
}
|
||
|
||
function refreshFrame(frame) {
|
||
frame.addEventListener("turbo:before-frame-render", ({ detail }) => {
|
||
detail.render = MorphingFrameRenderer.renderElement;
|
||
}, { once: true });
|
||
|
||
frame.reload();
|
||
}
|
||
|
||
class SnapshotCache {
|
||
keys = []
|
||
snapshots = {}
|
||
|
||
constructor(size) {
|
||
this.size = size;
|
||
}
|
||
|
||
has(location) {
|
||
return toCacheKey(location) in this.snapshots
|
||
}
|
||
|
||
get(location) {
|
||
if (this.has(location)) {
|
||
const snapshot = this.read(location);
|
||
this.touch(location);
|
||
return snapshot
|
||
}
|
||
}
|
||
|
||
put(location, snapshot) {
|
||
this.write(location, snapshot);
|
||
this.touch(location);
|
||
return snapshot
|
||
}
|
||
|
||
clear() {
|
||
this.snapshots = {};
|
||
}
|
||
|
||
// Private
|
||
|
||
read(location) {
|
||
return this.snapshots[toCacheKey(location)]
|
||
}
|
||
|
||
write(location, snapshot) {
|
||
this.snapshots[toCacheKey(location)] = snapshot;
|
||
}
|
||
|
||
touch(location) {
|
||
const key = toCacheKey(location);
|
||
const index = this.keys.indexOf(key);
|
||
if (index > -1) this.keys.splice(index, 1);
|
||
this.keys.unshift(key);
|
||
this.trim();
|
||
}
|
||
|
||
trim() {
|
||
for (const key of this.keys.splice(this.size)) {
|
||
delete this.snapshots[key];
|
||
}
|
||
}
|
||
}
|
||
|
||
class PageView extends View {
|
||
snapshotCache = new SnapshotCache(10)
|
||
lastRenderedLocation = new URL(location.href)
|
||
forceReloaded = false
|
||
|
||
shouldTransitionTo(newSnapshot) {
|
||
return this.snapshot.prefersViewTransitions && newSnapshot.prefersViewTransitions
|
||
}
|
||
|
||
renderPage(snapshot, isPreview = false, willRender = true, visit) {
|
||
const shouldMorphPage = this.isPageRefresh(visit) && this.snapshot.shouldMorphPage;
|
||
const rendererClass = shouldMorphPage ? MorphingPageRenderer : PageRenderer;
|
||
|
||
const renderer = new rendererClass(this.snapshot, snapshot, rendererClass.renderElement, isPreview, willRender);
|
||
|
||
if (!renderer.shouldRender) {
|
||
this.forceReloaded = true;
|
||
} else {
|
||
visit?.changeHistory();
|
||
}
|
||
|
||
return this.render(renderer)
|
||
}
|
||
|
||
renderError(snapshot, visit) {
|
||
visit?.changeHistory();
|
||
const renderer = new ErrorRenderer(this.snapshot, snapshot, ErrorRenderer.renderElement, false);
|
||
return this.render(renderer)
|
||
}
|
||
|
||
clearSnapshotCache() {
|
||
this.snapshotCache.clear();
|
||
}
|
||
|
||
async cacheSnapshot(snapshot = this.snapshot) {
|
||
if (snapshot.isCacheable) {
|
||
this.delegate.viewWillCacheSnapshot();
|
||
const { lastRenderedLocation: location } = this;
|
||
await nextEventLoopTick();
|
||
const cachedSnapshot = snapshot.clone();
|
||
this.snapshotCache.put(location, cachedSnapshot);
|
||
return cachedSnapshot
|
||
}
|
||
}
|
||
|
||
getCachedSnapshotForLocation(location) {
|
||
return this.snapshotCache.get(location)
|
||
}
|
||
|
||
isPageRefresh(visit) {
|
||
return !visit || (this.lastRenderedLocation.pathname === visit.location.pathname && visit.action === "replace")
|
||
}
|
||
|
||
shouldPreserveScrollPosition(visit) {
|
||
return this.isPageRefresh(visit) && this.snapshot.shouldPreserveScrollPosition
|
||
}
|
||
|
||
get snapshot() {
|
||
return PageSnapshot.fromElement(this.element)
|
||
}
|
||
}
|
||
|
||
class Preloader {
|
||
selector = "a[data-turbo-preload]"
|
||
|
||
constructor(delegate, snapshotCache) {
|
||
this.delegate = delegate;
|
||
this.snapshotCache = snapshotCache;
|
||
}
|
||
|
||
start() {
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", this.#preloadAll);
|
||
} else {
|
||
this.preloadOnLoadLinksForView(document.body);
|
||
}
|
||
}
|
||
|
||
stop() {
|
||
document.removeEventListener("DOMContentLoaded", this.#preloadAll);
|
||
}
|
||
|
||
preloadOnLoadLinksForView(element) {
|
||
for (const link of element.querySelectorAll(this.selector)) {
|
||
if (this.delegate.shouldPreloadLink(link)) {
|
||
this.preloadURL(link);
|
||
}
|
||
}
|
||
}
|
||
|
||
async preloadURL(link) {
|
||
const location = new URL(link.href);
|
||
|
||
if (this.snapshotCache.has(location)) {
|
||
return
|
||
}
|
||
|
||
const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), link);
|
||
await fetchRequest.perform();
|
||
}
|
||
|
||
// Fetch request delegate
|
||
|
||
prepareRequest(fetchRequest) {
|
||
fetchRequest.headers["X-Sec-Purpose"] = "prefetch";
|
||
}
|
||
|
||
async requestSucceededWithResponse(fetchRequest, fetchResponse) {
|
||
try {
|
||
const responseHTML = await fetchResponse.responseHTML;
|
||
const snapshot = PageSnapshot.fromHTMLString(responseHTML);
|
||
|
||
this.snapshotCache.put(fetchRequest.url, snapshot);
|
||
} catch (_) {
|
||
// If we cannot preload that is ok!
|
||
}
|
||
}
|
||
|
||
requestStarted(fetchRequest) {}
|
||
|
||
requestErrored(fetchRequest) {}
|
||
|
||
requestFinished(fetchRequest) {}
|
||
|
||
requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
|
||
|
||
requestFailedWithResponse(fetchRequest, fetchResponse) {}
|
||
|
||
#preloadAll = () => {
|
||
this.preloadOnLoadLinksForView(document.body);
|
||
}
|
||
}
|
||
|
||
class Cache {
|
||
constructor(session) {
|
||
this.session = session;
|
||
}
|
||
|
||
clear() {
|
||
this.session.clearCache();
|
||
}
|
||
|
||
resetCacheControl() {
|
||
this.#setCacheControl("");
|
||
}
|
||
|
||
exemptPageFromCache() {
|
||
this.#setCacheControl("no-cache");
|
||
}
|
||
|
||
exemptPageFromPreview() {
|
||
this.#setCacheControl("no-preview");
|
||
}
|
||
|
||
#setCacheControl(value) {
|
||
setMetaContent("turbo-cache-control", value);
|
||
}
|
||
}
|
||
|
||
class Session {
|
||
navigator = new Navigator(this)
|
||
history = new History(this)
|
||
view = new PageView(this, document.documentElement)
|
||
adapter = new BrowserAdapter(this)
|
||
|
||
pageObserver = new PageObserver(this)
|
||
cacheObserver = new CacheObserver()
|
||
linkPrefetchObserver = new LinkPrefetchObserver(this, document)
|
||
linkClickObserver = new LinkClickObserver(this, window)
|
||
formSubmitObserver = new FormSubmitObserver(this, document)
|
||
scrollObserver = new ScrollObserver(this)
|
||
streamObserver = new StreamObserver(this)
|
||
formLinkClickObserver = new FormLinkClickObserver(this, document.documentElement)
|
||
frameRedirector = new FrameRedirector(this, document.documentElement)
|
||
streamMessageRenderer = new StreamMessageRenderer()
|
||
cache = new Cache(this)
|
||
|
||
drive = true
|
||
enabled = true
|
||
progressBarDelay = 500
|
||
started = false
|
||
formMode = "on"
|
||
#pageRefreshDebouncePeriod = 150
|
||
|
||
constructor(recentRequests) {
|
||
this.recentRequests = recentRequests;
|
||
this.preloader = new Preloader(this, this.view.snapshotCache);
|
||
this.debouncedRefresh = this.refresh;
|
||
this.pageRefreshDebouncePeriod = this.pageRefreshDebouncePeriod;
|
||
}
|
||
|
||
start() {
|
||
if (!this.started) {
|
||
this.pageObserver.start();
|
||
this.cacheObserver.start();
|
||
this.linkPrefetchObserver.start();
|
||
this.formLinkClickObserver.start();
|
||
this.linkClickObserver.start();
|
||
this.formSubmitObserver.start();
|
||
this.scrollObserver.start();
|
||
this.streamObserver.start();
|
||
this.frameRedirector.start();
|
||
this.history.start();
|
||
this.preloader.start();
|
||
this.started = true;
|
||
this.enabled = true;
|
||
}
|
||
}
|
||
|
||
disable() {
|
||
this.enabled = false;
|
||
}
|
||
|
||
stop() {
|
||
if (this.started) {
|
||
this.pageObserver.stop();
|
||
this.cacheObserver.stop();
|
||
this.linkPrefetchObserver.stop();
|
||
this.formLinkClickObserver.stop();
|
||
this.linkClickObserver.stop();
|
||
this.formSubmitObserver.stop();
|
||
this.scrollObserver.stop();
|
||
this.streamObserver.stop();
|
||
this.frameRedirector.stop();
|
||
this.history.stop();
|
||
this.preloader.stop();
|
||
this.started = false;
|
||
}
|
||
}
|
||
|
||
registerAdapter(adapter) {
|
||
this.adapter = adapter;
|
||
}
|
||
|
||
visit(location, options = {}) {
|
||
const frameElement = options.frame ? document.getElementById(options.frame) : null;
|
||
|
||
if (frameElement instanceof FrameElement) {
|
||
const action = options.action || getVisitAction(frameElement);
|
||
|
||
frameElement.delegate.proposeVisitIfNavigatedWithAction(frameElement, action);
|
||
frameElement.src = location.toString();
|
||
} else {
|
||
this.navigator.proposeVisit(expandURL(location), options);
|
||
}
|
||
}
|
||
|
||
refresh(url, requestId) {
|
||
const isRecentRequest = requestId && this.recentRequests.has(requestId);
|
||
if (!isRecentRequest && !this.navigator.currentVisit) {
|
||
this.visit(url, { action: "replace", shouldCacheSnapshot: false });
|
||
}
|
||
}
|
||
|
||
connectStreamSource(source) {
|
||
this.streamObserver.connectStreamSource(source);
|
||
}
|
||
|
||
disconnectStreamSource(source) {
|
||
this.streamObserver.disconnectStreamSource(source);
|
||
}
|
||
|
||
renderStreamMessage(message) {
|
||
this.streamMessageRenderer.render(StreamMessage.wrap(message));
|
||
}
|
||
|
||
clearCache() {
|
||
this.view.clearSnapshotCache();
|
||
}
|
||
|
||
setProgressBarDelay(delay) {
|
||
this.progressBarDelay = delay;
|
||
}
|
||
|
||
setFormMode(mode) {
|
||
this.formMode = mode;
|
||
}
|
||
|
||
get location() {
|
||
return this.history.location
|
||
}
|
||
|
||
get restorationIdentifier() {
|
||
return this.history.restorationIdentifier
|
||
}
|
||
|
||
get pageRefreshDebouncePeriod() {
|
||
return this.#pageRefreshDebouncePeriod
|
||
}
|
||
|
||
set pageRefreshDebouncePeriod(value) {
|
||
this.refresh = debounce(this.debouncedRefresh.bind(this), value);
|
||
this.#pageRefreshDebouncePeriod = value;
|
||
}
|
||
|
||
// Preloader delegate
|
||
|
||
shouldPreloadLink(element) {
|
||
const isUnsafe = element.hasAttribute("data-turbo-method");
|
||
const isStream = element.hasAttribute("data-turbo-stream");
|
||
const frameTarget = element.getAttribute("data-turbo-frame");
|
||
const frame = frameTarget == "_top" ?
|
||
null :
|
||
document.getElementById(frameTarget) || findClosestRecursively(element, "turbo-frame:not([disabled])");
|
||
|
||
if (isUnsafe || isStream || frame instanceof FrameElement) {
|
||
return false
|
||
} else {
|
||
const location = new URL(element.href);
|
||
|
||
return this.elementIsNavigatable(element) && locationIsVisitable(location, this.snapshot.rootLocation)
|
||
}
|
||
}
|
||
|
||
// History delegate
|
||
|
||
historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
|
||
if (this.enabled) {
|
||
this.navigator.startVisit(location, restorationIdentifier, {
|
||
action: "restore",
|
||
historyChanged: true,
|
||
direction
|
||
});
|
||
} else {
|
||
this.adapter.pageInvalidated({
|
||
reason: "turbo_disabled"
|
||
});
|
||
}
|
||
}
|
||
|
||
// Scroll observer delegate
|
||
|
||
scrollPositionChanged(position) {
|
||
this.history.updateRestorationData({ scrollPosition: position });
|
||
}
|
||
|
||
// Form click observer delegate
|
||
|
||
willSubmitFormLinkToLocation(link, location) {
|
||
return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation)
|
||
}
|
||
|
||
submittedFormLinkToLocation() {}
|
||
|
||
// Link hover observer delegate
|
||
|
||
canPrefetchRequestToLocation(link, location) {
|
||
return (
|
||
this.elementIsNavigatable(link) &&
|
||
locationIsVisitable(location, this.snapshot.rootLocation)
|
||
)
|
||
}
|
||
|
||
// Link click observer delegate
|
||
|
||
willFollowLinkToLocation(link, location, event) {
|
||
return (
|
||
this.elementIsNavigatable(link) &&
|
||
locationIsVisitable(location, this.snapshot.rootLocation) &&
|
||
this.applicationAllowsFollowingLinkToLocation(link, location, event)
|
||
)
|
||
}
|
||
|
||
followedLinkToLocation(link, location) {
|
||
const action = this.getActionForLink(link);
|
||
const acceptsStreamResponse = link.hasAttribute("data-turbo-stream");
|
||
|
||
this.visit(location.href, { action, acceptsStreamResponse });
|
||
}
|
||
|
||
// Navigator delegate
|
||
|
||
allowsVisitingLocationWithAction(location, action) {
|
||
return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location)
|
||
}
|
||
|
||
visitProposedToLocation(location, options) {
|
||
extendURLWithDeprecatedProperties(location);
|
||
this.adapter.visitProposedToLocation(location, options);
|
||
}
|
||
|
||
// Visit delegate
|
||
|
||
visitStarted(visit) {
|
||
if (!visit.acceptsStreamResponse) {
|
||
markAsBusy(document.documentElement);
|
||
this.view.markVisitDirection(visit.direction);
|
||
}
|
||
extendURLWithDeprecatedProperties(visit.location);
|
||
if (!visit.silent) {
|
||
this.notifyApplicationAfterVisitingLocation(visit.location, visit.action);
|
||
}
|
||
}
|
||
|
||
visitCompleted(visit) {
|
||
this.view.unmarkVisitDirection();
|
||
clearBusyState(document.documentElement);
|
||
this.notifyApplicationAfterPageLoad(visit.getTimingMetrics());
|
||
}
|
||
|
||
locationWithActionIsSamePage(location, action) {
|
||
return this.navigator.locationWithActionIsSamePage(location, action)
|
||
}
|
||
|
||
visitScrolledToSamePageLocation(oldURL, newURL) {
|
||
this.notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL);
|
||
}
|
||
|
||
// Form submit observer delegate
|
||
|
||
willSubmitForm(form, submitter) {
|
||
const action = getAction$1(form, submitter);
|
||
|
||
return (
|
||
this.submissionIsNavigatable(form, submitter) &&
|
||
locationIsVisitable(expandURL(action), this.snapshot.rootLocation)
|
||
)
|
||
}
|
||
|
||
formSubmitted(form, submitter) {
|
||
this.navigator.submitForm(form, submitter);
|
||
}
|
||
|
||
// Page observer delegate
|
||
|
||
pageBecameInteractive() {
|
||
this.view.lastRenderedLocation = this.location;
|
||
this.notifyApplicationAfterPageLoad();
|
||
}
|
||
|
||
pageLoaded() {
|
||
this.history.assumeControlOfScrollRestoration();
|
||
}
|
||
|
||
pageWillUnload() {
|
||
this.history.relinquishControlOfScrollRestoration();
|
||
}
|
||
|
||
// Stream observer delegate
|
||
|
||
receivedMessageFromStream(message) {
|
||
this.renderStreamMessage(message);
|
||
}
|
||
|
||
// Page view delegate
|
||
|
||
viewWillCacheSnapshot() {
|
||
if (!this.navigator.currentVisit?.silent) {
|
||
this.notifyApplicationBeforeCachingSnapshot();
|
||
}
|
||
}
|
||
|
||
allowsImmediateRender({ element }, options) {
|
||
const event = this.notifyApplicationBeforeRender(element, options);
|
||
const {
|
||
defaultPrevented,
|
||
detail: { render }
|
||
} = event;
|
||
|
||
if (this.view.renderer && render) {
|
||
this.view.renderer.renderElement = render;
|
||
}
|
||
|
||
return !defaultPrevented
|
||
}
|
||
|
||
viewRenderedSnapshot(_snapshot, _isPreview, renderMethod) {
|
||
this.view.lastRenderedLocation = this.history.location;
|
||
this.notifyApplicationAfterRender(renderMethod);
|
||
}
|
||
|
||
preloadOnLoadLinksForView(element) {
|
||
this.preloader.preloadOnLoadLinksForView(element);
|
||
}
|
||
|
||
viewInvalidated(reason) {
|
||
this.adapter.pageInvalidated(reason);
|
||
}
|
||
|
||
// Frame element
|
||
|
||
frameLoaded(frame) {
|
||
this.notifyApplicationAfterFrameLoad(frame);
|
||
}
|
||
|
||
frameRendered(fetchResponse, frame) {
|
||
this.notifyApplicationAfterFrameRender(fetchResponse, frame);
|
||
}
|
||
|
||
// Application events
|
||
|
||
applicationAllowsFollowingLinkToLocation(link, location, ev) {
|
||
const event = this.notifyApplicationAfterClickingLinkToLocation(link, location, ev);
|
||
return !event.defaultPrevented
|
||
}
|
||
|
||
applicationAllowsVisitingLocation(location) {
|
||
const event = this.notifyApplicationBeforeVisitingLocation(location);
|
||
return !event.defaultPrevented
|
||
}
|
||
|
||
notifyApplicationAfterClickingLinkToLocation(link, location, event) {
|
||
return dispatch("turbo:click", {
|
||
target: link,
|
||
detail: { url: location.href, originalEvent: event },
|
||
cancelable: true
|
||
})
|
||
}
|
||
|
||
notifyApplicationBeforeVisitingLocation(location) {
|
||
return dispatch("turbo:before-visit", {
|
||
detail: { url: location.href },
|
||
cancelable: true
|
||
})
|
||
}
|
||
|
||
notifyApplicationAfterVisitingLocation(location, action) {
|
||
return dispatch("turbo:visit", { detail: { url: location.href, action } })
|
||
}
|
||
|
||
notifyApplicationBeforeCachingSnapshot() {
|
||
return dispatch("turbo:before-cache")
|
||
}
|
||
|
||
notifyApplicationBeforeRender(newBody, options) {
|
||
return dispatch("turbo:before-render", {
|
||
detail: { newBody, ...options },
|
||
cancelable: true
|
||
})
|
||
}
|
||
|
||
notifyApplicationAfterRender(renderMethod) {
|
||
return dispatch("turbo:render", { detail: { renderMethod } })
|
||
}
|
||
|
||
notifyApplicationAfterPageLoad(timing = {}) {
|
||
return dispatch("turbo:load", {
|
||
detail: { url: this.location.href, timing }
|
||
})
|
||
}
|
||
|
||
notifyApplicationAfterVisitingSamePageLocation(oldURL, newURL) {
|
||
dispatchEvent(
|
||
new HashChangeEvent("hashchange", {
|
||
oldURL: oldURL.toString(),
|
||
newURL: newURL.toString()
|
||
})
|
||
);
|
||
}
|
||
|
||
notifyApplicationAfterFrameLoad(frame) {
|
||
return dispatch("turbo:frame-load", { target: frame })
|
||
}
|
||
|
||
notifyApplicationAfterFrameRender(fetchResponse, frame) {
|
||
return dispatch("turbo:frame-render", {
|
||
detail: { fetchResponse },
|
||
target: frame,
|
||
cancelable: true
|
||
})
|
||
}
|
||
|
||
// Helpers
|
||
|
||
submissionIsNavigatable(form, submitter) {
|
||
if (this.formMode == "off") {
|
||
return false
|
||
} else {
|
||
const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true;
|
||
|
||
if (this.formMode == "optin") {
|
||
return submitterIsNavigatable && form.closest('[data-turbo="true"]') != null
|
||
} else {
|
||
return submitterIsNavigatable && this.elementIsNavigatable(form)
|
||
}
|
||
}
|
||
}
|
||
|
||
elementIsNavigatable(element) {
|
||
const container = findClosestRecursively(element, "[data-turbo]");
|
||
const withinFrame = findClosestRecursively(element, "turbo-frame");
|
||
|
||
// Check if Drive is enabled on the session or we're within a Frame.
|
||
if (this.drive || withinFrame) {
|
||
// Element is navigatable by default, unless `data-turbo="false"`.
|
||
if (container) {
|
||
return container.getAttribute("data-turbo") != "false"
|
||
} else {
|
||
return true
|
||
}
|
||
} else {
|
||
// Element isn't navigatable by default, unless `data-turbo="true"`.
|
||
if (container) {
|
||
return container.getAttribute("data-turbo") == "true"
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Private
|
||
|
||
getActionForLink(link) {
|
||
return getVisitAction(link) || "advance"
|
||
}
|
||
|
||
get snapshot() {
|
||
return this.view.snapshot
|
||
}
|
||
}
|
||
|
||
// Older versions of the Turbo Native adapters referenced the
|
||
// `Location#absoluteURL` property in their implementations of
|
||
// the `Adapter#visitProposedToLocation()` and `#visitStarted()`
|
||
// methods. The Location class has since been removed in favor
|
||
// of the DOM URL API, and accordingly all Adapter methods now
|
||
// receive URL objects.
|
||
//
|
||
// We alias #absoluteURL to #toString() here to avoid crashing
|
||
// older adapters which do not expect URL objects. We should
|
||
// consider removing this support at some point in the future.
|
||
|
||
function extendURLWithDeprecatedProperties(url) {
|
||
Object.defineProperties(url, deprecatedLocationPropertyDescriptors);
|
||
}
|
||
|
||
const deprecatedLocationPropertyDescriptors = {
|
||
absoluteURL: {
|
||
get() {
|
||
return this.toString()
|
||
}
|
||
}
|
||
};
|
||
|
||
const session = new Session(recentRequests);
|
||
const { cache, navigator: navigator$1 } = session;
|
||
|
||
/**
|
||
* Starts the main session.
|
||
* This initialises any necessary observers such as those to monitor
|
||
* link interactions.
|
||
*/
|
||
function start() {
|
||
session.start();
|
||
}
|
||
|
||
/**
|
||
* Registers an adapter for the main session.
|
||
*
|
||
* @param adapter Adapter to register
|
||
*/
|
||
function registerAdapter(adapter) {
|
||
session.registerAdapter(adapter);
|
||
}
|
||
|
||
/**
|
||
* Performs an application visit to the given location.
|
||
*
|
||
* @param location Location to visit (a URL or path)
|
||
* @param options Options to apply
|
||
* @param options.action Type of history navigation to apply ("restore",
|
||
* "replace" or "advance")
|
||
* @param options.historyChanged Specifies whether the browser history has
|
||
* already been changed for this visit or not
|
||
* @param options.referrer Specifies the referrer of this visit such that
|
||
* navigations to the same page will not result in a new history entry.
|
||
* @param options.snapshotHTML Cached snapshot to render
|
||
* @param options.response Response of the specified location
|
||
*/
|
||
function visit(location, options) {
|
||
session.visit(location, options);
|
||
}
|
||
|
||
/**
|
||
* Connects a stream source to the main session.
|
||
*
|
||
* @param source Stream source to connect
|
||
*/
|
||
function connectStreamSource(source) {
|
||
session.connectStreamSource(source);
|
||
}
|
||
|
||
/**
|
||
* Disconnects a stream source from the main session.
|
||
*
|
||
* @param source Stream source to disconnect
|
||
*/
|
||
function disconnectStreamSource(source) {
|
||
session.disconnectStreamSource(source);
|
||
}
|
||
|
||
/**
|
||
* Renders a stream message to the main session by appending it to the
|
||
* current document.
|
||
*
|
||
* @param message Message to render
|
||
*/
|
||
function renderStreamMessage(message) {
|
||
session.renderStreamMessage(message);
|
||
}
|
||
|
||
/**
|
||
* Removes all entries from the Turbo Drive page cache.
|
||
* Call this when state has changed on the server that may affect cached pages.
|
||
*
|
||
* @deprecated since version 7.2.0 in favor of `Turbo.cache.clear()`
|
||
*/
|
||
function clearCache() {
|
||
console.warn(
|
||
"Please replace `Turbo.clearCache()` with `Turbo.cache.clear()`. The top-level function is deprecated and will be removed in a future version of Turbo.`"
|
||
);
|
||
session.clearCache();
|
||
}
|
||
|
||
/**
|
||
* Sets the delay after which the progress bar will appear during navigation.
|
||
*
|
||
* The progress bar appears after 500ms by default.
|
||
*
|
||
* Note that this method has no effect when used with the iOS or Android
|
||
* adapters.
|
||
*
|
||
* @param delay Time to delay in milliseconds
|
||
*/
|
||
function setProgressBarDelay(delay) {
|
||
session.setProgressBarDelay(delay);
|
||
}
|
||
|
||
function setConfirmMethod(confirmMethod) {
|
||
FormSubmission.confirmMethod = confirmMethod;
|
||
}
|
||
|
||
function setFormMode(mode) {
|
||
session.setFormMode(mode);
|
||
}
|
||
|
||
var Turbo = /*#__PURE__*/Object.freeze({
|
||
__proto__: null,
|
||
navigator: navigator$1,
|
||
session: session,
|
||
cache: cache,
|
||
PageRenderer: PageRenderer,
|
||
PageSnapshot: PageSnapshot,
|
||
FrameRenderer: FrameRenderer,
|
||
fetch: fetchWithTurboHeaders,
|
||
start: start,
|
||
registerAdapter: registerAdapter,
|
||
visit: visit,
|
||
connectStreamSource: connectStreamSource,
|
||
disconnectStreamSource: disconnectStreamSource,
|
||
renderStreamMessage: renderStreamMessage,
|
||
clearCache: clearCache,
|
||
setProgressBarDelay: setProgressBarDelay,
|
||
setConfirmMethod: setConfirmMethod,
|
||
setFormMode: setFormMode
|
||
});
|
||
|
||
class TurboFrameMissingError extends Error {}
|
||
|
||
class FrameController {
|
||
fetchResponseLoaded = (_fetchResponse) => Promise.resolve()
|
||
#currentFetchRequest = null
|
||
#resolveVisitPromise = () => {}
|
||
#connected = false
|
||
#hasBeenLoaded = false
|
||
#ignoredAttributes = new Set()
|
||
action = null
|
||
|
||
constructor(element) {
|
||
this.element = element;
|
||
this.view = new FrameView(this, this.element);
|
||
this.appearanceObserver = new AppearanceObserver(this, this.element);
|
||
this.formLinkClickObserver = new FormLinkClickObserver(this, this.element);
|
||
this.linkInterceptor = new LinkInterceptor(this, this.element);
|
||
this.restorationIdentifier = uuid();
|
||
this.formSubmitObserver = new FormSubmitObserver(this, this.element);
|
||
}
|
||
|
||
// Frame delegate
|
||
|
||
connect() {
|
||
if (!this.#connected) {
|
||
this.#connected = true;
|
||
if (this.loadingStyle == FrameLoadingStyle.lazy) {
|
||
this.appearanceObserver.start();
|
||
} else {
|
||
this.#loadSourceURL();
|
||
}
|
||
this.formLinkClickObserver.start();
|
||
this.linkInterceptor.start();
|
||
this.formSubmitObserver.start();
|
||
}
|
||
}
|
||
|
||
disconnect() {
|
||
if (this.#connected) {
|
||
this.#connected = false;
|
||
this.appearanceObserver.stop();
|
||
this.formLinkClickObserver.stop();
|
||
this.linkInterceptor.stop();
|
||
this.formSubmitObserver.stop();
|
||
}
|
||
}
|
||
|
||
disabledChanged() {
|
||
if (this.loadingStyle == FrameLoadingStyle.eager) {
|
||
this.#loadSourceURL();
|
||
}
|
||
}
|
||
|
||
sourceURLChanged() {
|
||
if (this.#isIgnoringChangesTo("src")) return
|
||
|
||
if (this.element.isConnected) {
|
||
this.complete = false;
|
||
}
|
||
|
||
if (this.loadingStyle == FrameLoadingStyle.eager || this.#hasBeenLoaded) {
|
||
this.#loadSourceURL();
|
||
}
|
||
}
|
||
|
||
sourceURLReloaded() {
|
||
const { src } = this.element;
|
||
this.element.removeAttribute("complete");
|
||
this.element.src = null;
|
||
this.element.src = src;
|
||
return this.element.loaded
|
||
}
|
||
|
||
loadingStyleChanged() {
|
||
if (this.loadingStyle == FrameLoadingStyle.lazy) {
|
||
this.appearanceObserver.start();
|
||
} else {
|
||
this.appearanceObserver.stop();
|
||
this.#loadSourceURL();
|
||
}
|
||
}
|
||
|
||
async #loadSourceURL() {
|
||
if (this.enabled && this.isActive && !this.complete && this.sourceURL) {
|
||
this.element.loaded = this.#visit(expandURL(this.sourceURL));
|
||
this.appearanceObserver.stop();
|
||
await this.element.loaded;
|
||
this.#hasBeenLoaded = true;
|
||
}
|
||
}
|
||
|
||
async loadResponse(fetchResponse) {
|
||
if (fetchResponse.redirected || (fetchResponse.succeeded && fetchResponse.isHTML)) {
|
||
this.sourceURL = fetchResponse.response.url;
|
||
}
|
||
|
||
try {
|
||
const html = await fetchResponse.responseHTML;
|
||
if (html) {
|
||
const document = parseHTMLDocument(html);
|
||
const pageSnapshot = PageSnapshot.fromDocument(document);
|
||
|
||
if (pageSnapshot.isVisitable) {
|
||
await this.#loadFrameResponse(fetchResponse, document);
|
||
} else {
|
||
await this.#handleUnvisitableFrameResponse(fetchResponse);
|
||
}
|
||
}
|
||
} finally {
|
||
this.fetchResponseLoaded = () => Promise.resolve();
|
||
}
|
||
}
|
||
|
||
// Appearance observer delegate
|
||
|
||
elementAppearedInViewport(element) {
|
||
this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element));
|
||
this.#loadSourceURL();
|
||
}
|
||
|
||
// Form link click observer delegate
|
||
|
||
willSubmitFormLinkToLocation(link) {
|
||
return this.#shouldInterceptNavigation(link)
|
||
}
|
||
|
||
submittedFormLinkToLocation(link, _location, form) {
|
||
const frame = this.#findFrameElement(link);
|
||
if (frame) form.setAttribute("data-turbo-frame", frame.id);
|
||
}
|
||
|
||
// Link interceptor delegate
|
||
|
||
shouldInterceptLinkClick(element, _location, _event) {
|
||
return this.#shouldInterceptNavigation(element)
|
||
}
|
||
|
||
linkClickIntercepted(element, location) {
|
||
this.#navigateFrame(element, location);
|
||
}
|
||
|
||
// Form submit observer delegate
|
||
|
||
willSubmitForm(element, submitter) {
|
||
return element.closest("turbo-frame") == this.element && this.#shouldInterceptNavigation(element, submitter)
|
||
}
|
||
|
||
formSubmitted(element, submitter) {
|
||
if (this.formSubmission) {
|
||
this.formSubmission.stop();
|
||
}
|
||
|
||
this.formSubmission = new FormSubmission(this, element, submitter);
|
||
const { fetchRequest } = this.formSubmission;
|
||
this.prepareRequest(fetchRequest);
|
||
this.formSubmission.start();
|
||
}
|
||
|
||
// Fetch request delegate
|
||
|
||
prepareRequest(request) {
|
||
request.headers["Turbo-Frame"] = this.id;
|
||
|
||
if (this.currentNavigationElement?.hasAttribute("data-turbo-stream")) {
|
||
request.acceptResponseType(StreamMessage.contentType);
|
||
}
|
||
}
|
||
|
||
requestStarted(_request) {
|
||
markAsBusy(this.element);
|
||
}
|
||
|
||
requestPreventedHandlingResponse(_request, _response) {
|
||
this.#resolveVisitPromise();
|
||
}
|
||
|
||
async requestSucceededWithResponse(request, response) {
|
||
await this.loadResponse(response);
|
||
this.#resolveVisitPromise();
|
||
}
|
||
|
||
async requestFailedWithResponse(request, response) {
|
||
await this.loadResponse(response);
|
||
this.#resolveVisitPromise();
|
||
}
|
||
|
||
requestErrored(request, error) {
|
||
console.error(error);
|
||
this.#resolveVisitPromise();
|
||
}
|
||
|
||
requestFinished(_request) {
|
||
clearBusyState(this.element);
|
||
}
|
||
|
||
// Form submission delegate
|
||
|
||
formSubmissionStarted({ formElement }) {
|
||
markAsBusy(formElement, this.#findFrameElement(formElement));
|
||
}
|
||
|
||
formSubmissionSucceededWithResponse(formSubmission, response) {
|
||
const frame = this.#findFrameElement(formSubmission.formElement, formSubmission.submitter);
|
||
|
||
frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(formSubmission.submitter, formSubmission.formElement, frame));
|
||
frame.delegate.loadResponse(response);
|
||
|
||
if (!formSubmission.isSafe) {
|
||
session.clearCache();
|
||
}
|
||
}
|
||
|
||
formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
|
||
this.element.delegate.loadResponse(fetchResponse);
|
||
session.clearCache();
|
||
}
|
||
|
||
formSubmissionErrored(formSubmission, error) {
|
||
console.error(error);
|
||
}
|
||
|
||
formSubmissionFinished({ formElement }) {
|
||
clearBusyState(formElement, this.#findFrameElement(formElement));
|
||
}
|
||
|
||
// View delegate
|
||
|
||
allowsImmediateRender({ element: newFrame }, options) {
|
||
const event = dispatch("turbo:before-frame-render", {
|
||
target: this.element,
|
||
detail: { newFrame, ...options },
|
||
cancelable: true
|
||
});
|
||
const {
|
||
defaultPrevented,
|
||
detail: { render }
|
||
} = event;
|
||
|
||
if (this.view.renderer && render) {
|
||
this.view.renderer.renderElement = render;
|
||
}
|
||
|
||
return !defaultPrevented
|
||
}
|
||
|
||
viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}
|
||
|
||
preloadOnLoadLinksForView(element) {
|
||
session.preloadOnLoadLinksForView(element);
|
||
}
|
||
|
||
viewInvalidated() {}
|
||
|
||
// Frame renderer delegate
|
||
|
||
willRenderFrame(currentElement, _newElement) {
|
||
this.previousFrameElement = currentElement.cloneNode(true);
|
||
}
|
||
|
||
visitCachedSnapshot = ({ element }) => {
|
||
const frame = element.querySelector("#" + this.element.id);
|
||
|
||
if (frame && this.previousFrameElement) {
|
||
frame.replaceChildren(...this.previousFrameElement.children);
|
||
}
|
||
|
||
delete this.previousFrameElement;
|
||
}
|
||
|
||
// Private
|
||
|
||
async #loadFrameResponse(fetchResponse, document) {
|
||
const newFrameElement = await this.extractForeignFrameElement(document.body);
|
||
|
||
if (newFrameElement) {
|
||
const snapshot = new Snapshot(newFrameElement);
|
||
const renderer = new FrameRenderer(this, this.view.snapshot, snapshot, FrameRenderer.renderElement, false, false);
|
||
if (this.view.renderPromise) await this.view.renderPromise;
|
||
this.changeHistory();
|
||
|
||
await this.view.render(renderer);
|
||
this.complete = true;
|
||
session.frameRendered(fetchResponse, this.element);
|
||
session.frameLoaded(this.element);
|
||
await this.fetchResponseLoaded(fetchResponse);
|
||
} else if (this.#willHandleFrameMissingFromResponse(fetchResponse)) {
|
||
this.#handleFrameMissingFromResponse(fetchResponse);
|
||
}
|
||
}
|
||
|
||
async #visit(url) {
|
||
const request = new FetchRequest(this, FetchMethod.get, url, new URLSearchParams(), this.element);
|
||
|
||
this.#currentFetchRequest?.cancel();
|
||
this.#currentFetchRequest = request;
|
||
|
||
return new Promise((resolve) => {
|
||
this.#resolveVisitPromise = () => {
|
||
this.#resolveVisitPromise = () => {};
|
||
this.#currentFetchRequest = null;
|
||
resolve();
|
||
};
|
||
request.perform();
|
||
})
|
||
}
|
||
|
||
#navigateFrame(element, url, submitter) {
|
||
const frame = this.#findFrameElement(element, submitter);
|
||
|
||
frame.delegate.proposeVisitIfNavigatedWithAction(frame, getVisitAction(submitter, element, frame));
|
||
|
||
this.#withCurrentNavigationElement(element, () => {
|
||
frame.src = url;
|
||
});
|
||
}
|
||
|
||
proposeVisitIfNavigatedWithAction(frame, action = null) {
|
||
this.action = action;
|
||
|
||
if (this.action) {
|
||
const pageSnapshot = PageSnapshot.fromElement(frame).clone();
|
||
const { visitCachedSnapshot } = frame.delegate;
|
||
|
||
frame.delegate.fetchResponseLoaded = async (fetchResponse) => {
|
||
if (frame.src) {
|
||
const { statusCode, redirected } = fetchResponse;
|
||
const responseHTML = await fetchResponse.responseHTML;
|
||
const response = { statusCode, redirected, responseHTML };
|
||
const options = {
|
||
response,
|
||
visitCachedSnapshot,
|
||
willRender: false,
|
||
updateHistory: false,
|
||
restorationIdentifier: this.restorationIdentifier,
|
||
snapshot: pageSnapshot
|
||
};
|
||
|
||
if (this.action) options.action = this.action;
|
||
|
||
session.visit(frame.src, options);
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
changeHistory() {
|
||
if (this.action) {
|
||
const method = getHistoryMethodForAction(this.action);
|
||
session.history.update(method, expandURL(this.element.src || ""), this.restorationIdentifier);
|
||
}
|
||
}
|
||
|
||
async #handleUnvisitableFrameResponse(fetchResponse) {
|
||
console.warn(
|
||
`The response (${fetchResponse.statusCode}) from <turbo-frame id="${this.element.id}"> is performing a full page visit due to turbo-visit-control.`
|
||
);
|
||
|
||
await this.#visitResponse(fetchResponse.response);
|
||
}
|
||
|
||
#willHandleFrameMissingFromResponse(fetchResponse) {
|
||
this.element.setAttribute("complete", "");
|
||
|
||
const response = fetchResponse.response;
|
||
const visit = async (url, options) => {
|
||
if (url instanceof Response) {
|
||
this.#visitResponse(url);
|
||
} else {
|
||
session.visit(url, options);
|
||
}
|
||
};
|
||
|
||
const event = dispatch("turbo:frame-missing", {
|
||
target: this.element,
|
||
detail: { response, visit },
|
||
cancelable: true
|
||
});
|
||
|
||
return !event.defaultPrevented
|
||
}
|
||
|
||
#handleFrameMissingFromResponse(fetchResponse) {
|
||
this.view.missing();
|
||
this.#throwFrameMissingError(fetchResponse);
|
||
}
|
||
|
||
#throwFrameMissingError(fetchResponse) {
|
||
const message = `The response (${fetchResponse.statusCode}) did not contain the expected <turbo-frame id="${this.element.id}"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`;
|
||
throw new TurboFrameMissingError(message)
|
||
}
|
||
|
||
async #visitResponse(response) {
|
||
const wrapped = new FetchResponse(response);
|
||
const responseHTML = await wrapped.responseHTML;
|
||
const { location, redirected, statusCode } = wrapped;
|
||
|
||
return session.visit(location, { response: { redirected, statusCode, responseHTML } })
|
||
}
|
||
|
||
#findFrameElement(element, submitter) {
|
||
const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
|
||
return getFrameElementById(id) ?? this.element
|
||
}
|
||
|
||
async extractForeignFrameElement(container) {
|
||
let element;
|
||
const id = CSS.escape(this.id);
|
||
|
||
try {
|
||
element = activateElement(container.querySelector(`turbo-frame#${id}`), this.sourceURL);
|
||
if (element) {
|
||
return element
|
||
}
|
||
|
||
element = activateElement(container.querySelector(`turbo-frame[src][recurse~=${id}]`), this.sourceURL);
|
||
if (element) {
|
||
await element.loaded;
|
||
return await this.extractForeignFrameElement(element)
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
return new FrameElement()
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
#formActionIsVisitable(form, submitter) {
|
||
const action = getAction$1(form, submitter);
|
||
|
||
return locationIsVisitable(expandURL(action), this.rootLocation)
|
||
}
|
||
|
||
#shouldInterceptNavigation(element, submitter) {
|
||
const id = getAttribute("data-turbo-frame", submitter, element) || this.element.getAttribute("target");
|
||
|
||
if (element instanceof HTMLFormElement && !this.#formActionIsVisitable(element, submitter)) {
|
||
return false
|
||
}
|
||
|
||
if (!this.enabled || id == "_top") {
|
||
return false
|
||
}
|
||
|
||
if (id) {
|
||
const frameElement = getFrameElementById(id);
|
||
if (frameElement) {
|
||
return !frameElement.disabled
|
||
}
|
||
}
|
||
|
||
if (!session.elementIsNavigatable(element)) {
|
||
return false
|
||
}
|
||
|
||
if (submitter && !session.elementIsNavigatable(submitter)) {
|
||
return false
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// Computed properties
|
||
|
||
get id() {
|
||
return this.element.id
|
||
}
|
||
|
||
get enabled() {
|
||
return !this.element.disabled
|
||
}
|
||
|
||
get sourceURL() {
|
||
if (this.element.src) {
|
||
return this.element.src
|
||
}
|
||
}
|
||
|
||
set sourceURL(sourceURL) {
|
||
this.#ignoringChangesToAttribute("src", () => {
|
||
this.element.src = sourceURL ?? null;
|
||
});
|
||
}
|
||
|
||
get loadingStyle() {
|
||
return this.element.loading
|
||
}
|
||
|
||
get isLoading() {
|
||
return this.formSubmission !== undefined || this.#resolveVisitPromise() !== undefined
|
||
}
|
||
|
||
get complete() {
|
||
return this.element.hasAttribute("complete")
|
||
}
|
||
|
||
set complete(value) {
|
||
if (value) {
|
||
this.element.setAttribute("complete", "");
|
||
} else {
|
||
this.element.removeAttribute("complete");
|
||
}
|
||
}
|
||
|
||
get isActive() {
|
||
return this.element.isActive && this.#connected
|
||
}
|
||
|
||
get rootLocation() {
|
||
const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`);
|
||
const root = meta?.content ?? "/";
|
||
return expandURL(root)
|
||
}
|
||
|
||
#isIgnoringChangesTo(attributeName) {
|
||
return this.#ignoredAttributes.has(attributeName)
|
||
}
|
||
|
||
#ignoringChangesToAttribute(attributeName, callback) {
|
||
this.#ignoredAttributes.add(attributeName);
|
||
callback();
|
||
this.#ignoredAttributes.delete(attributeName);
|
||
}
|
||
|
||
#withCurrentNavigationElement(element, callback) {
|
||
this.currentNavigationElement = element;
|
||
callback();
|
||
delete this.currentNavigationElement;
|
||
}
|
||
}
|
||
|
||
function getFrameElementById(id) {
|
||
if (id != null) {
|
||
const element = document.getElementById(id);
|
||
if (element instanceof FrameElement) {
|
||
return element
|
||
}
|
||
}
|
||
}
|
||
|
||
function activateElement(element, currentURL) {
|
||
if (element) {
|
||
const src = element.getAttribute("src");
|
||
if (src != null && currentURL != null && urlsAreEqual(src, currentURL)) {
|
||
throw new Error(`Matching <turbo-frame id="${element.id}"> element has a source URL which references itself`)
|
||
}
|
||
if (element.ownerDocument !== document) {
|
||
element = document.importNode(element, true);
|
||
}
|
||
|
||
if (element instanceof FrameElement) {
|
||
element.connectedCallback();
|
||
element.disconnectedCallback();
|
||
return element
|
||
}
|
||
}
|
||
}
|
||
|
||
const StreamActions = {
|
||
after() {
|
||
this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e.nextSibling));
|
||
},
|
||
|
||
append() {
|
||
this.removeDuplicateTargetChildren();
|
||
this.targetElements.forEach((e) => e.append(this.templateContent));
|
||
},
|
||
|
||
before() {
|
||
this.targetElements.forEach((e) => e.parentElement?.insertBefore(this.templateContent, e));
|
||
},
|
||
|
||
prepend() {
|
||
this.removeDuplicateTargetChildren();
|
||
this.targetElements.forEach((e) => e.prepend(this.templateContent));
|
||
},
|
||
|
||
remove() {
|
||
this.targetElements.forEach((e) => e.remove());
|
||
},
|
||
|
||
replace() {
|
||
const method = this.getAttribute("method");
|
||
|
||
this.targetElements.forEach((targetElement) => {
|
||
if (method === "morph") {
|
||
morphElements(targetElement, this.templateContent);
|
||
} else {
|
||
targetElement.replaceWith(this.templateContent);
|
||
}
|
||
});
|
||
},
|
||
|
||
update() {
|
||
const method = this.getAttribute("method");
|
||
|
||
this.targetElements.forEach((targetElement) => {
|
||
if (method === "morph") {
|
||
morphChildren(targetElement, this.templateContent);
|
||
} else {
|
||
targetElement.innerHTML = "";
|
||
targetElement.append(this.templateContent);
|
||
}
|
||
});
|
||
},
|
||
|
||
refresh() {
|
||
session.refresh(this.baseURI, this.requestId);
|
||
}
|
||
};
|
||
|
||
// <turbo-stream action=replace target=id><template>...
|
||
|
||
/**
|
||
* Renders updates to the page from a stream of messages.
|
||
*
|
||
* Using the `action` attribute, this can be configured one of eight ways:
|
||
*
|
||
* - `after` - inserts the result after the target
|
||
* - `append` - appends the result to the target
|
||
* - `before` - inserts the result before the target
|
||
* - `prepend` - prepends the result to the target
|
||
* - `refresh` - initiates a page refresh
|
||
* - `remove` - removes the target
|
||
* - `replace` - replaces the outer HTML of the target
|
||
* - `update` - replaces the inner HTML of the target
|
||
*
|
||
* @customElement turbo-stream
|
||
* @example
|
||
* <turbo-stream action="append" target="dom_id">
|
||
* <template>
|
||
* Content to append to target designated with the dom_id.
|
||
* </template>
|
||
* </turbo-stream>
|
||
*/
|
||
class StreamElement extends HTMLElement {
|
||
static async renderElement(newElement) {
|
||
await newElement.performAction();
|
||
}
|
||
|
||
async connectedCallback() {
|
||
try {
|
||
await this.render();
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
this.disconnect();
|
||
}
|
||
}
|
||
|
||
async render() {
|
||
return (this.renderPromise ??= (async () => {
|
||
const event = this.beforeRenderEvent;
|
||
|
||
if (this.dispatchEvent(event)) {
|
||
await nextRepaint();
|
||
await event.detail.render(this);
|
||
}
|
||
})())
|
||
}
|
||
|
||
disconnect() {
|
||
try {
|
||
this.remove();
|
||
// eslint-disable-next-line no-empty
|
||
} catch {}
|
||
}
|
||
|
||
/**
|
||
* Removes duplicate children (by ID)
|
||
*/
|
||
removeDuplicateTargetChildren() {
|
||
this.duplicateChildren.forEach((c) => c.remove());
|
||
}
|
||
|
||
/**
|
||
* Gets the list of duplicate children (i.e. those with the same ID)
|
||
*/
|
||
get duplicateChildren() {
|
||
const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id);
|
||
const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id);
|
||
|
||
return existingChildren.filter((c) => newChildrenIds.includes(c.id))
|
||
}
|
||
|
||
/**
|
||
* Gets the action function to be performed.
|
||
*/
|
||
get performAction() {
|
||
if (this.action) {
|
||
const actionFunction = StreamActions[this.action];
|
||
if (actionFunction) {
|
||
return actionFunction
|
||
}
|
||
this.#raise("unknown action");
|
||
}
|
||
this.#raise("action attribute is missing");
|
||
}
|
||
|
||
/**
|
||
* Gets the target elements which the template will be rendered to.
|
||
*/
|
||
get targetElements() {
|
||
if (this.target) {
|
||
return this.targetElementsById
|
||
} else if (this.targets) {
|
||
return this.targetElementsByQuery
|
||
} else {
|
||
this.#raise("target or targets attribute is missing");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gets the contents of the main `<template>`.
|
||
*/
|
||
get templateContent() {
|
||
return this.templateElement.content.cloneNode(true)
|
||
}
|
||
|
||
/**
|
||
* Gets the main `<template>` used for rendering
|
||
*/
|
||
get templateElement() {
|
||
if (this.firstElementChild === null) {
|
||
const template = this.ownerDocument.createElement("template");
|
||
this.appendChild(template);
|
||
return template
|
||
} else if (this.firstElementChild instanceof HTMLTemplateElement) {
|
||
return this.firstElementChild
|
||
}
|
||
this.#raise("first child element must be a <template> element");
|
||
}
|
||
|
||
/**
|
||
* Gets the current action.
|
||
*/
|
||
get action() {
|
||
return this.getAttribute("action")
|
||
}
|
||
|
||
/**
|
||
* Gets the current target (an element ID) to which the result will
|
||
* be rendered.
|
||
*/
|
||
get target() {
|
||
return this.getAttribute("target")
|
||
}
|
||
|
||
/**
|
||
* Gets the current "targets" selector (a CSS selector)
|
||
*/
|
||
get targets() {
|
||
return this.getAttribute("targets")
|
||
}
|
||
|
||
/**
|
||
* Reads the request-id attribute
|
||
*/
|
||
get requestId() {
|
||
return this.getAttribute("request-id")
|
||
}
|
||
|
||
#raise(message) {
|
||
throw new Error(`${this.description}: ${message}`)
|
||
}
|
||
|
||
get description() {
|
||
return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
|
||
}
|
||
|
||
get beforeRenderEvent() {
|
||
return new CustomEvent("turbo:before-stream-render", {
|
||
bubbles: true,
|
||
cancelable: true,
|
||
detail: { newStream: this, render: StreamElement.renderElement }
|
||
})
|
||
}
|
||
|
||
get targetElementsById() {
|
||
const element = this.ownerDocument?.getElementById(this.target);
|
||
|
||
if (element !== null) {
|
||
return [element]
|
||
} else {
|
||
return []
|
||
}
|
||
}
|
||
|
||
get targetElementsByQuery() {
|
||
const elements = this.ownerDocument?.querySelectorAll(this.targets);
|
||
|
||
if (elements.length !== 0) {
|
||
return Array.prototype.slice.call(elements)
|
||
} else {
|
||
return []
|
||
}
|
||
}
|
||
}
|
||
|
||
class StreamSourceElement extends HTMLElement {
|
||
streamSource = null
|
||
|
||
connectedCallback() {
|
||
this.streamSource = this.src.match(/^ws{1,2}:/) ? new WebSocket(this.src) : new EventSource(this.src);
|
||
|
||
connectStreamSource(this.streamSource);
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
if (this.streamSource) {
|
||
this.streamSource.close();
|
||
|
||
disconnectStreamSource(this.streamSource);
|
||
}
|
||
}
|
||
|
||
get src() {
|
||
return this.getAttribute("src") || ""
|
||
}
|
||
}
|
||
|
||
FrameElement.delegateConstructor = FrameController;
|
||
|
||
if (customElements.get("turbo-frame") === undefined) {
|
||
customElements.define("turbo-frame", FrameElement);
|
||
}
|
||
|
||
if (customElements.get("turbo-stream") === undefined) {
|
||
customElements.define("turbo-stream", StreamElement);
|
||
}
|
||
|
||
if (customElements.get("turbo-stream-source") === undefined) {
|
||
customElements.define("turbo-stream-source", StreamSourceElement);
|
||
}
|
||
|
||
(() => {
|
||
let element = document.currentScript;
|
||
if (!element) return
|
||
if (element.hasAttribute("data-turbo-suppress-warning")) return
|
||
|
||
element = element.parentElement;
|
||
while (element) {
|
||
if (element == document.body) {
|
||
return console.warn(
|
||
unindent`
|
||
You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do!
|
||
|
||
Load your application’s JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change.
|
||
|
||
For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements
|
||
|
||
——
|
||
Suppress this warning by adding a "data-turbo-suppress-warning" attribute to: %s
|
||
`,
|
||
element.outerHTML
|
||
)
|
||
}
|
||
|
||
element = element.parentElement;
|
||
}
|
||
})();
|
||
|
||
window.Turbo = { ...Turbo, StreamActions };
|
||
start();
|
||
|
||
exports.FetchEnctype = FetchEnctype;
|
||
exports.FetchMethod = FetchMethod;
|
||
exports.FetchRequest = FetchRequest;
|
||
exports.FetchResponse = FetchResponse;
|
||
exports.FrameElement = FrameElement;
|
||
exports.FrameLoadingStyle = FrameLoadingStyle;
|
||
exports.FrameRenderer = FrameRenderer;
|
||
exports.PageRenderer = PageRenderer;
|
||
exports.PageSnapshot = PageSnapshot;
|
||
exports.StreamActions = StreamActions;
|
||
exports.StreamElement = StreamElement;
|
||
exports.StreamSourceElement = StreamSourceElement;
|
||
exports.cache = cache;
|
||
exports.clearCache = clearCache;
|
||
exports.connectStreamSource = connectStreamSource;
|
||
exports.disconnectStreamSource = disconnectStreamSource;
|
||
exports.fetch = fetchWithTurboHeaders;
|
||
exports.fetchEnctypeFromString = fetchEnctypeFromString;
|
||
exports.fetchMethodFromString = fetchMethodFromString;
|
||
exports.isSafe = isSafe;
|
||
exports.navigator = navigator$1;
|
||
exports.registerAdapter = registerAdapter;
|
||
exports.renderStreamMessage = renderStreamMessage;
|
||
exports.session = session;
|
||
exports.setConfirmMethod = setConfirmMethod;
|
||
exports.setFormMode = setFormMode;
|
||
exports.setProgressBarDelay = setProgressBarDelay;
|
||
exports.start = start;
|
||
exports.visit = visit;
|
||
|
||
Object.defineProperty(exports, '__esModule', { value: true });
|
||
|
||
}));
|