feat(modal): add cancellable shared runtime
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0647c501ba
commit
d79b063bf9
12 files changed
+999
-392
No files matched your search
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* User-scoped in-memory modal cache with short TTLs and request deduplication.
|
||||
* Private modal payloads are never persisted to browser storage.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_TTL = 15000;
|
||||
var entries = Object.create(null);
|
||||
var flights = Object.create(null);
|
||||
|
||||
function scope() {
|
||||
var config = window.aksrtData || window.aksrt || {};
|
||||
var userId = parseInt(config.userId || 0, 10) || 0;
|
||||
return (config.isLoggedIn && userId > 0 ? "user:" + userId : "guest") + ":v1";
|
||||
}
|
||||
|
||||
function token(key) {
|
||||
return scope() + "::" + String(key || "");
|
||||
}
|
||||
|
||||
function read(key) {
|
||||
var id = token(key);
|
||||
var entry = entries[id];
|
||||
if (!entry) return { hit: false, value: undefined };
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
delete entries[id];
|
||||
return { hit: false, value: undefined };
|
||||
}
|
||||
return { hit: true, value: entry.value };
|
||||
}
|
||||
|
||||
function tagsOf(value) {
|
||||
return Array.isArray(value) ? value.slice() : (value ? [value] : []);
|
||||
}
|
||||
|
||||
function set(key, value, options) {
|
||||
options = options || {};
|
||||
var ttl = Number(options.ttl || DEFAULT_TTL);
|
||||
entries[token(key)] = {
|
||||
value: value,
|
||||
expiresAt: Date.now() + Math.max(0, ttl),
|
||||
tags: tagsOf(options.tags),
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
function matchesTags(entry, wanted) {
|
||||
if (!wanted.length) return true;
|
||||
return wanted.some(function (tag) { return entry.tags.indexOf(tag) >= 0; });
|
||||
}
|
||||
|
||||
function invalidate(tags) {
|
||||
var wanted = Array.isArray(tags) ? tags : (tags ? [tags] : []);
|
||||
var removed = 0;
|
||||
Object.keys(entries).forEach(function (id) {
|
||||
if (!wanted.length || matchesTags(entries[id], wanted)) {
|
||||
delete entries[id];
|
||||
removed += 1;
|
||||
}
|
||||
});
|
||||
Object.keys(flights).forEach(function (id) {
|
||||
var flight = flights[id];
|
||||
if (!wanted.length || matchesTags(flight, wanted)) {
|
||||
flight.cancelled = true;
|
||||
delete flights[id];
|
||||
if (typeof flight.cancel === "function") flight.cancel();
|
||||
}
|
||||
});
|
||||
return removed;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
entries = Object.create(null);
|
||||
Object.keys(flights).forEach(function (id) {
|
||||
var flight = flights[id];
|
||||
flight.cancelled = true;
|
||||
if (typeof flight.cancel === "function") flight.cancel();
|
||||
});
|
||||
flights = Object.create(null);
|
||||
}
|
||||
|
||||
function acquire(key, loader, options) {
|
||||
options = options || {};
|
||||
var id = token(key);
|
||||
var cached = options.skipCache ? { hit: false } : read(key);
|
||||
|
||||
if (cached.hit) {
|
||||
return {
|
||||
cached: true,
|
||||
promise: Promise.resolve(cached.value),
|
||||
release: function () {},
|
||||
};
|
||||
}
|
||||
|
||||
var flight = flights[id];
|
||||
if (!flight) {
|
||||
flight = {
|
||||
refs: 0,
|
||||
settled: false,
|
||||
cancelled: false,
|
||||
tags: tagsOf(options.tags),
|
||||
cancel: options.cancel,
|
||||
};
|
||||
flight.promise = Promise.resolve().then(loader).then(function (value) {
|
||||
flight.settled = true;
|
||||
if (!flight.cancelled && options.store !== false) set(key, value, options);
|
||||
if (flights[id] === flight) delete flights[id];
|
||||
return value;
|
||||
}, function (error) {
|
||||
flight.settled = true;
|
||||
if (flights[id] === flight) delete flights[id];
|
||||
throw error;
|
||||
});
|
||||
flights[id] = flight;
|
||||
}
|
||||
|
||||
flight.refs += 1;
|
||||
var released = false;
|
||||
return {
|
||||
cached: false,
|
||||
promise: flight.promise,
|
||||
release: function () {
|
||||
if (released) return;
|
||||
released = true;
|
||||
flight.refs = Math.max(0, flight.refs - 1);
|
||||
if (flight.refs === 0 && !flight.settled) {
|
||||
flight.cancelled = true;
|
||||
if (flights[id] === flight) delete flights[id];
|
||||
if (typeof flight.cancel === "function") flight.cancel();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
["aksrt:auth:logout", "aksrt:account-switch", "aksrt:logout", "aksrt:user:logout", "aksrt:auth:changed", "wp_logout"].forEach(function (eventName) {
|
||||
document.addEventListener(eventName, clear);
|
||||
});
|
||||
|
||||
window.AksrtModalCache = {
|
||||
get: function (key) { return read(key); },
|
||||
set: set,
|
||||
acquire: acquire,
|
||||
invalidate: invalidate,
|
||||
clear: clear,
|
||||
scope: scope,
|
||||
};
|
||||
})(window, document);
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* Canonical frontend modal runtime.
|
||||
*
|
||||
* Keeps the existing remote-modal shell while adding cancellable loaders,
|
||||
* short in-memory caching, request deduplication, retryable errors, focus
|
||||
* restoration, and modal-scoped rehydration.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var ROOT_ID = "aksrt-remote-modal-root";
|
||||
var active = null;
|
||||
var sequence = 0;
|
||||
var loaders = Object.create(null);
|
||||
|
||||
function isMobile() {
|
||||
return window.matchMedia && window.matchMedia("(max-width: 768px)").matches;
|
||||
}
|
||||
|
||||
function ensureRoot() {
|
||||
var root = document.getElementById(ROOT_ID);
|
||||
if (root) return root;
|
||||
root = document.createElement("div");
|
||||
root.id = ROOT_ID;
|
||||
root.className = "aksrt-remote-modal-host";
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function lockScroll(lock) {
|
||||
document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock);
|
||||
document.body.classList.toggle("aksrt-remote-modal-open", !!lock);
|
||||
}
|
||||
|
||||
function skeleton(options) {
|
||||
return (options && options.skeleton) ||
|
||||
'<div class="aksrt-remote-modal__skeleton" aria-live="polite" aria-busy="true">' +
|
||||
'<span class="aksrt-remote-modal__skeleton-line is-wide"></span>' +
|
||||
'<span class="aksrt-remote-modal__skeleton-line"></span>' +
|
||||
'<span class="aksrt-remote-modal__skeleton-line is-short"></span>' +
|
||||
'<span class="aksrt-remote-modal__skeleton-block"></span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function telemetryStart(kind) {
|
||||
if (kind === false) return "";
|
||||
var telemetry = window.AksrtModalTelemetry;
|
||||
if (!telemetry || typeof telemetry.start !== "function") return "";
|
||||
return telemetry.start(kind || "unknown");
|
||||
}
|
||||
|
||||
function telemetryMark(session, event) {
|
||||
var telemetry = window.AksrtModalTelemetry;
|
||||
if (!session || !session.telemetryId || !telemetry || typeof telemetry.mark !== "function") return;
|
||||
telemetry.mark(session.telemetryId, event);
|
||||
}
|
||||
|
||||
function setState(session, status, error) {
|
||||
session.state.status = status;
|
||||
session.state.error = error || null;
|
||||
session.state.updatedAt = Date.now();
|
||||
if (session.shell) session.shell.setAttribute("data-aksrt-modal-state", status);
|
||||
if (typeof session.options.onState === "function") {
|
||||
session.options.onState(status, session);
|
||||
}
|
||||
}
|
||||
|
||||
function focusable(shell) {
|
||||
return shell.querySelectorAll("button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])");
|
||||
}
|
||||
|
||||
function closeSelector() {
|
||||
return "[data-aksrt-remote-modal-close], [data-aksrt-modal-close], [data-dismiss='modal'], .aksrt-remote-modal__close, .aksrt-vip-modal-close, .payvip-modal .close";
|
||||
}
|
||||
|
||||
function bindChrome(session) {
|
||||
var shell = session.shell;
|
||||
shell.addEventListener("click", function (event) {
|
||||
var closeButton = event.target.closest(closeSelector());
|
||||
if (closeButton) {
|
||||
event.preventDefault();
|
||||
close("control");
|
||||
return;
|
||||
}
|
||||
if (event.target === shell || event.target.classList.contains("aksrt-remote-modal__overlay")) {
|
||||
close("overlay");
|
||||
return;
|
||||
}
|
||||
var retry = event.target.closest("[data-aksrt-remote-modal-retry], [data-aksrt-modal-retry]");
|
||||
if (retry) {
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
shell.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close("escape");
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
var nodes = focusable(shell);
|
||||
if (!nodes.length) {
|
||||
event.preventDefault();
|
||||
shell.focus();
|
||||
return;
|
||||
}
|
||||
var first = nodes[0];
|
||||
var last = nodes[nodes.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createSession(options) {
|
||||
options = options || {};
|
||||
var root = ensureRoot();
|
||||
var shell = document.createElement("div");
|
||||
var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile());
|
||||
var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : "";
|
||||
var initialHtml = options.html !== undefined ? options.html : skeleton(options);
|
||||
|
||||
shell.className =
|
||||
"aksrt-remote-modal" +
|
||||
(bottom ? " aksrt-remote-modal--bottom" : "") +
|
||||
(options.className ? " " + options.className : "");
|
||||
shell.setAttribute("role", "dialog");
|
||||
shell.setAttribute("aria-modal", "true");
|
||||
shell.setAttribute("tabindex", "-1");
|
||||
shell.innerHTML =
|
||||
'<div class="aksrt-remote-modal__overlay" data-aksrt-remote-modal-close></div>' +
|
||||
'<div class="aksrt-remote-modal__dialog" style="' + heightStyle + '">' +
|
||||
'<div class="aksrt-remote-modal__content">' + initialHtml + "</div></div>";
|
||||
|
||||
var session = {
|
||||
id: String(++sequence),
|
||||
shell: shell,
|
||||
content: shell.querySelector(".aksrt-remote-modal__content"),
|
||||
options: options,
|
||||
state: window.AksrtModalState && typeof window.AksrtModalState.create === "function"
|
||||
? window.AksrtModalState.create(options.kind || "generic")
|
||||
: { status: "idle", error: null },
|
||||
focusReturn: document.activeElement,
|
||||
controller: null,
|
||||
flight: null,
|
||||
closed: false,
|
||||
telemetryId: telemetryStart(options.telemetry === false ? false : options.kind),
|
||||
};
|
||||
|
||||
root.appendChild(shell);
|
||||
bindChrome(session);
|
||||
active = session;
|
||||
lockScroll(true);
|
||||
void shell.offsetWidth;
|
||||
shell.classList.add("is-open");
|
||||
setState(session, options.html !== undefined ? "ready" : "loading");
|
||||
telemetryMark(session, "shell");
|
||||
|
||||
var nodes = focusable(shell);
|
||||
try {
|
||||
(nodes.length ? nodes[0] : shell).focus();
|
||||
} catch (error) {}
|
||||
|
||||
document.dispatchEvent(new CustomEvent("aksrt:remote-modal:open", {
|
||||
detail: { shell: shell, options: options, session: session },
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent("aksrt:modal:open", {
|
||||
detail: { shell: shell, options: options, session: session },
|
||||
}));
|
||||
|
||||
if (options.html !== undefined) {
|
||||
rehydrate(session);
|
||||
telemetryMark(session, "first-content");
|
||||
telemetryMark(session, "ready");
|
||||
if (typeof options.onContent === "function") options.onContent(options.html, session);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
function rehydrate(session) {
|
||||
if (!session || !session.content) return;
|
||||
if (window.AksrtFrontend && typeof window.AksrtFrontend.rehydrate === "function") {
|
||||
window.AksrtFrontend.rehydrate(session.content, "modal");
|
||||
}
|
||||
}
|
||||
|
||||
function abortSessionRequest(session) {
|
||||
if (!session) return;
|
||||
if (session.flight && typeof session.flight.release === "function") session.flight.release();
|
||||
session.flight = null;
|
||||
if (session.controller) {
|
||||
try { session.controller.abort(); } catch (error) {}
|
||||
}
|
||||
session.controller = null;
|
||||
}
|
||||
|
||||
function close(reason, preserveRequest) {
|
||||
if (!active) return;
|
||||
var session = active;
|
||||
active = null;
|
||||
session.closed = true;
|
||||
if (!preserveRequest) abortSessionRequest(session);
|
||||
session.shell.classList.remove("is-open");
|
||||
if (!preserveRequest) lockScroll(false);
|
||||
document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close", {
|
||||
detail: { shell: session.shell, reason: reason || "close", session: session },
|
||||
}));
|
||||
document.dispatchEvent(new CustomEvent("aksrt:modal:close", {
|
||||
detail: { shell: session.shell, reason: reason || "close", session: session },
|
||||
}));
|
||||
|
||||
var node = session.shell;
|
||||
window.setTimeout(function () {
|
||||
if (node && node.parentNode) node.parentNode.removeChild(node);
|
||||
var target = session.focusReturn;
|
||||
if (!preserveRequest && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") {
|
||||
try { target.focus(); } catch (error) {}
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function responseHtml(result) {
|
||||
if (!result) return "";
|
||||
var json = result.json;
|
||||
if (json) {
|
||||
if (typeof json.html === "string") return json.html;
|
||||
if (json.data && typeof json.data.html === "string") return json.data.html;
|
||||
if (json.data && typeof json.data.show_modal === "string") return json.data.show_modal;
|
||||
if (typeof json.data === "string") return json.data;
|
||||
}
|
||||
return typeof result.text === "string" ? result.text : "";
|
||||
}
|
||||
|
||||
function errorMessage(error) {
|
||||
if (error && error.code === "unauthorized") return "请先登录";
|
||||
return error && error.message ? String(error.message) : "加载失败,请重试";
|
||||
}
|
||||
|
||||
function renderError(session, error) {
|
||||
if (!session || session.closed || active !== session) return;
|
||||
var unauthorized = error && error.code === "unauthorized";
|
||||
setState(session, unauthorized ? "unauthorized" : "error", error);
|
||||
telemetryMark(session, unauthorized ? "unauthorized" : "error");
|
||||
session.content.innerHTML =
|
||||
'<div class="aksrt-remote-modal__error touch border-title"><b>提示</b></div>' +
|
||||
'<div class="aksrt-remote-modal__error-body">' + escapeHtml(errorMessage(error)) +
|
||||
'</div><div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but" data-aksrt-remote-modal-retry>重试</button>' +
|
||||
'<button type="button" class="but" data-aksrt-remote-modal-close>关闭</button></div>';
|
||||
rehydrate(session);
|
||||
if (typeof session.options.onError === "function") session.options.onError(error, session);
|
||||
}
|
||||
|
||||
function renderResult(session, result) {
|
||||
if (!session || session.closed || active !== session) return;
|
||||
var html = responseHtml(result);
|
||||
if (!html) {
|
||||
setState(session, "empty");
|
||||
telemetryMark(session, "first-content");
|
||||
telemetryMark(session, "ready");
|
||||
session.content.innerHTML = '<div class="aksrt-remote-modal__error-body">暂无内容</div>';
|
||||
return;
|
||||
}
|
||||
session.content.innerHTML = html;
|
||||
setState(session, "ready");
|
||||
telemetryMark(session, "first-content");
|
||||
rehydrate(session);
|
||||
telemetryMark(session, "ready");
|
||||
if (typeof session.options.onContent === "function") session.options.onContent(html, session);
|
||||
}
|
||||
|
||||
function load(session, loadOptions) {
|
||||
loadOptions = loadOptions || {};
|
||||
if (!session || session.closed || !session.options.url) return Promise.resolve(session && session.shell);
|
||||
abortSessionRequest(session);
|
||||
setState(session, "loading");
|
||||
session.content.innerHTML = skeleton(session.options);
|
||||
session.controller = window.AbortController ? new window.AbortController() : null;
|
||||
|
||||
var requestOptions = {
|
||||
method: session.options.method || "GET",
|
||||
headers: session.options.headers || {},
|
||||
body: session.options.body,
|
||||
query: session.options.query,
|
||||
signal: session.controller ? session.controller.signal : undefined,
|
||||
};
|
||||
if (requestOptions.body === undefined) delete requestOptions.body;
|
||||
if (!requestOptions.signal) delete requestOptions.signal;
|
||||
|
||||
var request = window.AksrtRequest && typeof window.AksrtRequest.request === "function"
|
||||
? function () { return window.AksrtRequest.request(session.options.url, requestOptions); }
|
||||
: function () {
|
||||
return window.fetch(session.options.url, requestOptions).then(function (response) {
|
||||
return response.text().then(function (text) { return { ok: response.ok, status: response.status, text: text, json: null }; });
|
||||
});
|
||||
};
|
||||
|
||||
var method = String(session.options.method || "GET").toUpperCase();
|
||||
var cacheable = method === "GET" && session.options.cache !== false;
|
||||
var cacheKey = session.options.cacheKey ||
|
||||
method + ":" +
|
||||
(window.AksrtRequest && typeof window.AksrtRequest.url === "function"
|
||||
? window.AksrtRequest.url(session.options.url, session.options.query)
|
||||
: session.options.url);
|
||||
var cache = window.AksrtModalCache;
|
||||
var acquireOptions = {
|
||||
ttl: Number(session.options.cacheTtl || 15000),
|
||||
tags: session.options.cacheTags || session.options.tags || [],
|
||||
skipCache: !!loadOptions.force || !cacheable,
|
||||
store: cacheable,
|
||||
cancel: function () {
|
||||
if (session.controller) {
|
||||
try { session.controller.abort(); } catch (error) {}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
session.flight = cacheable && cache && typeof cache.acquire === "function"
|
||||
? cache.acquire(cacheKey, request, acquireOptions)
|
||||
: { promise: request(), release: function () {} };
|
||||
|
||||
return session.flight.promise.then(function (result) {
|
||||
if (!session.closed && active === session) renderResult(session, result);
|
||||
return session.shell;
|
||||
}).catch(function (error) {
|
||||
var aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
|
||||
if (!aborted && !session.closed && active === session) renderError(session, error);
|
||||
return session.shell;
|
||||
}).then(function (shell) {
|
||||
if (session.flight && typeof session.flight.release === "function") session.flight.release();
|
||||
session.flight = null;
|
||||
return shell;
|
||||
});
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
options = options || {};
|
||||
close("replace");
|
||||
var session = createSession(options);
|
||||
return session.shell;
|
||||
}
|
||||
|
||||
function sessionKey(options) {
|
||||
if (!options || !options.url) return "";
|
||||
var method = String(options.method || "GET").toUpperCase();
|
||||
var requestUrl = window.AksrtRequest && typeof window.AksrtRequest.url === "function"
|
||||
? window.AksrtRequest.url(options.url, options.query)
|
||||
: String(options.url);
|
||||
return method + ":" + requestUrl;
|
||||
}
|
||||
|
||||
function openRemote(options) {
|
||||
options = Object.assign({}, options || {});
|
||||
if (!options.url) return Promise.reject(new Error("missing url"));
|
||||
var sameRequest = active && active.flight && sessionKey(active.options) === sessionKey(options);
|
||||
close("replace", !!sameRequest);
|
||||
var session = createSession(options);
|
||||
return load(session).then(function () { return session.shell; });
|
||||
}
|
||||
|
||||
function refresh(options) {
|
||||
if (!active || !active.options.url) return Promise.resolve(null);
|
||||
if (options && typeof options === "object") active.options = Object.assign({}, active.options, options);
|
||||
return load(active, { force: true });
|
||||
}
|
||||
|
||||
function registerLoader(kind, loader) {
|
||||
if (!kind || typeof loader !== "function") return;
|
||||
loaders[String(kind)] = loader;
|
||||
}
|
||||
|
||||
function openLoader(kind, context) {
|
||||
var loader = loaders[String(kind)];
|
||||
if (!loader) return Promise.reject(new Error("missing modal loader: " + kind));
|
||||
return openRemote(loader(context || {}));
|
||||
}
|
||||
|
||||
function invalidate(tags) {
|
||||
if (window.AksrtModalCache && typeof window.AksrtModalCache.invalidate === "function") {
|
||||
return window.AksrtModalCache.invalidate(tags);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
window.AksrtModalRuntime = {
|
||||
open: open,
|
||||
openRemote: openRemote,
|
||||
close: close,
|
||||
refresh: refresh,
|
||||
registerLoader: registerLoader,
|
||||
openLoader: openLoader,
|
||||
invalidate: invalidate,
|
||||
getActive: function () { return active ? active.shell : null; },
|
||||
getState: function () { return active ? active.state : null; },
|
||||
};
|
||||
|
||||
document.dispatchEvent(new CustomEvent("aksrt:modal-runtime:ready"));
|
||||
})(window, document);
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared modal state vocabulary.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window) {
|
||||
"use strict";
|
||||
|
||||
var statuses = {
|
||||
idle: "idle",
|
||||
loading: "loading",
|
||||
ready: "ready",
|
||||
empty: "empty",
|
||||
submitting: "submitting",
|
||||
success: "success",
|
||||
error: "error",
|
||||
unauthorized: "unauthorized",
|
||||
stale: "stale",
|
||||
};
|
||||
|
||||
function create(kind) {
|
||||
return {
|
||||
kind: String(kind || "generic"),
|
||||
status: statuses.idle,
|
||||
updatedAt: 0,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
window.AksrtModalState = {
|
||||
statuses: statuses,
|
||||
create: create,
|
||||
};
|
||||
})(window);
|
||||
+57
-212
@@ -1,232 +1,77 @@
|
||||
/**
|
||||
* AKSRT Remote Modal — zibll RefreshModal parity.
|
||||
* AKSRT Remote Modal compatibility bridge.
|
||||
*
|
||||
* Open a center/bottom modal with static HTML or remote fetch.
|
||||
* Contract:
|
||||
* - data-toggle="AksrtRemoteModal" | data-aksrt-remote-modal
|
||||
* - data-remote / href → fetch HTML
|
||||
* - data-class → dialog class (modal-mini full-sm …)
|
||||
* - data-height → min-height hint
|
||||
* - mobile-bottom="true" → bottom sheet on narrow viewports
|
||||
* The lifecycle implementation lives in AksrtModalRuntime. This file keeps
|
||||
* the legacy trigger contract and the AksrtRemoteModal public alias stable.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
var ROOT_ID = "aksrt-remote-modal-root";
|
||||
var active = null;
|
||||
|
||||
function isMobile() {
|
||||
return window.matchMedia && window.matchMedia("(max-width: 768px)").matches;
|
||||
function runtime() {
|
||||
return window.AksrtModalRuntime || null;
|
||||
}
|
||||
|
||||
function ensureRoot() {
|
||||
var root = document.getElementById(ROOT_ID);
|
||||
if (root) return root;
|
||||
root = document.createElement("div");
|
||||
root.id = ROOT_ID;
|
||||
root.className = "aksrt-remote-modal-host";
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
function fromTrigger(element) {
|
||||
var modalRuntime = runtime();
|
||||
if (!modalRuntime || !element) return Promise.reject(new Error("modal runtime unavailable"));
|
||||
|
||||
function restHeaders() {
|
||||
var h = { Accept: "text/html, application/json" };
|
||||
if (window.aksrtData && aksrtData.restNonce) {
|
||||
h["X-WP-Nonce"] = aksrtData.restNonce;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
var url = element.getAttribute("data-remote") ||
|
||||
element.getAttribute("data-aksrt-remote") ||
|
||||
element.getAttribute("href") || "";
|
||||
if (!url || url === "#") return Promise.resolve(null);
|
||||
|
||||
function lockScroll(lock) {
|
||||
document.documentElement.classList.toggle("aksrt-remote-modal-open", !!lock);
|
||||
document.body.classList.toggle("aksrt-remote-modal-open", !!lock);
|
||||
}
|
||||
var ttl = parseInt(element.getAttribute("data-modal-cache-ttl") || "15000", 10);
|
||||
var tags = (element.getAttribute("data-modal-cache-tags") || "").split(",").map(function (tag) {
|
||||
return tag.trim();
|
||||
}).filter(Boolean);
|
||||
|
||||
function close() {
|
||||
if (!active) return;
|
||||
active.classList.remove("is-open");
|
||||
lockScroll(false);
|
||||
var node = active;
|
||||
active = null;
|
||||
window.setTimeout(function () {
|
||||
if (node && node.parentNode) node.parentNode.removeChild(node);
|
||||
}, 220);
|
||||
document.dispatchEvent(new CustomEvent("aksrt:remote-modal:close"));
|
||||
}
|
||||
|
||||
function bindChrome(shell) {
|
||||
shell.addEventListener("click", function (e) {
|
||||
if (e.target.closest("[data-aksrt-remote-modal-close], [data-dismiss='modal'], .aksrt-remote-modal__close")) {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.target === shell || e.target.classList.contains("aksrt-remote-modal__overlay")) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
shell.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") close();
|
||||
});
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
options = options || {};
|
||||
close();
|
||||
|
||||
var root = ensureRoot();
|
||||
var shell = document.createElement("div");
|
||||
var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile());
|
||||
shell.className =
|
||||
"aksrt-remote-modal" +
|
||||
(bottom ? " aksrt-remote-modal--bottom" : "") +
|
||||
(options.className ? " " + options.className : "");
|
||||
shell.setAttribute("role", "dialog");
|
||||
shell.setAttribute("aria-modal", "true");
|
||||
|
||||
var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : "";
|
||||
shell.innerHTML =
|
||||
'<div class="aksrt-remote-modal__overlay" data-aksrt-remote-modal-close></div>' +
|
||||
'<div class="aksrt-remote-modal__dialog" style="' + heightStyle + '">' +
|
||||
'<div class="aksrt-remote-modal__content">' +
|
||||
(options.html || "") +
|
||||
"</div></div>";
|
||||
|
||||
root.appendChild(shell);
|
||||
bindChrome(shell);
|
||||
active = shell;
|
||||
lockScroll(true);
|
||||
|
||||
// Force reflow then open for CSS transition.
|
||||
void shell.offsetWidth;
|
||||
shell.classList.add("is-open");
|
||||
|
||||
var focusable = shell.querySelector(
|
||||
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
|
||||
);
|
||||
if (focusable) {
|
||||
try {
|
||||
focusable.focus();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("aksrt:remote-modal:open", { detail: { shell: shell, options: options } })
|
||||
);
|
||||
return shell;
|
||||
}
|
||||
|
||||
function openRemote(options) {
|
||||
options = options || {};
|
||||
var url = options.url || "";
|
||||
if (!url) return Promise.reject(new Error("missing url"));
|
||||
|
||||
var loading = open({
|
||||
html:
|
||||
'<div class="aksrt-remote-modal__loading"><i class="fas fa-spinner fa-spin"></i> ' +
|
||||
((window.aksrtData && aksrtData.ajaxLoading) || "加载中…") +
|
||||
"</div>",
|
||||
className: options.className || "modal-mini full-sm",
|
||||
bottom: options.bottom,
|
||||
mobileBottom: options.mobileBottom,
|
||||
height: options.height,
|
||||
});
|
||||
|
||||
return fetch(url, {
|
||||
method: options.method || "GET",
|
||||
credentials: "same-origin",
|
||||
headers: Object.assign(restHeaders(), options.headers || {}),
|
||||
body: options.body,
|
||||
})
|
||||
.then(function (res) {
|
||||
var type = (res.headers.get("content-type") || "").toLowerCase();
|
||||
if (type.indexOf("application/json") !== -1) {
|
||||
return res.json().then(function (json) {
|
||||
if (!res.ok || (json && json.success === false)) {
|
||||
var msg =
|
||||
(json && (json.message || (json.data && json.data.message))) ||
|
||||
"加载失败";
|
||||
throw new Error(msg);
|
||||
}
|
||||
var html =
|
||||
(json && (json.html || (json.data && (json.data.html || json.data)))) || "";
|
||||
if (typeof html !== "string") html = "";
|
||||
return html;
|
||||
});
|
||||
}
|
||||
return res.text().then(function (text) {
|
||||
if (!res.ok) throw new Error("加载失败");
|
||||
return text;
|
||||
});
|
||||
})
|
||||
.then(function (html) {
|
||||
if (loading && loading.parentNode) loading.parentNode.removeChild(loading);
|
||||
if (active === loading) active = null;
|
||||
return open({
|
||||
html: html,
|
||||
className: options.className || "modal-mini full-sm",
|
||||
bottom: options.bottom,
|
||||
mobileBottom: options.mobileBottom,
|
||||
height: options.height,
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (loading && loading.parentNode) loading.parentNode.removeChild(loading);
|
||||
if (active === loading) active = null;
|
||||
open({
|
||||
html:
|
||||
'<div class="aksrt-remote-modal__error touch border-title"><b>提示</b></div>' +
|
||||
'<div class="aksrt-remote-modal__error-body">' +
|
||||
(err && err.message ? String(err.message) : "加载失败") +
|
||||
'</div><div class="but-average modal-buts"><button type="button" class="but" data-aksrt-remote-modal-close>关闭</button></div>',
|
||||
className: "modal-mini",
|
||||
mobileBottom: true,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
function fromTrigger(el) {
|
||||
if (!el) return null;
|
||||
var url =
|
||||
el.getAttribute("data-remote") ||
|
||||
el.getAttribute("data-aksrt-remote") ||
|
||||
el.getAttribute("href") ||
|
||||
"";
|
||||
if (!url || url === "#") return null;
|
||||
var height = el.getAttribute("data-height") || el.getAttribute("data-aksrt-height");
|
||||
return openRemote({
|
||||
return modalRuntime.openRemote({
|
||||
url: url,
|
||||
className: el.getAttribute("data-class") || el.getAttribute("data-aksrt-class") || "modal-mini full-sm",
|
||||
height: height ? parseInt(height, 10) : 0,
|
||||
mobileBottom: el.getAttribute("mobile-bottom") !== "false",
|
||||
bottom: el.getAttribute("data-bottom") === "true",
|
||||
method: element.getAttribute("data-method") || "GET",
|
||||
className: element.getAttribute("data-class") || element.getAttribute("data-aksrt-class") || "modal-mini full-sm",
|
||||
height: parseInt(element.getAttribute("data-height") || element.getAttribute("data-aksrt-height") || "0", 10) || 0,
|
||||
mobileBottom: element.getAttribute("mobile-bottom") !== "false",
|
||||
bottom: element.getAttribute("data-bottom") === "true",
|
||||
kind: element.getAttribute("data-modal-kind") || "generic",
|
||||
cacheKey: element.getAttribute("data-modal-cache-key") || "",
|
||||
cacheTtl: ttl > 0 ? ttl : 15000,
|
||||
cacheTags: tags,
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
var trigger = e.target.closest(
|
||||
'[data-toggle="AksrtRemoteModal"], [data-toggle="RefreshModal"], [data-aksrt-remote-modal], a.aksrt-remote-modal-link'
|
||||
);
|
||||
if (!trigger) return;
|
||||
if (trigger.getAttribute("data-aksrt-remote-modal") === "off") return;
|
||||
e.preventDefault();
|
||||
fromTrigger(trigger);
|
||||
});
|
||||
function bind() {
|
||||
if (window.__aksrtRemoteModalBound) return;
|
||||
if (!runtime()) return;
|
||||
window.__aksrtRemoteModalBound = true;
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && active) close();
|
||||
});
|
||||
document.addEventListener("click", function (event) {
|
||||
var trigger = event.target.closest(
|
||||
'[data-toggle="AksrtRemoteModal"], [data-toggle="RefreshModal"], [data-aksrt-remote-modal], a.aksrt-remote-modal-link'
|
||||
);
|
||||
if (!trigger || trigger.getAttribute("data-aksrt-remote-modal") === "off") return;
|
||||
event.preventDefault();
|
||||
fromTrigger(trigger);
|
||||
});
|
||||
}
|
||||
|
||||
window.AksrtRemoteModal = {
|
||||
open: open,
|
||||
openRemote: openRemote,
|
||||
close: close,
|
||||
fromTrigger: fromTrigger,
|
||||
getActive: function () {
|
||||
return active;
|
||||
},
|
||||
};
|
||||
function expose() {
|
||||
var modalRuntime = runtime();
|
||||
if (!modalRuntime) return;
|
||||
window.AksrtRemoteModal = {
|
||||
open: modalRuntime.open,
|
||||
openRemote: modalRuntime.openRemote,
|
||||
close: modalRuntime.close,
|
||||
refresh: modalRuntime.refresh,
|
||||
fromTrigger: fromTrigger,
|
||||
getActive: modalRuntime.getActive,
|
||||
};
|
||||
bind();
|
||||
}
|
||||
|
||||
expose();
|
||||
if (!window.AksrtModalRuntime) {
|
||||
document.addEventListener("aksrt:modal-runtime:ready", expose, { once: true });
|
||||
}
|
||||
})(window, document);
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* AKSRT frontend request adapter.
|
||||
*
|
||||
* Owns the injected REST root, nonce headers, response envelopes, and
|
||||
* abort-aware error normalization used by modal loaders.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function (window) {
|
||||
"use strict";
|
||||
|
||||
function data() {
|
||||
return window.aksrtData || window.aksrt || {};
|
||||
}
|
||||
|
||||
function restBase() {
|
||||
var config = data();
|
||||
var raw = config.restUrl || config.apiUrl || config.rest_url || "";
|
||||
var siteUrl = config.siteUrl || config.homeUrl || (window.location.origin + "/");
|
||||
|
||||
if (window.location.protocol === "https:" && siteUrl.substring(0, 5) === "http:") {
|
||||
siteUrl = "https:" + siteUrl.substring(5);
|
||||
}
|
||||
|
||||
if (raw) return String(raw).replace(/\/$/, "");
|
||||
return String(siteUrl).replace(/\/$/, "") + "/index.php?rest_route=/aksrt/v1";
|
||||
}
|
||||
|
||||
function appendQuery(url, query) {
|
||||
if (!query) return url;
|
||||
var parts = [];
|
||||
|
||||
if (typeof query === "string") {
|
||||
parts = query.replace(/^\?/, "").split("&").filter(Boolean);
|
||||
} else if (typeof query === "object") {
|
||||
Object.keys(query).forEach(function (key) {
|
||||
if (query[key] === undefined || query[key] === null) return;
|
||||
parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(query[key])));
|
||||
});
|
||||
}
|
||||
|
||||
if (!parts.length) return url;
|
||||
return url + (url.indexOf("?") >= 0 ? "&" : "?") + parts.join("&");
|
||||
}
|
||||
|
||||
function url(path, query) {
|
||||
path = String(path || "");
|
||||
var inlineQuery = "";
|
||||
var queryIndex = path.indexOf("?");
|
||||
|
||||
if (queryIndex >= 0) {
|
||||
inlineQuery = path.slice(queryIndex + 1);
|
||||
path = path.slice(0, queryIndex);
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return appendQuery(path, query || inlineQuery);
|
||||
}
|
||||
|
||||
if (path.charAt(0) !== "/") path = "/" + path;
|
||||
var base = restBase();
|
||||
var result = base.indexOf("rest_route=") >= 0 ? base + path : base + path;
|
||||
return appendQuery(result, query || inlineQuery);
|
||||
}
|
||||
|
||||
function headers(options) {
|
||||
var config = data();
|
||||
var source = (options && options.headers) || {};
|
||||
var result = {};
|
||||
var nonce = config.restNonce || config.nonce || "";
|
||||
|
||||
Object.keys(source).forEach(function (key) {
|
||||
result[key] = source[key];
|
||||
});
|
||||
|
||||
if (!result.Accept) result.Accept = "application/json, text/html;q=0.9, */*;q=0.8";
|
||||
if (nonce && !result["X-WP-Nonce"]) result["X-WP-Nonce"] = nonce;
|
||||
|
||||
if (options && options.body !== undefined && options.body !== null &&
|
||||
!(window.FormData && options.body instanceof window.FormData) &&
|
||||
!result["Content-Type"]) {
|
||||
result["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function responseError(result) {
|
||||
var json = result.json;
|
||||
var message = json && (json.message || (json.data && json.data.message));
|
||||
var error = new Error(message || (result.status ? "请求失败(HTTP " + result.status + ")" : "请求失败"));
|
||||
error.status = result.status;
|
||||
error.code = result.status === 401 ? "unauthorized" : "http_error";
|
||||
error.response = result;
|
||||
error.json = json;
|
||||
return error;
|
||||
}
|
||||
|
||||
function parseResponse(response) {
|
||||
return response.text().then(function (text) {
|
||||
var type = (response.headers.get("content-type") || "").toLowerCase();
|
||||
var json = null;
|
||||
var trimmed = String(text || "").trim();
|
||||
|
||||
if (type.indexOf("application/json") >= 0 || trimmed.charAt(0) === "{" || trimmed.charAt(0) === "[") {
|
||||
try {
|
||||
json = trimmed ? JSON.parse(trimmed) : {};
|
||||
} catch (error) {
|
||||
json = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
text: text,
|
||||
json: json,
|
||||
data: json && json.data !== undefined ? json.data : json,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function request(path, options) {
|
||||
options = Object.assign({}, options || {});
|
||||
var requestUrl = url(path, options.query);
|
||||
delete options.query;
|
||||
|
||||
var fetchOptions = Object.assign({}, options, {
|
||||
credentials: options.credentials || "same-origin",
|
||||
headers: headers(options),
|
||||
});
|
||||
|
||||
return window.fetch(requestUrl, fetchOptions).then(function (response) {
|
||||
return parseResponse(response);
|
||||
}).then(function (result) {
|
||||
if (!result.ok || (result.json && result.json.success === false)) {
|
||||
throw responseError(result);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
window.AksrtRequest = {
|
||||
url: url,
|
||||
request: request,
|
||||
get: function (path, options) {
|
||||
return request(path, Object.assign({}, options || {}, { method: "GET" }));
|
||||
},
|
||||
post: function (path, body, options) {
|
||||
return request(path, Object.assign({}, options || {}, {
|
||||
method: "POST",
|
||||
body: body,
|
||||
}));
|
||||
},
|
||||
};
|
||||
})(window);
|
||||
Reference in new issue
Block a user