8c8dbe888e
This patches 2 problems in Turbo/idiomorph that are causing problems when reloading the pagination frames: - No remove callback in idiomorph when removing a node that had been mapped. - Regression introduced by https://github.com/hotwired/turbo/pull/1311, where it won't prevent the removal of frames with refresh=morph I will upstream both patches, this is temporary. https://fizzy.37signals.com/5986089/cards/2291
6051 lines
185 KiB
JavaScript
6051 lines
185 KiB
JavaScript
/*!
|
||
Turbo 8.0.17
|
||
Copyright © 2025 37signals LLC
|
||
*/
|
||
(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;
|
||
if ("SubmitEvent" in window) {
|
||
const prototypeOfSubmitEvent = window.SubmitEvent.prototype;
|
||
if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) {
|
||
prototype = prototypeOfSubmitEvent;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
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"
|
||
};
|
||
|
||
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();
|
||
}
|
||
}
|
||
get src() {
|
||
return this.getAttribute("src");
|
||
}
|
||
set src(value) {
|
||
if (value) {
|
||
this.setAttribute("src", value);
|
||
} else {
|
||
this.removeAttribute("src");
|
||
}
|
||
}
|
||
get refresh() {
|
||
return this.getAttribute("refresh");
|
||
}
|
||
set refresh(value) {
|
||
if (value) {
|
||
this.setAttribute("refresh", value);
|
||
} else {
|
||
this.removeAttribute("refresh");
|
||
}
|
||
}
|
||
get shouldReloadWithMorph() {
|
||
return this.src && this.refresh === "morph";
|
||
}
|
||
get loading() {
|
||
return frameLoadingStyleFromString(this.getAttribute("loading") || "");
|
||
}
|
||
set loading(value) {
|
||
if (value) {
|
||
this.setAttribute("loading", value);
|
||
} else {
|
||
this.removeAttribute("loading");
|
||
}
|
||
}
|
||
get disabled() {
|
||
return this.hasAttribute("disabled");
|
||
}
|
||
set disabled(value) {
|
||
if (value) {
|
||
this.setAttribute("disabled", "");
|
||
} else {
|
||
this.removeAttribute("disabled");
|
||
}
|
||
}
|
||
get autoscroll() {
|
||
return this.hasAttribute("autoscroll");
|
||
}
|
||
set autoscroll(value) {
|
||
if (value) {
|
||
this.setAttribute("autoscroll", "");
|
||
} else {
|
||
this.removeAttribute("autoscroll");
|
||
}
|
||
}
|
||
get complete() {
|
||
return !this.delegate.isLoading;
|
||
}
|
||
get isActive() {
|
||
return this.ownerDocument === document && !this.isPreview;
|
||
}
|
||
get isPreview() {
|
||
return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview");
|
||
}
|
||
}
|
||
|
||
function frameLoadingStyleFromString(style) {
|
||
switch (style.toLowerCase()) {
|
||
case "lazy":
|
||
return FrameLoadingStyle.lazy;
|
||
|
||
default:
|
||
return FrameLoadingStyle.eager;
|
||
}
|
||
}
|
||
|
||
const drive = {
|
||
enabled: true,
|
||
progressBarDelay: 500,
|
||
unvisitableExtensions: new Set([ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc", ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg", ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi", ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf", ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv", ".xls", ".xlsx", ".xml", ".zip" ])
|
||
};
|
||
|
||
function activateScriptElement(element) {
|
||
if (element.getAttribute("data-turbo-eval") == "false") {
|
||
return element;
|
||
} else {
|
||
const createdScriptElement = document.createElement("script");
|
||
const cspNonce = getCspNonce();
|
||
if (cspNonce) {
|
||
createdScriptElement.nonce = cspNonce;
|
||
}
|
||
createdScriptElement.textContent = element.textContent;
|
||
createdScriptElement.async = false;
|
||
copyElementAttributes(createdScriptElement, element);
|
||
return createdScriptElement;
|
||
}
|
||
}
|
||
|
||
function copyElementAttributes(destinationElement, sourceElement) {
|
||
for (const {name: name, value: 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: target, cancelable: cancelable, detail: detail} = {}) {
|
||
const event = new CustomEvent(eventName, {
|
||
cancelable: cancelable,
|
||
bubbles: true,
|
||
composed: true,
|
||
detail: detail
|
||
});
|
||
if (target && target.isConnected) {
|
||
target.dispatchEvent(event);
|
||
} else {
|
||
document.documentElement.dispatchEvent(event);
|
||
}
|
||
return event;
|
||
}
|
||
|
||
function cancelEvent(event) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
}
|
||
|
||
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 = 2e3) {
|
||
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 getCspNonce() {
|
||
const element = getMetaElement("csp-nonce");
|
||
if (element) {
|
||
const {nonce: nonce, content: content} = element;
|
||
return nonce == "" ? content : nonce;
|
||
}
|
||
}
|
||
|
||
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) {
|
||
const link = findClosestRecursively(target, "a[href], a[xlink\\:href]");
|
||
if (!link) return null;
|
||
if (link.hasAttribute("download")) return null;
|
||
if (link.hasAttribute("target") && link.target !== "_self") return null;
|
||
return link;
|
||
}
|
||
|
||
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);
|
||
};
|
||
}
|
||
|
||
const submitter = {
|
||
"aria-disabled": {
|
||
beforeSubmit: submitter => {
|
||
submitter.setAttribute("aria-disabled", "true");
|
||
submitter.addEventListener("click", cancelEvent);
|
||
},
|
||
afterSubmit: submitter => {
|
||
submitter.removeAttribute("aria-disabled");
|
||
submitter.removeEventListener("click", cancelEvent);
|
||
}
|
||
},
|
||
disabled: {
|
||
beforeSubmit: submitter => submitter.disabled = true,
|
||
afterSubmit: submitter => submitter.disabled = false
|
||
}
|
||
};
|
||
|
||
class Config {
|
||
#submitter=null;
|
||
constructor(config) {
|
||
Object.assign(this, config);
|
||
}
|
||
get submitter() {
|
||
return this.#submitter;
|
||
}
|
||
set submitter(value) {
|
||
this.#submitter = submitter[value] || value;
|
||
}
|
||
}
|
||
|
||
const forms = new Config({
|
||
mode: "on",
|
||
submitter: "disabled"
|
||
});
|
||
|
||
const config = {
|
||
drive: drive,
|
||
forms: forms
|
||
};
|
||
|
||
function expandURL(locatable) {
|
||
return new URL(locatable.toString(), document.baseURI);
|
||
}
|
||
|
||
function getAnchor(url) {
|
||
let anchorMatch;
|
||
if (url.hash) {
|
||
return url.hash.slice(1);
|
||
} 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 isPrefixedBy(baseURL, url) {
|
||
const prefix = addTrailingSlash(url.origin + url.pathname);
|
||
return addTrailingSlash(baseURL.href) === prefix || baseURL.href.startsWith(prefix);
|
||
}
|
||
|
||
function locationIsVisitable(location, rootLocation) {
|
||
return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(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 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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
function fetchWithTurboHeaders(url, options = {}) {
|
||
const modifiedHeaders = new Headers(options.headers || {});
|
||
const requestUID = uuid();
|
||
recentRequests.add(requestUID);
|
||
modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
|
||
return window.fetch(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: 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: 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: 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: url,
|
||
request: request,
|
||
expire: new Date((new Date).getTime() + ttl)
|
||
};
|
||
}
|
||
clear() {
|
||
if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
|
||
this.#prefetched = null;
|
||
}
|
||
}
|
||
|
||
const cacheTtl = 10 * 1e3;
|
||
|
||
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) {
|
||
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;
|
||
}
|
||
async start() {
|
||
const {initialized: initialized, requesting: requesting} = FormSubmissionState;
|
||
const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
|
||
if (typeof confirmationMessage === "string") {
|
||
const confirmMethod = typeof config.forms.confirm === "function" ? config.forms.confirm : FormSubmission.confirmMethod;
|
||
const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter);
|
||
if (!answer) {
|
||
return;
|
||
}
|
||
}
|
||
if (this.state == initialized) {
|
||
this.state = requesting;
|
||
return this.fetchRequest.perform();
|
||
}
|
||
}
|
||
stop() {
|
||
const {stopping: stopping, stopped: stopped} = FormSubmissionState;
|
||
if (this.state != stopping && this.state != stopped) {
|
||
this.state = stopping;
|
||
this.fetchRequest.cancel();
|
||
return true;
|
||
}
|
||
}
|
||
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;
|
||
if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter);
|
||
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: error
|
||
};
|
||
this.delegate.formSubmissionErrored(this, error);
|
||
}
|
||
requestFinished(_request) {
|
||
this.state = FormSubmissionState.stopped;
|
||
if (this.submitter) config.forms.submitter.afterSubmit(this.submitter);
|
||
this.resetSubmitterText();
|
||
clearBusyState(this.formElement);
|
||
dispatch("turbo:submit-end", {
|
||
target: this.formElement,
|
||
detail: {
|
||
formSubmission: this,
|
||
...this.result
|
||
}
|
||
});
|
||
this.delegate.formSubmissionFinished(this);
|
||
}
|
||
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: 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;
|
||
}
|
||
scrollToAnchor(anchor) {
|
||
const element = this.snapshot.getElementForAnchor(anchor);
|
||
if (element) {
|
||
this.focusElement(element);
|
||
this.scrollToElement(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: x, y: y}) {
|
||
this.scrollRoot.scrollTo(x, y);
|
||
}
|
||
scrollToTop() {
|
||
this.scrollToPosition({
|
||
x: 0,
|
||
y: 0
|
||
});
|
||
}
|
||
get scrollRoot() {
|
||
return window;
|
||
}
|
||
async render(renderer) {
|
||
const {isPreview: isPreview, shouldRender: shouldRender, willRender: willRender, newSnapshot: snapshot} = renderer;
|
||
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();
|
||
}
|
||
canPrefetchRequestToLocation(link, location) {
|
||
return false;
|
||
}
|
||
prefetchAndCacheRequestToLocation(link, location) {
|
||
return;
|
||
}
|
||
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: type,
|
||
name: name,
|
||
value: 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;
|
||
static renderElement(currentElement, newElement) {}
|
||
constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) {
|
||
this.currentSnapshot = currentSnapshot;
|
||
this.newSnapshot = newSnapshot;
|
||
this.isPreview = isPreview;
|
||
this.willRender = willRender;
|
||
this.renderElement = this.constructor.renderElement;
|
||
this.promise = new Promise(((resolve, reject) => this.resolvingFunctions = {
|
||
resolve: resolve,
|
||
reject: reject
|
||
}));
|
||
}
|
||
get shouldRender() {
|
||
return true;
|
||
}
|
||
get shouldAutofocus() {
|
||
return true;
|
||
}
|
||
get reloadReason() {
|
||
return;
|
||
}
|
||
prepareToRender() {
|
||
return;
|
||
}
|
||
render() {}
|
||
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();
|
||
}
|
||
}
|
||
}
|
||
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: block,
|
||
behavior: 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;
|
||
}
|
||
}
|
||
|
||
var Idiomorph = function() {
|
||
const noOp = () => {};
|
||
const defaults = {
|
||
morphStyle: "outerHTML",
|
||
callbacks: {
|
||
beforeNodeAdded: noOp,
|
||
afterNodeAdded: noOp,
|
||
beforeNodeMorphed: noOp,
|
||
afterNodeMorphed: noOp,
|
||
beforeNodeRemoved: noOp,
|
||
afterNodeRemoved: noOp,
|
||
beforeAttributeUpdated: noOp
|
||
},
|
||
head: {
|
||
style: "merge",
|
||
shouldPreserve: elt => elt.getAttribute("im-preserve") === "true",
|
||
shouldReAppend: elt => elt.getAttribute("im-re-append") === "true",
|
||
shouldRemove: noOp,
|
||
afterHeadMorphed: noOp
|
||
},
|
||
restoreFocus: true
|
||
};
|
||
function morph(oldNode, newContent, config = {}) {
|
||
oldNode = normalizeElement(oldNode);
|
||
const newNode = normalizeParent(newContent);
|
||
const ctx = createMorphContext(oldNode, newNode, config);
|
||
const morphedNodes = saveAndRestoreFocus(ctx, (() => withHeadBlocking(ctx, oldNode, newNode, (ctx => {
|
||
if (ctx.morphStyle === "innerHTML") {
|
||
morphChildren(ctx, oldNode, newNode);
|
||
return Array.from(oldNode.childNodes);
|
||
} else {
|
||
return morphOuterHTML(ctx, oldNode, newNode);
|
||
}
|
||
}))));
|
||
ctx.pantry.remove();
|
||
return morphedNodes;
|
||
}
|
||
function morphOuterHTML(ctx, oldNode, newNode) {
|
||
const oldParent = normalizeParent(oldNode);
|
||
let childNodes = Array.from(oldParent.childNodes);
|
||
const index = childNodes.indexOf(oldNode);
|
||
const rightMargin = childNodes.length - (index + 1);
|
||
morphChildren(ctx, oldParent, newNode, oldNode, oldNode.nextSibling);
|
||
childNodes = Array.from(oldParent.childNodes);
|
||
return childNodes.slice(index, childNodes.length - rightMargin);
|
||
}
|
||
function saveAndRestoreFocus(ctx, fn) {
|
||
if (!ctx.config.restoreFocus) return fn();
|
||
let activeElement = document.activeElement;
|
||
if (!(activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement)) {
|
||
return fn();
|
||
}
|
||
const {id: activeElementId, selectionStart: selectionStart, selectionEnd: selectionEnd} = activeElement;
|
||
const results = fn();
|
||
if (activeElementId && activeElementId !== document.activeElement?.id) {
|
||
activeElement = ctx.target.querySelector(`#${activeElementId}`);
|
||
activeElement?.focus();
|
||
}
|
||
if (activeElement && !activeElement.selectionEnd && selectionEnd) {
|
||
activeElement.setSelectionRange(selectionStart, selectionEnd);
|
||
}
|
||
return results;
|
||
}
|
||
const morphChildren = function() {
|
||
function morphChildren(ctx, oldParent, newParent, insertionPoint = null, endPoint = null) {
|
||
if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) {
|
||
oldParent = oldParent.content;
|
||
newParent = newParent.content;
|
||
}
|
||
insertionPoint ||= oldParent.firstChild;
|
||
for (const newChild of newParent.childNodes) {
|
||
if (insertionPoint && insertionPoint != endPoint) {
|
||
const bestMatch = findBestMatch(ctx, newChild, insertionPoint, endPoint);
|
||
if (bestMatch) {
|
||
if (bestMatch !== insertionPoint) {
|
||
removeNodesBetween(ctx, insertionPoint, bestMatch);
|
||
}
|
||
morphNode(bestMatch, newChild, ctx);
|
||
insertionPoint = bestMatch.nextSibling;
|
||
continue;
|
||
}
|
||
}
|
||
if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
|
||
const movedChild = moveBeforeById(oldParent, newChild.id, insertionPoint, ctx);
|
||
morphNode(movedChild, newChild, ctx);
|
||
insertionPoint = movedChild.nextSibling;
|
||
continue;
|
||
}
|
||
const insertedNode = createNode(oldParent, newChild, insertionPoint, ctx);
|
||
if (insertedNode) {
|
||
insertionPoint = insertedNode.nextSibling;
|
||
}
|
||
}
|
||
while (insertionPoint && insertionPoint != endPoint) {
|
||
const tempNode = insertionPoint;
|
||
insertionPoint = insertionPoint.nextSibling;
|
||
removeNode(ctx, tempNode);
|
||
}
|
||
}
|
||
function createNode(oldParent, newChild, insertionPoint, ctx) {
|
||
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
|
||
if (ctx.idMap.has(newChild)) {
|
||
const newEmptyChild = document.createElement(newChild.tagName);
|
||
oldParent.insertBefore(newEmptyChild, insertionPoint);
|
||
morphNode(newEmptyChild, newChild, ctx);
|
||
ctx.callbacks.afterNodeAdded(newEmptyChild);
|
||
return newEmptyChild;
|
||
} else {
|
||
const newClonedChild = document.importNode(newChild, true);
|
||
oldParent.insertBefore(newClonedChild, insertionPoint);
|
||
ctx.callbacks.afterNodeAdded(newClonedChild);
|
||
return newClonedChild;
|
||
}
|
||
}
|
||
const findBestMatch = function() {
|
||
function findBestMatch(ctx, node, startPoint, endPoint) {
|
||
let softMatch = null;
|
||
let nextSibling = node.nextSibling;
|
||
let siblingSoftMatchCount = 0;
|
||
let cursor = startPoint;
|
||
while (cursor && cursor != endPoint) {
|
||
if (isSoftMatch(cursor, node)) {
|
||
if (isIdSetMatch(ctx, cursor, node)) {
|
||
return cursor;
|
||
}
|
||
if (softMatch === null) {
|
||
if (!ctx.idMap.has(cursor)) {
|
||
softMatch = cursor;
|
||
}
|
||
}
|
||
}
|
||
if (softMatch === null && nextSibling && isSoftMatch(cursor, nextSibling)) {
|
||
siblingSoftMatchCount++;
|
||
nextSibling = nextSibling.nextSibling;
|
||
if (siblingSoftMatchCount >= 2) {
|
||
softMatch = undefined;
|
||
}
|
||
}
|
||
if (cursor.contains(document.activeElement)) break;
|
||
cursor = cursor.nextSibling;
|
||
}
|
||
return softMatch || null;
|
||
}
|
||
function isIdSetMatch(ctx, oldNode, newNode) {
|
||
let oldSet = ctx.idMap.get(oldNode);
|
||
let newSet = ctx.idMap.get(newNode);
|
||
if (!newSet || !oldSet) return false;
|
||
for (const id of oldSet) {
|
||
if (newSet.has(id)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
function isSoftMatch(oldNode, newNode) {
|
||
const oldElt = oldNode;
|
||
const newElt = newNode;
|
||
return oldElt.nodeType === newElt.nodeType && oldElt.tagName === newElt.tagName && (!oldElt.id || oldElt.id === newElt.id);
|
||
}
|
||
return findBestMatch;
|
||
}();
|
||
function removeNode(ctx, node) {
|
||
if (ctx.idMap.has(node)) {
|
||
if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
|
||
moveBefore(ctx.pantry, node, null);
|
||
} else {
|
||
if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
|
||
node.parentNode?.removeChild(node);
|
||
ctx.callbacks.afterNodeRemoved(node);
|
||
}
|
||
}
|
||
function removeNodesBetween(ctx, startInclusive, endExclusive) {
|
||
let cursor = startInclusive;
|
||
while (cursor && cursor !== endExclusive) {
|
||
let tempNode = cursor;
|
||
cursor = cursor.nextSibling;
|
||
removeNode(ctx, tempNode);
|
||
}
|
||
return cursor;
|
||
}
|
||
function moveBeforeById(parentNode, id, after, ctx) {
|
||
const target = ctx.target.querySelector(`#${id}`) || ctx.pantry.querySelector(`#${id}`);
|
||
removeElementFromAncestorsIdMaps(target, ctx);
|
||
moveBefore(parentNode, target, after);
|
||
return target;
|
||
}
|
||
function removeElementFromAncestorsIdMaps(element, ctx) {
|
||
const id = element.id;
|
||
while (element = element.parentNode) {
|
||
let idSet = ctx.idMap.get(element);
|
||
if (idSet) {
|
||
idSet.delete(id);
|
||
if (!idSet.size) {
|
||
ctx.idMap.delete(element);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function moveBefore(parentNode, element, after) {
|
||
if (parentNode.moveBefore) {
|
||
try {
|
||
parentNode.moveBefore(element, after);
|
||
} catch (e) {
|
||
parentNode.insertBefore(element, after);
|
||
}
|
||
} else {
|
||
parentNode.insertBefore(element, after);
|
||
}
|
||
}
|
||
return morphChildren;
|
||
}();
|
||
const morphNode = function() {
|
||
function morphNode(oldNode, newContent, ctx) {
|
||
if (ctx.ignoreActive && oldNode === document.activeElement) {
|
||
return null;
|
||
}
|
||
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(oldNode, newContent, ctx);
|
||
} else {
|
||
morphAttributes(oldNode, newContent, ctx);
|
||
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
|
||
morphChildren(ctx, oldNode, newContent);
|
||
}
|
||
}
|
||
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
|
||
return oldNode;
|
||
}
|
||
function morphAttributes(oldNode, newNode, ctx) {
|
||
let type = newNode.nodeType;
|
||
if (type === 1) {
|
||
const oldElt = oldNode;
|
||
const newElt = newNode;
|
||
const oldAttributes = oldElt.attributes;
|
||
const newAttributes = newElt.attributes;
|
||
for (const newAttribute of newAttributes) {
|
||
if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
|
||
continue;
|
||
}
|
||
if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
|
||
oldElt.setAttribute(newAttribute.name, newAttribute.value);
|
||
}
|
||
}
|
||
for (let i = oldAttributes.length - 1; 0 <= i; i--) {
|
||
const oldAttribute = oldAttributes[i];
|
||
if (!oldAttribute) continue;
|
||
if (!newElt.hasAttribute(oldAttribute.name)) {
|
||
if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
|
||
continue;
|
||
}
|
||
oldElt.removeAttribute(oldAttribute.name);
|
||
}
|
||
}
|
||
if (!ignoreValueOfActiveElement(oldElt, ctx)) {
|
||
syncInputValue(oldElt, newElt, ctx);
|
||
}
|
||
}
|
||
if (type === 8 || type === 3) {
|
||
if (oldNode.nodeValue !== newNode.nodeValue) {
|
||
oldNode.nodeValue = newNode.nodeValue;
|
||
}
|
||
}
|
||
}
|
||
function syncInputValue(oldElement, newElement, ctx) {
|
||
if (oldElement instanceof HTMLInputElement && newElement instanceof HTMLInputElement && newElement.type !== "file") {
|
||
let newValue = newElement.value;
|
||
let oldValue = oldElement.value;
|
||
syncBooleanAttribute(oldElement, newElement, "checked", ctx);
|
||
syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
|
||
if (!newElement.hasAttribute("value")) {
|
||
if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
|
||
oldElement.value = "";
|
||
oldElement.removeAttribute("value");
|
||
}
|
||
} else if (oldValue !== newValue) {
|
||
if (!ignoreAttribute("value", oldElement, "update", ctx)) {
|
||
oldElement.setAttribute("value", newValue);
|
||
oldElement.value = newValue;
|
||
}
|
||
}
|
||
} else if (oldElement instanceof HTMLOptionElement && newElement instanceof HTMLOptionElement) {
|
||
syncBooleanAttribute(oldElement, newElement, "selected", ctx);
|
||
} else if (oldElement instanceof HTMLTextAreaElement && newElement instanceof HTMLTextAreaElement) {
|
||
let newValue = newElement.value;
|
||
let oldValue = oldElement.value;
|
||
if (ignoreAttribute("value", oldElement, "update", ctx)) {
|
||
return;
|
||
}
|
||
if (newValue !== oldValue) {
|
||
oldElement.value = newValue;
|
||
}
|
||
if (oldElement.firstChild && oldElement.firstChild.nodeValue !== newValue) {
|
||
oldElement.firstChild.nodeValue = newValue;
|
||
}
|
||
}
|
||
}
|
||
function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
|
||
const newLiveValue = newElement[attributeName], oldLiveValue = oldElement[attributeName];
|
||
if (newLiveValue !== oldLiveValue) {
|
||
const ignoreUpdate = ignoreAttribute(attributeName, oldElement, "update", ctx);
|
||
if (!ignoreUpdate) {
|
||
oldElement[attributeName] = newElement[attributeName];
|
||
}
|
||
if (newLiveValue) {
|
||
if (!ignoreUpdate) {
|
||
oldElement.setAttribute(attributeName, "");
|
||
}
|
||
} else {
|
||
if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
|
||
oldElement.removeAttribute(attributeName);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function ignoreAttribute(attr, element, updateType, ctx) {
|
||
if (attr === "value" && ctx.ignoreActiveValue && element === document.activeElement) {
|
||
return true;
|
||
}
|
||
return ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === false;
|
||
}
|
||
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
|
||
return !!ctx.ignoreActiveValue && possibleActiveElement === document.activeElement && possibleActiveElement !== document.body;
|
||
}
|
||
return morphNode;
|
||
}();
|
||
function withHeadBlocking(ctx, oldNode, newNode, callback) {
|
||
if (ctx.head.block) {
|
||
const oldHead = oldNode.querySelector("head");
|
||
const newHead = newNode.querySelector("head");
|
||
if (oldHead && newHead) {
|
||
const promises = handleHeadElement(oldHead, newHead, ctx);
|
||
return Promise.all(promises).then((() => {
|
||
const newCtx = Object.assign(ctx, {
|
||
head: {
|
||
block: false,
|
||
ignore: true
|
||
}
|
||
});
|
||
return callback(newCtx);
|
||
}));
|
||
}
|
||
}
|
||
return callback(ctx);
|
||
}
|
||
function handleHeadElement(oldHead, newHead, ctx) {
|
||
let added = [];
|
||
let removed = [];
|
||
let preserved = [];
|
||
let nodesToAppend = [];
|
||
let srcToNewHeadNodes = new Map;
|
||
for (const newHeadChild of newHead.children) {
|
||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||
}
|
||
for (const currentHeadElt of oldHead.children) {
|
||
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
|
||
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
|
||
if (inNewContent || isPreserved) {
|
||
if (isReAppended) {
|
||
removed.push(currentHeadElt);
|
||
} else {
|
||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||
preserved.push(currentHeadElt);
|
||
}
|
||
} else {
|
||
if (ctx.head.style === "append") {
|
||
if (isReAppended) {
|
||
removed.push(currentHeadElt);
|
||
nodesToAppend.push(currentHeadElt);
|
||
}
|
||
} else {
|
||
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
|
||
removed.push(currentHeadElt);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 ("href" in newElt && newElt.href || "src" in newElt && newElt.src) {
|
||
let resolve;
|
||
let promise = new Promise((function(_resolve) {
|
||
resolve = _resolve;
|
||
}));
|
||
newElt.addEventListener("load", (function() {
|
||
resolve();
|
||
}));
|
||
promises.push(promise);
|
||
}
|
||
oldHead.appendChild(newElt);
|
||
ctx.callbacks.afterNodeAdded(newElt);
|
||
added.push(newElt);
|
||
}
|
||
}
|
||
for (const removedElement of removed) {
|
||
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
|
||
oldHead.removeChild(removedElement);
|
||
ctx.callbacks.afterNodeRemoved(removedElement);
|
||
}
|
||
}
|
||
ctx.head.afterHeadMorphed(oldHead, {
|
||
added: added,
|
||
kept: preserved,
|
||
removed: removed
|
||
});
|
||
return promises;
|
||
}
|
||
const createMorphContext = function() {
|
||
function createMorphContext(oldNode, newContent, config) {
|
||
const {persistentIds: persistentIds, idMap: idMap} = createIdMaps(oldNode, newContent);
|
||
const mergedConfig = mergeDefaults(config);
|
||
const morphStyle = mergedConfig.morphStyle || "outerHTML";
|
||
if (![ "innerHTML", "outerHTML" ].includes(morphStyle)) {
|
||
throw `Do not understand how to morph style ${morphStyle}`;
|
||
}
|
||
return {
|
||
target: oldNode,
|
||
newContent: newContent,
|
||
config: mergedConfig,
|
||
morphStyle: morphStyle,
|
||
ignoreActive: mergedConfig.ignoreActive,
|
||
ignoreActiveValue: mergedConfig.ignoreActiveValue,
|
||
restoreFocus: mergedConfig.restoreFocus,
|
||
idMap: idMap,
|
||
persistentIds: persistentIds,
|
||
pantry: createPantry(),
|
||
callbacks: mergedConfig.callbacks,
|
||
head: mergedConfig.head
|
||
};
|
||
}
|
||
function mergeDefaults(config) {
|
||
let finalConfig = Object.assign({}, defaults);
|
||
Object.assign(finalConfig, config);
|
||
finalConfig.callbacks = Object.assign({}, defaults.callbacks, config.callbacks);
|
||
finalConfig.head = Object.assign({}, defaults.head, config.head);
|
||
return finalConfig;
|
||
}
|
||
function createPantry() {
|
||
const pantry = document.createElement("div");
|
||
pantry.hidden = true;
|
||
document.body.insertAdjacentElement("afterend", pantry);
|
||
return pantry;
|
||
}
|
||
function findIdElements(root) {
|
||
let elements = Array.from(root.querySelectorAll("[id]"));
|
||
if (root.id) {
|
||
elements.push(root);
|
||
}
|
||
return elements;
|
||
}
|
||
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
|
||
for (const elt of elements) {
|
||
if (persistentIds.has(elt.id)) {
|
||
let current = elt;
|
||
while (current) {
|
||
let idSet = idMap.get(current);
|
||
if (idSet == null) {
|
||
idSet = new Set;
|
||
idMap.set(current, idSet);
|
||
}
|
||
idSet.add(elt.id);
|
||
if (current === root) break;
|
||
current = current.parentElement;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
function createIdMaps(oldContent, newContent) {
|
||
const oldIdElements = findIdElements(oldContent);
|
||
const newIdElements = findIdElements(newContent);
|
||
const persistentIds = createPersistentIds(oldIdElements, newIdElements);
|
||
let idMap = new Map;
|
||
populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
|
||
const newRoot = newContent.__idiomorphRoot || newContent;
|
||
populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
|
||
return {
|
||
persistentIds: persistentIds,
|
||
idMap: idMap
|
||
};
|
||
}
|
||
function createPersistentIds(oldIdElements, newIdElements) {
|
||
let duplicateIds = new Set;
|
||
let oldIdTagNameMap = new Map;
|
||
for (const {id: id, tagName: tagName} of oldIdElements) {
|
||
if (oldIdTagNameMap.has(id)) {
|
||
duplicateIds.add(id);
|
||
} else {
|
||
oldIdTagNameMap.set(id, tagName);
|
||
}
|
||
}
|
||
let persistentIds = new Set;
|
||
for (const {id: id, tagName: tagName} of newIdElements) {
|
||
if (persistentIds.has(id)) {
|
||
duplicateIds.add(id);
|
||
} else if (oldIdTagNameMap.get(id) === tagName) {
|
||
persistentIds.add(id);
|
||
}
|
||
}
|
||
for (const id of duplicateIds) {
|
||
persistentIds.delete(id);
|
||
}
|
||
return persistentIds;
|
||
}
|
||
return createMorphContext;
|
||
}();
|
||
const {normalizeElement: normalizeElement, normalizeParent: normalizeParent} = function() {
|
||
const generatedByIdiomorph = new WeakSet;
|
||
function normalizeElement(content) {
|
||
if (content instanceof Document) {
|
||
return content.documentElement;
|
||
} else {
|
||
return content;
|
||
}
|
||
}
|
||
function normalizeParent(newContent) {
|
||
if (newContent == null) {
|
||
return document.createElement("div");
|
||
} else if (typeof newContent === "string") {
|
||
return normalizeParent(parseContent(newContent));
|
||
} else if (generatedByIdiomorph.has(newContent)) {
|
||
return newContent;
|
||
} else if (newContent instanceof Node) {
|
||
if (newContent.parentNode) {
|
||
return createDuckTypedParent(newContent);
|
||
} else {
|
||
const dummyParent = document.createElement("div");
|
||
dummyParent.append(newContent);
|
||
return dummyParent;
|
||
}
|
||
} else {
|
||
const dummyParent = document.createElement("div");
|
||
for (const elt of [ ...newContent ]) {
|
||
dummyParent.append(elt);
|
||
}
|
||
return dummyParent;
|
||
}
|
||
}
|
||
function createDuckTypedParent(newContent) {
|
||
return {
|
||
childNodes: [ newContent ],
|
||
querySelectorAll: s => {
|
||
const elements = newContent.querySelectorAll(s);
|
||
return newContent.matches(s) ? [ newContent, ...elements ] : elements;
|
||
},
|
||
insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
|
||
moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
|
||
get __idiomorphRoot() {
|
||
return newContent;
|
||
}
|
||
};
|
||
}
|
||
function parseContent(newContent) {
|
||
let parser = new DOMParser;
|
||
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, "");
|
||
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
|
||
let content = parser.parseFromString(newContent, "text/html");
|
||
if (contentWithSvgsRemoved.match(/<\/html>/)) {
|
||
generatedByIdiomorph.add(content);
|
||
return content;
|
||
} else {
|
||
let htmlElement = content.firstChild;
|
||
if (htmlElement) {
|
||
generatedByIdiomorph.add(htmlElement);
|
||
}
|
||
return htmlElement;
|
||
}
|
||
} else {
|
||
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
|
||
let content = responseDoc.body.querySelector("template").content;
|
||
generatedByIdiomorph.add(content);
|
||
return content;
|
||
}
|
||
}
|
||
return {
|
||
normalizeElement: normalizeElement,
|
||
normalizeParent: normalizeParent
|
||
};
|
||
}();
|
||
return {
|
||
morph: morph,
|
||
defaults: defaults
|
||
};
|
||
}();
|
||
|
||
function morphElements(currentElement, newElement, {callbacks: callbacks, ...options} = {}) {
|
||
Idiomorph.morph(currentElement, newElement, {
|
||
...options,
|
||
callbacks: new DefaultIdiomorphCallbacks(callbacks)
|
||
});
|
||
}
|
||
|
||
function morphChildren(currentElement, newElement, options = {}) {
|
||
morphElements(currentElement, newElement.childNodes, {
|
||
...options,
|
||
morphStyle: "innerHTML"
|
||
});
|
||
}
|
||
|
||
class DefaultIdiomorphCallbacks {
|
||
#beforeNodeMorphed;
|
||
constructor({beforeNodeMorphed: beforeNodeMorphed} = {}) {
|
||
this.#beforeNodeMorphed = beforeNodeMorphed || (() => true);
|
||
}
|
||
beforeNodeAdded=node => !(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: currentElement,
|
||
newElement: newElement
|
||
}
|
||
});
|
||
return !event.defaultPrevented;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
};
|
||
beforeAttributeUpdated=(attributeName, target, mutationType) => {
|
||
const event = dispatch("turbo:before-morph-attribute", {
|
||
cancelable: true,
|
||
target: target,
|
||
detail: {
|
||
attributeName: attributeName,
|
||
mutationType: mutationType
|
||
}
|
||
});
|
||
return !event.defaultPrevented;
|
||
};
|
||
beforeNodeRemoved=node => this.beforeNodeMorphed(node);
|
||
afterNodeMorphed=(currentElement, newElement) => {
|
||
if (currentElement instanceof Element) {
|
||
dispatch("turbo:morph-element", {
|
||
target: currentElement,
|
||
detail: {
|
||
currentElement: currentElement,
|
||
newElement: newElement
|
||
}
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
class MorphingFrameRenderer extends FrameRenderer {
|
||
static renderElement(currentElement, newElement) {
|
||
dispatch("turbo:before-frame-morph", {
|
||
target: currentElement,
|
||
detail: {
|
||
currentElement: currentElement,
|
||
newElement: newElement
|
||
}
|
||
});
|
||
morphChildren(currentElement, newElement);
|
||
}
|
||
async preservingPermanentElements(callback) {
|
||
return await callback();
|
||
}
|
||
}
|
||
|
||
class ProgressBar {
|
||
static animationDuration=300;
|
||
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();
|
||
}
|
||
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;
|
||
const cspNonce = getCspNonce();
|
||
if (cspNonce) {
|
||
element.nonce = cspNonce;
|
||
}
|
||
return element;
|
||
}
|
||
createProgressElement() {
|
||
const element = document.createElement("div");
|
||
element.className = "turbo-progress-bar";
|
||
return element;
|
||
}
|
||
}
|
||
|
||
class HeadSnapshot extends Snapshot {
|
||
detailsByOuterHTML=this.children.filter((element => !elementIsNoscript(element))).map((element => elementWithoutNonce(element))).reduce(((result, element) => {
|
||
const {outerHTML: 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}) => type == matchedType)).map((({elements: [element]}) => element));
|
||
}
|
||
get provisionalElements() {
|
||
return Object.keys(this.detailsByOuterHTML).reduce(((result, outerHTML) => {
|
||
const {type: type, tracked: tracked, elements: 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: documentElement, body: body, head: 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() {
|
||
const viewTransitionEnabled = this.getSetting("view-transition") === "true" || this.headSnapshot.getMetaValue("view-transition") === "same-origin";
|
||
return viewTransitionEnabled && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
}
|
||
get shouldMorphPage() {
|
||
return this.getSetting("refresh-method") === "morph";
|
||
}
|
||
get shouldPreserveScrollPosition() {
|
||
return this.getSetting("refresh-scroll") === "preserve";
|
||
}
|
||
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();
|
||
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: action, historyChanged: historyChanged, referrer: referrer, snapshot: snapshot, snapshotHTML: snapshotHTML, response: response, visitCachedSnapshot: visitCachedSnapshot, willRender: willRender, updateHistory: updateHistory, shouldCacheSnapshot: shouldCacheSnapshot, acceptsStreamResponse: acceptsStreamResponse, direction: 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: 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: statusCode, responseHTML: 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);
|
||
}));
|
||
}
|
||
}
|
||
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: redirected, statusCode: statusCode} = response;
|
||
if (responseHTML == undefined) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.contentTypeMismatch,
|
||
redirected: redirected
|
||
});
|
||
} else {
|
||
this.redirectedToLocation = response.redirected ? response.location : undefined;
|
||
this.recordResponse({
|
||
statusCode: statusCode,
|
||
responseHTML: responseHTML,
|
||
redirected: redirected
|
||
});
|
||
}
|
||
}
|
||
async requestFailedWithResponse(request, response) {
|
||
const responseHTML = await response.responseHTML;
|
||
const {redirected: redirected, statusCode: statusCode} = response;
|
||
if (responseHTML == undefined) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.contentTypeMismatch,
|
||
redirected: redirected
|
||
});
|
||
} else {
|
||
this.recordResponse({
|
||
statusCode: statusCode,
|
||
responseHTML: responseHTML,
|
||
redirected: redirected
|
||
});
|
||
}
|
||
}
|
||
requestErrored(_request, _error) {
|
||
this.recordResponse({
|
||
statusCode: SystemStatusCode.networkFailure,
|
||
redirected: false
|
||
});
|
||
}
|
||
requestFinished() {
|
||
this.finishRequest();
|
||
}
|
||
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: 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;
|
||
}
|
||
}
|
||
recordTimingMetric(metric) {
|
||
this.timingMetrics[metric] = (new Date).getTime();
|
||
}
|
||
getTimingMetrics() {
|
||
return {
|
||
...this.timingMetrics
|
||
};
|
||
}
|
||
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();
|
||
await new Promise((resolve => {
|
||
this.frame = document.visibilityState === "hidden" ? setTimeout((() => resolve()), 0) : requestAnimationFrame((() => resolve()));
|
||
}));
|
||
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;
|
||
this.redirectedToLocation = null;
|
||
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();
|
||
if (visit.response.redirected) {
|
||
this.redirectedToLocation = visit.redirectedToLocation;
|
||
}
|
||
}
|
||
visitRequestFailedWithStatusCode(visit, statusCode) {
|
||
switch (statusCode) {
|
||
case SystemStatusCode.networkFailure:
|
||
case SystemStatusCode.timeoutFailure:
|
||
case SystemStatusCode.contentTypeMismatch:
|
||
return this.reload({
|
||
reason: "request_failed",
|
||
context: {
|
||
statusCode: 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) {}
|
||
linkPrefetchingIsEnabledForLocation(location) {
|
||
return true;
|
||
}
|
||
formSubmissionStarted(_formSubmission) {
|
||
this.progressBar.setValue(0);
|
||
this.showFormProgressBarAfterDelay();
|
||
}
|
||
formSubmissionFinished(_formSubmission) {
|
||
this.progressBar.setValue(1);
|
||
this.hideFormProgressBar();
|
||
}
|
||
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.redirectedToLocation || 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();
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
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: restorationIdentifier,
|
||
restorationIndex: this.currentIndex
|
||
}
|
||
};
|
||
method.call(history, state, "", location.href);
|
||
this.location = location;
|
||
this.restorationIdentifier = restorationIdentifier;
|
||
}
|
||
getRestorationDataForIdentifier(restorationIdentifier) {
|
||
return this.restorationData[restorationIdentifier] || {};
|
||
}
|
||
updateRestorationData(additionalData) {
|
||
const {restorationIdentifier: restorationIdentifier} = this;
|
||
const restorationData = this.restorationData[restorationIdentifier];
|
||
this.restorationData[restorationIdentifier] = {
|
||
...restorationData,
|
||
...additionalData
|
||
};
|
||
}
|
||
assumeControlOfScrollRestoration() {
|
||
if (!this.previousScrollRestoration) {
|
||
this.previousScrollRestoration = history.scrollRestoration ?? "auto";
|
||
history.scrollRestoration = "manual";
|
||
}
|
||
}
|
||
relinquishControlOfScrollRestoration() {
|
||
if (this.previousScrollRestoration) {
|
||
history.scrollRestoration = this.previousScrollRestoration;
|
||
delete this.previousScrollRestoration;
|
||
}
|
||
}
|
||
onPopState=event => {
|
||
if (this.shouldHandlePopState()) {
|
||
const {turbo: turbo} = event.state || {};
|
||
if (turbo) {
|
||
this.location = new URL(window.location.href);
|
||
const {restorationIdentifier: restorationIdentifier, restorationIndex: 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;
|
||
};
|
||
shouldHandlePopState() {
|
||
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);
|
||
fetchRequest.fetchOptions.priority = "low";
|
||
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) {
|
||
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;
|
||
}
|
||
}
|
||
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 => link.origin !== document.location.origin || ![ "http:", "https:" ].includes(link.protocol) || link.hasAttribute("target");
|
||
|
||
const linkToTheSamePage = link => 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 => 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;
|
||
}
|
||
formSubmissionStarted(formSubmission) {
|
||
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: statusCode, redirected: redirected} = fetchResponse;
|
||
const action = this.#getActionForFormSubmission(formSubmission, fetchResponse);
|
||
const visitOptions = {
|
||
action: action,
|
||
shouldCacheSnapshot: shouldCacheSnapshot,
|
||
response: {
|
||
statusCode: statusCode,
|
||
responseHTML: responseHTML,
|
||
redirected: 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) {
|
||
if (typeof this.adapter.formSubmissionFinished === "function") {
|
||
this.adapter.formSubmissionFinished(formSubmission);
|
||
}
|
||
}
|
||
linkPrefetchingIsEnabledForLocation(location) {
|
||
if (typeof this.adapter.linkPrefetchingIsEnabledForLocation === "function") {
|
||
return this.adapter.linkPrefetchingIsEnabledForLocation(location);
|
||
}
|
||
return true;
|
||
}
|
||
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);
|
||
}
|
||
get location() {
|
||
return this.history.location;
|
||
}
|
||
get restorationIdentifier() {
|
||
return this.history.restorationIdentifier;
|
||
}
|
||
#getActionForFormSubmission(formSubmission, fetchResponse) {
|
||
const {submitter: submitter, formElement: 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: 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
|
||
});
|
||
};
|
||
updatePosition(position) {
|
||
this.delegate.scrollPositionChanged(position);
|
||
}
|
||
}
|
||
|
||
class StreamMessageRenderer {
|
||
render({fragment: fragment}) {
|
||
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), (() => {
|
||
withAutofocusFromFragment(fragment, (() => {
|
||
withPreservedFocus((() => {
|
||
document.documentElement.appendChild(fragment);
|
||
}));
|
||
}));
|
||
}));
|
||
}
|
||
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: 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: documentElement, body: body} = document;
|
||
documentElement.replaceChild(newElement, body);
|
||
}
|
||
async render() {
|
||
this.replaceHeadAndBody();
|
||
this.activateScriptElements();
|
||
}
|
||
replaceHeadAndBody() {
|
||
const {documentElement: documentElement, head: 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");
|
||
}
|
||
}
|
||
|
||
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: documentElement} = this.currentSnapshot;
|
||
const {lang: 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 (element.tagName == "TITLE") {
|
||
if (newElement.tagName != "TITLE") {
|
||
continue;
|
||
}
|
||
if (element.innerHTML == newElement.innerHTML) {
|
||
elementList.splice(index, 1);
|
||
return true;
|
||
}
|
||
}
|
||
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 => 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");
|
||
}
|
||
}
|
||
|
||
export 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)) frame.reload()
|
||
}
|
||
|
||
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]")
|
||
}
|
||
|
||
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 = {};
|
||
}
|
||
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, 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, 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();
|
||
}
|
||
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 (_) {}
|
||
}
|
||
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);
|
||
enabled=true;
|
||
started=false;
|
||
#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);
|
||
const isCurrentUrl = url === document.baseURI;
|
||
if (!isRecentRequest && !this.navigator.currentVisit && isCurrentUrl) {
|
||
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) {
|
||
console.warn("Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`");
|
||
this.progressBarDelay = delay;
|
||
}
|
||
set progressBarDelay(delay) {
|
||
config.drive.progressBarDelay = delay;
|
||
}
|
||
get progressBarDelay() {
|
||
return config.drive.progressBarDelay;
|
||
}
|
||
set drive(value) {
|
||
config.drive.enabled = value;
|
||
}
|
||
get drive() {
|
||
return config.drive.enabled;
|
||
}
|
||
set formMode(value) {
|
||
config.forms.mode = value;
|
||
}
|
||
get formMode() {
|
||
return config.forms.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;
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
historyPoppedToLocationWithRestorationIdentifierAndDirection(location, restorationIdentifier, direction) {
|
||
if (this.enabled) {
|
||
this.navigator.startVisit(location, restorationIdentifier, {
|
||
action: "restore",
|
||
historyChanged: true,
|
||
direction: direction
|
||
});
|
||
} else {
|
||
this.adapter.pageInvalidated({
|
||
reason: "turbo_disabled"
|
||
});
|
||
}
|
||
}
|
||
scrollPositionChanged(position) {
|
||
this.history.updateRestorationData({
|
||
scrollPosition: position
|
||
});
|
||
}
|
||
willSubmitFormLinkToLocation(link, location) {
|
||
return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation);
|
||
}
|
||
submittedFormLinkToLocation() {}
|
||
canPrefetchRequestToLocation(link, location) {
|
||
return this.elementIsNavigatable(link) && locationIsVisitable(location, this.snapshot.rootLocation) && this.navigator.linkPrefetchingIsEnabledForLocation(location);
|
||
}
|
||
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: action,
|
||
acceptsStreamResponse: acceptsStreamResponse
|
||
});
|
||
}
|
||
allowsVisitingLocationWithAction(location, action) {
|
||
return this.locationWithActionIsSamePage(location, action) || this.applicationAllowsVisitingLocation(location);
|
||
}
|
||
visitProposedToLocation(location, options) {
|
||
extendURLWithDeprecatedProperties(location);
|
||
this.adapter.visitProposedToLocation(location, options);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
pageBecameInteractive() {
|
||
this.view.lastRenderedLocation = this.location;
|
||
this.notifyApplicationAfterPageLoad();
|
||
}
|
||
pageLoaded() {
|
||
this.history.assumeControlOfScrollRestoration();
|
||
}
|
||
pageWillUnload() {
|
||
this.history.relinquishControlOfScrollRestoration();
|
||
}
|
||
receivedMessageFromStream(message) {
|
||
this.renderStreamMessage(message);
|
||
}
|
||
viewWillCacheSnapshot() {
|
||
if (!this.navigator.currentVisit?.silent) {
|
||
this.notifyApplicationBeforeCachingSnapshot();
|
||
}
|
||
}
|
||
allowsImmediateRender({element: element}, options) {
|
||
const event = this.notifyApplicationBeforeRender(element, options);
|
||
const {defaultPrevented: defaultPrevented, detail: {render: 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);
|
||
}
|
||
frameLoaded(frame) {
|
||
this.notifyApplicationAfterFrameLoad(frame);
|
||
}
|
||
frameRendered(fetchResponse, frame) {
|
||
this.notifyApplicationAfterFrameRender(fetchResponse, frame);
|
||
}
|
||
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: action
|
||
}
|
||
});
|
||
}
|
||
notifyApplicationBeforeCachingSnapshot() {
|
||
return dispatch("turbo:before-cache");
|
||
}
|
||
notifyApplicationBeforeRender(newBody, options) {
|
||
return dispatch("turbo:before-render", {
|
||
detail: {
|
||
newBody: newBody,
|
||
...options
|
||
},
|
||
cancelable: true
|
||
});
|
||
}
|
||
notifyApplicationAfterRender(renderMethod) {
|
||
return dispatch("turbo:render", {
|
||
detail: {
|
||
renderMethod: renderMethod
|
||
}
|
||
});
|
||
}
|
||
notifyApplicationAfterPageLoad(timing = {}) {
|
||
return dispatch("turbo:load", {
|
||
detail: {
|
||
url: this.location.href,
|
||
timing: 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: fetchResponse
|
||
},
|
||
target: frame,
|
||
cancelable: true
|
||
});
|
||
}
|
||
submissionIsNavigatable(form, submitter) {
|
||
if (config.forms.mode == "off") {
|
||
return false;
|
||
} else {
|
||
const submitterIsNavigatable = submitter ? this.elementIsNavigatable(submitter) : true;
|
||
if (config.forms.mode == "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");
|
||
if (config.drive.enabled || withinFrame) {
|
||
if (container) {
|
||
return container.getAttribute("data-turbo") != "false";
|
||
} else {
|
||
return true;
|
||
}
|
||
} else {
|
||
if (container) {
|
||
return container.getAttribute("data-turbo") == "true";
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
getActionForLink(link) {
|
||
return getVisitAction(link) || "advance";
|
||
}
|
||
get snapshot() {
|
||
return this.view.snapshot;
|
||
}
|
||
}
|
||
|
||
function extendURLWithDeprecatedProperties(url) {
|
||
Object.defineProperties(url, deprecatedLocationPropertyDescriptors);
|
||
}
|
||
|
||
const deprecatedLocationPropertyDescriptors = {
|
||
absoluteURL: {
|
||
get() {
|
||
return this.toString();
|
||
}
|
||
}
|
||
};
|
||
|
||
const session = new Session(recentRequests);
|
||
|
||
const {cache: cache, navigator: navigator$1} = session;
|
||
|
||
function start() {
|
||
session.start();
|
||
}
|
||
|
||
function registerAdapter(adapter) {
|
||
session.registerAdapter(adapter);
|
||
}
|
||
|
||
function visit(location, options) {
|
||
session.visit(location, options);
|
||
}
|
||
|
||
function connectStreamSource(source) {
|
||
session.connectStreamSource(source);
|
||
}
|
||
|
||
function disconnectStreamSource(source) {
|
||
session.disconnectStreamSource(source);
|
||
}
|
||
|
||
function renderStreamMessage(message) {
|
||
session.renderStreamMessage(message);
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function setProgressBarDelay(delay) {
|
||
console.warn("Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`");
|
||
config.drive.progressBarDelay = delay;
|
||
}
|
||
|
||
function setConfirmMethod(confirmMethod) {
|
||
console.warn("Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`");
|
||
config.forms.confirm = confirmMethod;
|
||
}
|
||
|
||
function setFormMode(mode) {
|
||
console.warn("Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`");
|
||
config.forms.mode = mode;
|
||
}
|
||
|
||
function morphBodyElements(currentBody, newBody) {
|
||
MorphingPageRenderer.renderElement(currentBody, newBody);
|
||
}
|
||
|
||
function morphTurboFrameElements(currentFrame, newFrame) {
|
||
MorphingFrameRenderer.renderElement(currentFrame, newFrame);
|
||
}
|
||
|
||
var Turbo = Object.freeze({
|
||
__proto__: null,
|
||
navigator: navigator$1,
|
||
session: session,
|
||
cache: cache,
|
||
PageRenderer: PageRenderer,
|
||
PageSnapshot: PageSnapshot,
|
||
FrameRenderer: FrameRenderer,
|
||
fetch: fetchWithTurboHeaders,
|
||
config: config,
|
||
start: start,
|
||
registerAdapter: registerAdapter,
|
||
visit: visit,
|
||
connectStreamSource: connectStreamSource,
|
||
disconnectStreamSource: disconnectStreamSource,
|
||
renderStreamMessage: renderStreamMessage,
|
||
clearCache: clearCache,
|
||
setProgressBarDelay: setProgressBarDelay,
|
||
setConfirmMethod: setConfirmMethod,
|
||
setFormMode: setFormMode,
|
||
morphBodyElements: morphBodyElements,
|
||
morphTurboFrameElements: morphTurboFrameElements,
|
||
morphChildren: morphChildren,
|
||
morphElements: morphElements
|
||
});
|
||
|
||
class TurboFrameMissingError extends Error {}
|
||
|
||
class FrameController {
|
||
fetchResponseLoaded=_fetchResponse => Promise.resolve();
|
||
#currentFetchRequest=null;
|
||
#resolveVisitPromise=() => {};
|
||
#connected=false;
|
||
#hasBeenLoaded=false;
|
||
#ignoredAttributes=new Set;
|
||
#shouldMorphFrame=false;
|
||
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);
|
||
}
|
||
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 {refresh: refresh, src: src} = this.element;
|
||
this.#shouldMorphFrame = src && refresh === "morph";
|
||
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.#shouldMorphFrame = false;
|
||
this.fetchResponseLoaded = () => Promise.resolve();
|
||
}
|
||
}
|
||
elementAppearedInViewport(element) {
|
||
this.proposeVisitIfNavigatedWithAction(element, getVisitAction(element));
|
||
this.#loadSourceURL();
|
||
}
|
||
willSubmitFormLinkToLocation(link) {
|
||
return this.#shouldInterceptNavigation(link);
|
||
}
|
||
submittedFormLinkToLocation(link, _location, form) {
|
||
const frame = this.#findFrameElement(link);
|
||
if (frame) form.setAttribute("data-turbo-frame", frame.id);
|
||
}
|
||
shouldInterceptLinkClick(element, _location, _event) {
|
||
return this.#shouldInterceptNavigation(element);
|
||
}
|
||
linkClickIntercepted(element, location) {
|
||
this.#navigateFrame(element, location);
|
||
}
|
||
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: fetchRequest} = this.formSubmission;
|
||
this.prepareRequest(fetchRequest);
|
||
this.formSubmission.start();
|
||
}
|
||
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);
|
||
}
|
||
formSubmissionStarted({formElement: 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: formElement}) {
|
||
clearBusyState(formElement, this.#findFrameElement(formElement));
|
||
}
|
||
allowsImmediateRender({element: newFrame}, options) {
|
||
const event = dispatch("turbo:before-frame-render", {
|
||
target: this.element,
|
||
detail: {
|
||
newFrame: newFrame,
|
||
...options
|
||
},
|
||
cancelable: true
|
||
});
|
||
const {defaultPrevented: defaultPrevented, detail: {render: render}} = event;
|
||
if (this.view.renderer && render) {
|
||
this.view.renderer.renderElement = render;
|
||
}
|
||
return !defaultPrevented;
|
||
}
|
||
viewRenderedSnapshot(_snapshot, _isPreview, _renderMethod) {}
|
||
preloadOnLoadLinksForView(element) {
|
||
session.preloadOnLoadLinksForView(element);
|
||
}
|
||
viewInvalidated() {}
|
||
willRenderFrame(currentElement, _newElement) {
|
||
this.previousFrameElement = currentElement.cloneNode(true);
|
||
}
|
||
visitCachedSnapshot=({element: element}) => {
|
||
const frame = element.querySelector("#" + this.element.id);
|
||
if (frame && this.previousFrameElement) {
|
||
frame.replaceChildren(...this.previousFrameElement.children);
|
||
}
|
||
delete this.previousFrameElement;
|
||
};
|
||
async #loadFrameResponse(fetchResponse, document) {
|
||
const newFrameElement = await this.extractForeignFrameElement(document.body);
|
||
const rendererClass = this.#shouldMorphFrame ? MorphingFrameRenderer : FrameRenderer;
|
||
if (newFrameElement) {
|
||
const snapshot = new Snapshot(newFrameElement);
|
||
const renderer = new rendererClass(this, this.view.snapshot, snapshot, 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: visitCachedSnapshot} = frame.delegate;
|
||
frame.delegate.fetchResponseLoaded = async fetchResponse => {
|
||
if (frame.src) {
|
||
const {statusCode: statusCode, redirected: redirected} = fetchResponse;
|
||
const responseHTML = await fetchResponse.responseHTML;
|
||
const response = {
|
||
statusCode: statusCode,
|
||
redirected: redirected,
|
||
responseHTML: responseHTML
|
||
};
|
||
const options = {
|
||
response: response,
|
||
visitCachedSnapshot: 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: response,
|
||
visit: 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: location, redirected: redirected, statusCode: statusCode} = wrapped;
|
||
return session.visit(location, {
|
||
response: {
|
||
redirected: redirected,
|
||
statusCode: statusCode,
|
||
responseHTML: 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;
|
||
}
|
||
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);
|
||
}
|
||
};
|
||
|
||
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();
|
||
} catch {}
|
||
}
|
||
removeDuplicateTargetChildren() {
|
||
this.duplicateChildren.forEach((c => c.remove()));
|
||
}
|
||
get duplicateChildren() {
|
||
const existingChildren = this.targetElements.flatMap((e => [ ...e.children ])).filter((c => !!c.getAttribute("id")));
|
||
const newChildrenIds = [ ...this.templateContent?.children || [] ].filter((c => !!c.getAttribute("id"))).map((c => c.getAttribute("id")));
|
||
return existingChildren.filter((c => newChildrenIds.includes(c.getAttribute("id"))));
|
||
}
|
||
get performAction() {
|
||
if (this.action) {
|
||
const actionFunction = StreamActions[this.action];
|
||
if (actionFunction) {
|
||
return actionFunction;
|
||
}
|
||
this.#raise("unknown action");
|
||
}
|
||
this.#raise("action attribute is missing");
|
||
}
|
||
get targetElements() {
|
||
if (this.target) {
|
||
return this.targetElementsById;
|
||
} else if (this.targets) {
|
||
return this.targetElementsByQuery;
|
||
} else {
|
||
this.#raise("target or targets attribute is missing");
|
||
}
|
||
}
|
||
get templateContent() {
|
||
return this.templateElement.content.cloneNode(true);
|
||
}
|
||
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");
|
||
}
|
||
get action() {
|
||
return this.getAttribute("action");
|
||
}
|
||
get target() {
|
||
return this.getAttribute("target");
|
||
}
|
||
get targets() {
|
||
return this.getAttribute("targets");
|
||
}
|
||
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: StreamActions
|
||
};
|
||
|
||
start();
|
||
|
||
var Turbo$1 = Object.freeze({
|
||
__proto__: null,
|
||
FetchEnctype: FetchEnctype,
|
||
FetchMethod: FetchMethod,
|
||
FetchRequest: FetchRequest,
|
||
FetchResponse: FetchResponse,
|
||
FrameElement: FrameElement,
|
||
FrameLoadingStyle: FrameLoadingStyle,
|
||
FrameRenderer: FrameRenderer,
|
||
PageRenderer: PageRenderer,
|
||
PageSnapshot: PageSnapshot,
|
||
StreamActions: StreamActions,
|
||
StreamElement: StreamElement,
|
||
StreamSourceElement: StreamSourceElement,
|
||
cache: cache,
|
||
clearCache: clearCache,
|
||
config: config,
|
||
connectStreamSource: connectStreamSource,
|
||
disconnectStreamSource: disconnectStreamSource,
|
||
fetch: fetchWithTurboHeaders,
|
||
fetchEnctypeFromString: fetchEnctypeFromString,
|
||
fetchMethodFromString: fetchMethodFromString,
|
||
isSafe: isSafe,
|
||
morphBodyElements: morphBodyElements,
|
||
morphChildren: morphChildren,
|
||
morphElements: morphElements,
|
||
morphTurboFrameElements: morphTurboFrameElements,
|
||
navigator: navigator$1,
|
||
registerAdapter: registerAdapter,
|
||
renderStreamMessage: renderStreamMessage,
|
||
session: session,
|
||
setConfirmMethod: setConfirmMethod,
|
||
setFormMode: setFormMode,
|
||
setProgressBarDelay: setProgressBarDelay,
|
||
start: start,
|
||
visit: visit
|
||
});
|
||
|
||
let consumer;
|
||
|
||
async function getConsumer() {
|
||
return consumer || setConsumer(createConsumer$1().then(setConsumer));
|
||
}
|
||
|
||
function setConsumer(newConsumer) {
|
||
return consumer = newConsumer;
|
||
}
|
||
|
||
async function createConsumer$1() {
|
||
const {createConsumer: createConsumer} = await Promise.resolve().then((function() {
|
||
return index;
|
||
}));
|
||
return createConsumer();
|
||
}
|
||
|
||
async function subscribeTo(channel, mixin) {
|
||
const {subscriptions: subscriptions} = await getConsumer();
|
||
return subscriptions.create(channel, mixin);
|
||
}
|
||
|
||
var cable = Object.freeze({
|
||
__proto__: null,
|
||
getConsumer: getConsumer,
|
||
setConsumer: setConsumer,
|
||
createConsumer: createConsumer$1,
|
||
subscribeTo: subscribeTo
|
||
});
|
||
|
||
function walk(obj) {
|
||
if (!obj || typeof obj !== "object") return obj;
|
||
if (obj instanceof Date || obj instanceof RegExp) return obj;
|
||
if (Array.isArray(obj)) return obj.map(walk);
|
||
return Object.keys(obj).reduce((function(acc, key) {
|
||
var camel = key[0].toLowerCase() + key.slice(1).replace(/([A-Z]+)/g, (function(m, x) {
|
||
return "_" + x.toLowerCase();
|
||
}));
|
||
acc[camel] = walk(obj[key]);
|
||
return acc;
|
||
}), {});
|
||
}
|
||
|
||
class TurboCableStreamSourceElement extends HTMLElement {
|
||
static observedAttributes=[ "channel", "signed-stream-name" ];
|
||
async connectedCallback() {
|
||
connectStreamSource(this);
|
||
this.subscription = await subscribeTo(this.channel, {
|
||
received: this.dispatchMessageEvent.bind(this),
|
||
connected: this.subscriptionConnected.bind(this),
|
||
disconnected: this.subscriptionDisconnected.bind(this)
|
||
});
|
||
}
|
||
disconnectedCallback() {
|
||
disconnectStreamSource(this);
|
||
if (this.subscription) this.subscription.unsubscribe();
|
||
this.subscriptionDisconnected();
|
||
}
|
||
attributeChangedCallback() {
|
||
if (this.subscription) {
|
||
this.disconnectedCallback();
|
||
this.connectedCallback();
|
||
}
|
||
}
|
||
dispatchMessageEvent(data) {
|
||
const event = new MessageEvent("message", {
|
||
data: data
|
||
});
|
||
return this.dispatchEvent(event);
|
||
}
|
||
subscriptionConnected() {
|
||
this.setAttribute("connected", "");
|
||
}
|
||
subscriptionDisconnected() {
|
||
this.removeAttribute("connected");
|
||
}
|
||
get channel() {
|
||
const channel = this.getAttribute("channel");
|
||
const signed_stream_name = this.getAttribute("signed-stream-name");
|
||
return {
|
||
channel: channel,
|
||
signed_stream_name: signed_stream_name,
|
||
...walk({
|
||
...this.dataset
|
||
})
|
||
};
|
||
}
|
||
}
|
||
|
||
if (customElements.get("turbo-cable-stream-source") === undefined) {
|
||
customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement);
|
||
}
|
||
|
||
function encodeMethodIntoRequestBody(event) {
|
||
if (event.target instanceof HTMLFormElement) {
|
||
const {target: form, detail: {fetchOptions: fetchOptions}} = event;
|
||
form.addEventListener("turbo:submit-start", (({detail: {formSubmission: {submitter: submitter}}}) => {
|
||
const body = isBodyInit(fetchOptions.body) ? fetchOptions.body : new URLSearchParams;
|
||
const method = determineFetchMethod(submitter, body, form);
|
||
if (!/get/i.test(method)) {
|
||
if (/post/i.test(method)) {
|
||
body.delete("_method");
|
||
} else {
|
||
body.set("_method", method);
|
||
}
|
||
fetchOptions.method = "post";
|
||
}
|
||
}), {
|
||
once: true
|
||
});
|
||
}
|
||
}
|
||
|
||
function determineFetchMethod(submitter, body, form) {
|
||
const formMethod = determineFormMethod(submitter);
|
||
const overrideMethod = body.get("_method");
|
||
const method = form.getAttribute("method") || "get";
|
||
if (typeof formMethod == "string") {
|
||
return formMethod;
|
||
} else if (typeof overrideMethod == "string") {
|
||
return overrideMethod;
|
||
} else {
|
||
return method;
|
||
}
|
||
}
|
||
|
||
function determineFormMethod(submitter) {
|
||
if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
|
||
if (submitter.name === "_method") {
|
||
return submitter.value;
|
||
} else if (submitter.hasAttribute("formmethod")) {
|
||
return submitter.formMethod;
|
||
} else {
|
||
return null;
|
||
}
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isBodyInit(body) {
|
||
return body instanceof FormData || body instanceof URLSearchParams;
|
||
}
|
||
|
||
window.Turbo = Turbo$1;
|
||
|
||
addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody);
|
||
|
||
var adapters = {
|
||
logger: typeof console !== "undefined" ? console : undefined,
|
||
WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined
|
||
};
|
||
|
||
var logger = {
|
||
log(...messages) {
|
||
if (this.enabled) {
|
||
messages.push(Date.now());
|
||
adapters.logger.log("[ActionCable]", ...messages);
|
||
}
|
||
}
|
||
};
|
||
|
||
const now = () => (new Date).getTime();
|
||
|
||
const secondsSince = time => (now() - time) / 1e3;
|
||
|
||
class ConnectionMonitor {
|
||
constructor(connection) {
|
||
this.visibilityDidChange = this.visibilityDidChange.bind(this);
|
||
this.connection = connection;
|
||
this.reconnectAttempts = 0;
|
||
}
|
||
start() {
|
||
if (!this.isRunning()) {
|
||
this.startedAt = now();
|
||
delete this.stoppedAt;
|
||
this.startPolling();
|
||
addEventListener("visibilitychange", this.visibilityDidChange);
|
||
logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
|
||
}
|
||
}
|
||
stop() {
|
||
if (this.isRunning()) {
|
||
this.stoppedAt = now();
|
||
this.stopPolling();
|
||
removeEventListener("visibilitychange", this.visibilityDidChange);
|
||
logger.log("ConnectionMonitor stopped");
|
||
}
|
||
}
|
||
isRunning() {
|
||
return this.startedAt && !this.stoppedAt;
|
||
}
|
||
recordMessage() {
|
||
this.pingedAt = now();
|
||
}
|
||
recordConnect() {
|
||
this.reconnectAttempts = 0;
|
||
delete this.disconnectedAt;
|
||
logger.log("ConnectionMonitor recorded connect");
|
||
}
|
||
recordDisconnect() {
|
||
this.disconnectedAt = now();
|
||
logger.log("ConnectionMonitor recorded disconnect");
|
||
}
|
||
startPolling() {
|
||
this.stopPolling();
|
||
this.poll();
|
||
}
|
||
stopPolling() {
|
||
clearTimeout(this.pollTimeout);
|
||
}
|
||
poll() {
|
||
this.pollTimeout = setTimeout((() => {
|
||
this.reconnectIfStale();
|
||
this.poll();
|
||
}), this.getPollInterval());
|
||
}
|
||
getPollInterval() {
|
||
const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor;
|
||
const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10));
|
||
const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate;
|
||
const jitter = jitterMax * Math.random();
|
||
return staleThreshold * 1e3 * backoff * (1 + jitter);
|
||
}
|
||
reconnectIfStale() {
|
||
if (this.connectionIsStale()) {
|
||
logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
|
||
this.reconnectAttempts++;
|
||
if (this.disconnectedRecently()) {
|
||
logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
|
||
} else {
|
||
logger.log("ConnectionMonitor reopening");
|
||
this.connection.reopen();
|
||
}
|
||
}
|
||
}
|
||
get refreshedAt() {
|
||
return this.pingedAt ? this.pingedAt : this.startedAt;
|
||
}
|
||
connectionIsStale() {
|
||
return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
|
||
}
|
||
disconnectedRecently() {
|
||
return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
|
||
}
|
||
visibilityDidChange() {
|
||
if (document.visibilityState === "visible") {
|
||
setTimeout((() => {
|
||
if (this.connectionIsStale() || !this.connection.isOpen()) {
|
||
logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`);
|
||
this.connection.reopen();
|
||
}
|
||
}), 200);
|
||
}
|
||
}
|
||
}
|
||
|
||
ConnectionMonitor.staleThreshold = 6;
|
||
|
||
ConnectionMonitor.reconnectionBackoffRate = .15;
|
||
|
||
var ConnectionMonitor$1 = ConnectionMonitor;
|
||
|
||
var INTERNAL = {
|
||
message_types: {
|
||
welcome: "welcome",
|
||
disconnect: "disconnect",
|
||
ping: "ping",
|
||
confirmation: "confirm_subscription",
|
||
rejection: "reject_subscription"
|
||
},
|
||
disconnect_reasons: {
|
||
unauthorized: "unauthorized",
|
||
invalid_request: "invalid_request",
|
||
server_restart: "server_restart",
|
||
remote: "remote"
|
||
},
|
||
default_mount_path: "/cable",
|
||
protocols: [ "actioncable-v1-json", "actioncable-unsupported" ]
|
||
};
|
||
|
||
const {message_types: message_types, protocols: protocols} = INTERNAL;
|
||
|
||
const supportedProtocols = protocols.slice(0, protocols.length - 1);
|
||
|
||
const indexOf = [].indexOf;
|
||
|
||
class Connection {
|
||
constructor(consumer) {
|
||
this.open = this.open.bind(this);
|
||
this.consumer = consumer;
|
||
this.subscriptions = this.consumer.subscriptions;
|
||
this.monitor = new ConnectionMonitor$1(this);
|
||
this.disconnected = true;
|
||
}
|
||
send(data) {
|
||
if (this.isOpen()) {
|
||
this.webSocket.send(JSON.stringify(data));
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
}
|
||
open() {
|
||
if (this.isActive()) {
|
||
logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);
|
||
return false;
|
||
} else {
|
||
const socketProtocols = [ ...protocols, ...this.consumer.subprotocols || [] ];
|
||
logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`);
|
||
if (this.webSocket) {
|
||
this.uninstallEventHandlers();
|
||
}
|
||
this.webSocket = new adapters.WebSocket(this.consumer.url, socketProtocols);
|
||
this.installEventHandlers();
|
||
this.monitor.start();
|
||
return true;
|
||
}
|
||
}
|
||
close({allowReconnect: allowReconnect} = {
|
||
allowReconnect: true
|
||
}) {
|
||
if (!allowReconnect) {
|
||
this.monitor.stop();
|
||
}
|
||
if (this.isOpen()) {
|
||
return this.webSocket.close();
|
||
}
|
||
}
|
||
reopen() {
|
||
logger.log(`Reopening WebSocket, current state is ${this.getState()}`);
|
||
if (this.isActive()) {
|
||
try {
|
||
return this.close();
|
||
} catch (error) {
|
||
logger.log("Failed to reopen WebSocket", error);
|
||
} finally {
|
||
logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);
|
||
setTimeout(this.open, this.constructor.reopenDelay);
|
||
}
|
||
} else {
|
||
return this.open();
|
||
}
|
||
}
|
||
getProtocol() {
|
||
if (this.webSocket) {
|
||
return this.webSocket.protocol;
|
||
}
|
||
}
|
||
isOpen() {
|
||
return this.isState("open");
|
||
}
|
||
isActive() {
|
||
return this.isState("open", "connecting");
|
||
}
|
||
triedToReconnect() {
|
||
return this.monitor.reconnectAttempts > 0;
|
||
}
|
||
isProtocolSupported() {
|
||
return indexOf.call(supportedProtocols, this.getProtocol()) >= 0;
|
||
}
|
||
isState(...states) {
|
||
return indexOf.call(states, this.getState()) >= 0;
|
||
}
|
||
getState() {
|
||
if (this.webSocket) {
|
||
for (let state in adapters.WebSocket) {
|
||
if (adapters.WebSocket[state] === this.webSocket.readyState) {
|
||
return state.toLowerCase();
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
installEventHandlers() {
|
||
for (let eventName in this.events) {
|
||
const handler = this.events[eventName].bind(this);
|
||
this.webSocket[`on${eventName}`] = handler;
|
||
}
|
||
}
|
||
uninstallEventHandlers() {
|
||
for (let eventName in this.events) {
|
||
this.webSocket[`on${eventName}`] = function() {};
|
||
}
|
||
}
|
||
}
|
||
|
||
Connection.reopenDelay = 500;
|
||
|
||
Connection.prototype.events = {
|
||
message(event) {
|
||
if (!this.isProtocolSupported()) {
|
||
return;
|
||
}
|
||
const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data);
|
||
this.monitor.recordMessage();
|
||
switch (type) {
|
||
case message_types.welcome:
|
||
if (this.triedToReconnect()) {
|
||
this.reconnectAttempted = true;
|
||
}
|
||
this.monitor.recordConnect();
|
||
return this.subscriptions.reload();
|
||
|
||
case message_types.disconnect:
|
||
logger.log(`Disconnecting. Reason: ${reason}`);
|
||
return this.close({
|
||
allowReconnect: reconnect
|
||
});
|
||
|
||
case message_types.ping:
|
||
return null;
|
||
|
||
case message_types.confirmation:
|
||
this.subscriptions.confirmSubscription(identifier);
|
||
if (this.reconnectAttempted) {
|
||
this.reconnectAttempted = false;
|
||
return this.subscriptions.notify(identifier, "connected", {
|
||
reconnected: true
|
||
});
|
||
} else {
|
||
return this.subscriptions.notify(identifier, "connected", {
|
||
reconnected: false
|
||
});
|
||
}
|
||
|
||
case message_types.rejection:
|
||
return this.subscriptions.reject(identifier);
|
||
|
||
default:
|
||
return this.subscriptions.notify(identifier, "received", message);
|
||
}
|
||
},
|
||
open() {
|
||
logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);
|
||
this.disconnected = false;
|
||
if (!this.isProtocolSupported()) {
|
||
logger.log("Protocol is unsupported. Stopping monitor and disconnecting.");
|
||
return this.close({
|
||
allowReconnect: false
|
||
});
|
||
}
|
||
},
|
||
close(event) {
|
||
logger.log("WebSocket onclose event");
|
||
if (this.disconnected) {
|
||
return;
|
||
}
|
||
this.disconnected = true;
|
||
this.monitor.recordDisconnect();
|
||
return this.subscriptions.notifyAll("disconnected", {
|
||
willAttemptReconnect: this.monitor.isRunning()
|
||
});
|
||
},
|
||
error() {
|
||
logger.log("WebSocket onerror event");
|
||
}
|
||
};
|
||
|
||
var Connection$1 = Connection;
|
||
|
||
const extend = function(object, properties) {
|
||
if (properties != null) {
|
||
for (let key in properties) {
|
||
const value = properties[key];
|
||
object[key] = value;
|
||
}
|
||
}
|
||
return object;
|
||
};
|
||
|
||
class Subscription {
|
||
constructor(consumer, params = {}, mixin) {
|
||
this.consumer = consumer;
|
||
this.identifier = JSON.stringify(params);
|
||
extend(this, mixin);
|
||
}
|
||
perform(action, data = {}) {
|
||
data.action = action;
|
||
return this.send(data);
|
||
}
|
||
send(data) {
|
||
return this.consumer.send({
|
||
command: "message",
|
||
identifier: this.identifier,
|
||
data: JSON.stringify(data)
|
||
});
|
||
}
|
||
unsubscribe() {
|
||
return this.consumer.subscriptions.remove(this);
|
||
}
|
||
}
|
||
|
||
class SubscriptionGuarantor {
|
||
constructor(subscriptions) {
|
||
this.subscriptions = subscriptions;
|
||
this.pendingSubscriptions = [];
|
||
}
|
||
guarantee(subscription) {
|
||
if (this.pendingSubscriptions.indexOf(subscription) == -1) {
|
||
logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
|
||
this.pendingSubscriptions.push(subscription);
|
||
} else {
|
||
logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
|
||
}
|
||
this.startGuaranteeing();
|
||
}
|
||
forget(subscription) {
|
||
logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
|
||
this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
|
||
}
|
||
startGuaranteeing() {
|
||
this.stopGuaranteeing();
|
||
this.retrySubscribing();
|
||
}
|
||
stopGuaranteeing() {
|
||
clearTimeout(this.retryTimeout);
|
||
}
|
||
retrySubscribing() {
|
||
this.retryTimeout = setTimeout((() => {
|
||
if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
|
||
this.pendingSubscriptions.map((subscription => {
|
||
logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
|
||
this.subscriptions.subscribe(subscription);
|
||
}));
|
||
}
|
||
}), 500);
|
||
}
|
||
}
|
||
|
||
var SubscriptionGuarantor$1 = SubscriptionGuarantor;
|
||
|
||
class Subscriptions {
|
||
constructor(consumer) {
|
||
this.consumer = consumer;
|
||
this.guarantor = new SubscriptionGuarantor$1(this);
|
||
this.subscriptions = [];
|
||
}
|
||
create(channelName, mixin) {
|
||
const channel = channelName;
|
||
const params = typeof channel === "object" ? channel : {
|
||
channel: channel
|
||
};
|
||
const subscription = new Subscription(this.consumer, params, mixin);
|
||
return this.add(subscription);
|
||
}
|
||
add(subscription) {
|
||
this.subscriptions.push(subscription);
|
||
this.consumer.ensureActiveConnection();
|
||
this.notify(subscription, "initialized");
|
||
this.subscribe(subscription);
|
||
return subscription;
|
||
}
|
||
remove(subscription) {
|
||
this.forget(subscription);
|
||
if (!this.findAll(subscription.identifier).length) {
|
||
this.sendCommand(subscription, "unsubscribe");
|
||
}
|
||
return subscription;
|
||
}
|
||
reject(identifier) {
|
||
return this.findAll(identifier).map((subscription => {
|
||
this.forget(subscription);
|
||
this.notify(subscription, "rejected");
|
||
return subscription;
|
||
}));
|
||
}
|
||
forget(subscription) {
|
||
this.guarantor.forget(subscription);
|
||
this.subscriptions = this.subscriptions.filter((s => s !== subscription));
|
||
return subscription;
|
||
}
|
||
findAll(identifier) {
|
||
return this.subscriptions.filter((s => s.identifier === identifier));
|
||
}
|
||
reload() {
|
||
return this.subscriptions.map((subscription => this.subscribe(subscription)));
|
||
}
|
||
notifyAll(callbackName, ...args) {
|
||
return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
|
||
}
|
||
notify(subscription, callbackName, ...args) {
|
||
let subscriptions;
|
||
if (typeof subscription === "string") {
|
||
subscriptions = this.findAll(subscription);
|
||
} else {
|
||
subscriptions = [ subscription ];
|
||
}
|
||
return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
|
||
}
|
||
subscribe(subscription) {
|
||
if (this.sendCommand(subscription, "subscribe")) {
|
||
this.guarantor.guarantee(subscription);
|
||
}
|
||
}
|
||
confirmSubscription(identifier) {
|
||
logger.log(`Subscription confirmed ${identifier}`);
|
||
this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
|
||
}
|
||
sendCommand(subscription, command) {
|
||
const {identifier: identifier} = subscription;
|
||
return this.consumer.send({
|
||
command: command,
|
||
identifier: identifier
|
||
});
|
||
}
|
||
}
|
||
|
||
class Consumer {
|
||
constructor(url) {
|
||
this._url = url;
|
||
this.subscriptions = new Subscriptions(this);
|
||
this.connection = new Connection$1(this);
|
||
this.subprotocols = [];
|
||
}
|
||
get url() {
|
||
return createWebSocketURL(this._url);
|
||
}
|
||
send(data) {
|
||
return this.connection.send(data);
|
||
}
|
||
connect() {
|
||
return this.connection.open();
|
||
}
|
||
disconnect() {
|
||
return this.connection.close({
|
||
allowReconnect: false
|
||
});
|
||
}
|
||
ensureActiveConnection() {
|
||
if (!this.connection.isActive()) {
|
||
return this.connection.open();
|
||
}
|
||
}
|
||
addSubProtocol(subprotocol) {
|
||
this.subprotocols = [ ...this.subprotocols, subprotocol ];
|
||
}
|
||
}
|
||
|
||
function createWebSocketURL(url) {
|
||
if (typeof url === "function") {
|
||
url = url();
|
||
}
|
||
if (url && !/^wss?:/i.test(url)) {
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.href = a.href;
|
||
a.protocol = a.protocol.replace("http", "ws");
|
||
return a.href;
|
||
} else {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) {
|
||
return new Consumer(url);
|
||
}
|
||
|
||
function getConfig(name) {
|
||
const element = document.head.querySelector(`meta[name='action-cable-${name}']`);
|
||
if (element) {
|
||
return element.getAttribute("content");
|
||
}
|
||
}
|
||
|
||
var index = Object.freeze({
|
||
__proto__: null,
|
||
Connection: Connection$1,
|
||
ConnectionMonitor: ConnectionMonitor$1,
|
||
Consumer: Consumer,
|
||
INTERNAL: INTERNAL,
|
||
Subscription: Subscription,
|
||
Subscriptions: Subscriptions,
|
||
SubscriptionGuarantor: SubscriptionGuarantor$1,
|
||
adapters: adapters,
|
||
createWebSocketURL: createWebSocketURL,
|
||
logger: logger,
|
||
createConsumer: createConsumer,
|
||
getConfig: getConfig
|
||
});
|
||
|
||
export { Turbo$1 as Turbo, cable };
|