833 lines
46 KiB
JavaScript
833 lines
46 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const qs = (selector, root = document) => root.querySelector(selector);
|
|
const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector));
|
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
const MODULES = ["site", "navigation", "composer", "threads", "recommendations", "activity"];
|
|
const MODULE_META = {
|
|
site: ["站点信息", "品牌、主题与当前账号"],
|
|
navigation: ["导航", "信息流、搜索与移动导航"],
|
|
composer: ["发布器", "发帖弹窗和字数限制"],
|
|
threads: ["串文", "主串文、引用与嵌套回复"],
|
|
recommendations: ["推荐", "关注建议与热门话题"],
|
|
activity: ["动态", "互动通知面板"]
|
|
};
|
|
const embeddedData = JSON.parse(qs("#threadsPageData").textContent);
|
|
const initialData = clone(embeddedData);
|
|
|
|
const fingerprint = (text) => {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
hash ^= text.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
};
|
|
const pageFingerprint = fingerprint(JSON.stringify(initialData));
|
|
const storageKey = "threads-page-data-v1:" + pageFingerprint;
|
|
const themeKey = "threads-page-theme-v1:" + pageFingerprint;
|
|
|
|
const readStorage = (key) => {
|
|
try {
|
|
return window.localStorage ? window.localStorage.getItem(key) : null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
};
|
|
const writeStorage = (key, value) => {
|
|
try {
|
|
if (window.localStorage) window.localStorage.setItem(key, value);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
};
|
|
const removeStorage = (key) => {
|
|
try {
|
|
if (window.localStorage) window.localStorage.removeItem(key);
|
|
} catch (_error) {
|
|
/* Opaque sandbox previews may intentionally deny storage. */
|
|
}
|
|
};
|
|
const isObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
const isCompleteData = (value) => Boolean(isObject(value) && MODULES.every((name) => isObject(value[name])));
|
|
const cachedData = readStorage(storageKey);
|
|
let pageData = initialData;
|
|
if (cachedData) {
|
|
try {
|
|
const parsed = JSON.parse(cachedData);
|
|
if (isCompleteData(parsed)) pageData = parsed;
|
|
} catch (_error) {
|
|
pageData = initialData;
|
|
}
|
|
}
|
|
|
|
const escapeHTML = (value) => String(value == null ? "" : value).replace(/[&<>'"]/g, (character) => ({
|
|
"&": "&", "<": "<", ">": ">", "'": "'", '"': """
|
|
})[character]);
|
|
const safeRemoteUrl = (value, kind = "link") => {
|
|
const source = String(value || "").trim();
|
|
if (!source) return "";
|
|
const lowered = source.toLowerCase();
|
|
if (kind === "image" && lowered.startsWith("data:image/")) return source;
|
|
if (kind === "video" && lowered.startsWith("data:video/")) return source;
|
|
try {
|
|
const url = new URL(source);
|
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : "";
|
|
} catch (_error) { return ""; }
|
|
};
|
|
const validateRemoteMediaTree = (value, path = "当前模块") => {
|
|
if (Array.isArray(value)) return value.forEach((item, index) => validateRemoteMediaTree(item, path + "[" + index + "]"));
|
|
if (!value || typeof value !== "object") return;
|
|
const media = value.media;
|
|
if (media && ["image", "video", "webpage"].includes(media.type)) {
|
|
if ((media.type === "image" || media.type === "video") && !safeRemoteUrl(media.mediaUrl, media.type)) throw new Error(path + ".media.mediaUrl 无效");
|
|
if (media.type === "webpage" && !safeRemoteUrl(media.pageUrl)) throw new Error(path + ".media.pageUrl 无效");
|
|
if (media.posterUrl && !safeRemoteUrl(media.posterUrl, "image")) throw new Error(path + ".media.posterUrl 无效");
|
|
}
|
|
Object.entries(value).forEach(([key, item]) => { if (key !== "media") validateRemoteMediaTree(item, path + "." + key); });
|
|
};
|
|
const safeColor = (value, fallback) => /^#[0-9a-f]{6}$/i.test(String(value || "")) ? value : fallback;
|
|
const icon = (id) => '<svg aria-hidden="true"><use href="#' + id + '"/></svg>';
|
|
const colors = (person) => {
|
|
const list = person && Array.isArray(person.colors) ? person.colors : [];
|
|
return [safeColor(list[0], "#7257f5"), safeColor(list[1], "#d94fbd")];
|
|
};
|
|
const avatar = (person, className) => {
|
|
const pair = colors(person || {});
|
|
return '<span class="avatar ' + (className || "") + '" style="--a:' + pair[0] + ";--b:" + pair[1] + '">' + escapeHTML((person && person.initial) || "T") + "</span>";
|
|
};
|
|
const metric = (value) => {
|
|
const number = Math.max(0, Number(value) || 0);
|
|
return number ? '<span class="action-count">' + number.toLocaleString("zh-CN") + "</span>" : "";
|
|
};
|
|
const toast = qs("#toast");
|
|
let toastTimer = 0;
|
|
const showToast = (message) => {
|
|
toast.textContent = message;
|
|
toast.classList.add("show");
|
|
window.clearTimeout(toastTimer);
|
|
toastTimer = window.setTimeout(() => toast.classList.remove("show"), 2800);
|
|
};
|
|
const saveData = () => writeStorage(storageKey, JSON.stringify(pageData));
|
|
|
|
let themeInitialized = false;
|
|
const syncThemeButtons = () => {
|
|
const dark = document.documentElement.dataset.theme === "dark";
|
|
[qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => {
|
|
button.innerHTML = icon(dark ? "i-sun" : "i-moon");
|
|
button.setAttribute("aria-label", dark ? "切换浅色模式" : "切换深色模式");
|
|
});
|
|
qs('meta[name="theme-color"]').content = dark ? "#0a0a0a" : "#f5f5f3";
|
|
};
|
|
const applyInitialTheme = () => {
|
|
if (themeInitialized) return;
|
|
themeInitialized = true;
|
|
const configured = pageData.site.theme;
|
|
const stored = readStorage(themeKey);
|
|
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme:dark)").matches;
|
|
const next = configured === "dark" || configured === "light"
|
|
? configured
|
|
: (stored === "dark" || stored === "light" ? stored : (prefersDark ? "dark" : "light"));
|
|
document.documentElement.dataset.theme = next;
|
|
syncThemeButtons();
|
|
};
|
|
const toggleTheme = () => {
|
|
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
|
document.documentElement.dataset.theme = next;
|
|
writeStorage(themeKey, next);
|
|
syncThemeButtons();
|
|
};
|
|
|
|
const renderSite = () => {
|
|
const site = pageData.site || {};
|
|
const user = site.currentUser || {};
|
|
document.title = site.pageTitle || site.brand || "Threads";
|
|
qs('meta[name="description"]').content = site.description || "";
|
|
qs("#brandText").textContent = site.brand || "Threads";
|
|
qs("#brandLink").setAttribute("aria-label", (site.brand || "Threads") + " 首页");
|
|
qs("#mobileUser").innerHTML = avatar(user, "avatar-sm");
|
|
qs("#composerUser").innerHTML = avatar(user);
|
|
qs("#modalUser").innerHTML = avatar(user);
|
|
qs("#modalUserName").textContent = user.name || "";
|
|
const stats = user.stats || {};
|
|
qs("#profileSummary").innerHTML =
|
|
'<div class="profile-summary">' + avatar(user) +
|
|
'<span class="profile-copy"><strong>' + escapeHTML(user.name) + "</strong><span>" + escapeHTML(user.handle) +
|
|
'</span></span><button class="follow-button following">主页</button></div>' +
|
|
'<div class="profile-stats"><div><strong>' + escapeHTML(stats.following) + '</strong><span>关注</span></div><div><strong>' +
|
|
escapeHTML(stats.followers) + '</strong><span>关注者</span></div><div><strong>' + escapeHTML(stats.threads) +
|
|
"</strong><span>串聊</span></div></div>";
|
|
applyInitialTheme();
|
|
};
|
|
|
|
const renderNavigation = () => {
|
|
const navigation = pageData.navigation || {};
|
|
qs("#feedTitle").textContent = navigation.feedTitle || "首页";
|
|
qs("#feedFilter").textContent = navigation.feedFilter || "为你推荐";
|
|
qs("#searchInput").placeholder = navigation.searchPlaceholder || "搜索用户或关键词";
|
|
qs("#searchHints").textContent = navigation.searchHints || "";
|
|
const tabs = Array.isArray(navigation.tabs) ? navigation.tabs : [];
|
|
qs("#feedTabs").innerHTML = tabs.map((label, index) =>
|
|
'<button class="feed-tab' + (index === 0 ? " active" : "") + '">' + escapeHTML(label) + "</button>"
|
|
).join("");
|
|
const labels = Array.isArray(navigation.mobileLabels) ? navigation.mobileLabels : [];
|
|
const items = [
|
|
["i-home", ""],
|
|
["i-search", ' data-panel="searchPanel"'],
|
|
["i-plus", " data-open-composer"],
|
|
["i-heart", ' data-panel="activityPanel"'],
|
|
["i-user", ""]
|
|
];
|
|
qs("#mobileNav").innerHTML = items.map((item, index) => {
|
|
const label = labels[index] || "";
|
|
if (index === 2) return '<button' + item[1] + ' aria-label="' + escapeHTML(label) + '"><span class="mobile-create">' + icon(item[0]) + '</span><span class="sr-only">' + escapeHTML(label) + "</span></button>";
|
|
return '<button class="' + (index === 0 ? "active" : "") + '"' + item[1] + ' aria-label="' + escapeHTML(label) + '">' + icon(item[0]) + "<span>" + escapeHTML(label) + "</span></button>";
|
|
}).join("");
|
|
const links = Array.isArray(navigation.footerLinks) ? navigation.footerLinks : [];
|
|
qs("#sideFooter").innerHTML = links.map((label) => '<a href="#footer">' + escapeHTML(label) + "</a>").join("") +
|
|
"<span>" + escapeHTML(navigation.copyright) + "</span>";
|
|
};
|
|
|
|
const renderComposer = () => {
|
|
const composer = pageData.composer || {};
|
|
qs("#composerButton").textContent = composer.inlinePlaceholder || "";
|
|
qs("#inlinePublish").textContent = composer.publishLabel || "发布";
|
|
qs("#composeTitle").textContent = composer.dialogTitle || "新串聊";
|
|
qs("#composeText").placeholder = composer.textareaPlaceholder || "";
|
|
qs("#composeText").maxLength = Math.max(1, Number(composer.maxLength) || 500);
|
|
qs("#modalPublish").textContent = composer.publishLabel || "发布";
|
|
qs("#charCount").textContent = String(qs("#composeText").maxLength - qs("#composeText").value.length);
|
|
};
|
|
|
|
const autumnGrid = (label) => '<div class="image-grid" role="img" aria-label="' + escapeHTML(label) + '">' +
|
|
'<div class="main-image"><svg viewBox="0 0 400 520" preserveAspectRatio="xMidYMid slice"><defs><linearGradient id="threadAutumn1" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#a9c9de"/><stop offset="1" stop-color="#f2c096"/></linearGradient></defs><rect width="400" height="520" fill="url(#threadAutumn1)"/><circle cx="300" cy="105" r="48" fill="#fff0bf"/><path d="M0 350c100-50 210-20 400-60v230H0Z" fill="#6b7f63"/><path d="M210 520V205m0 42-92-88m92 120 108-110" stroke="#62493b" stroke-width="20" stroke-linecap="round"/><g fill="#d77b50"><circle cx="108" cy="148" r="70"/><circle cx="186" cy="115" r="82"/><circle cx="292" cy="138" r="68"/><circle cx="350" cy="205" r="54"/></g></svg></div>' +
|
|
'<div class="side-image"><svg viewBox="0 0 220 220"><rect width="220" height="220" fill="#e6b16d"/><circle cx="55" cy="60" r="38" fill="#c55c50"/><circle cx="130" cy="100" r="58" fill="#e37a4e"/><path d="M110 220V105" stroke="#553d35" stroke-width="16"/><path d="M0 190c80-30 150-10 220-40v70H0Z" fill="#736a4d"/></svg></div>' +
|
|
'<div class="side-image"><svg viewBox="0 0 220 220"><rect width="220" height="220" fill="#adc5ca"/><path d="M0 160 65 110l55 34 46-70 54 66v80H0Z" fill="#5b776e"/><circle cx="47" cy="55" r="28" fill="#ffe0a1"/><path d="M40 190c50-35 95-20 150-55" stroke="#eee0bd" stroke-width="12" fill="none" stroke-linecap="round"/></svg></div></div>';
|
|
|
|
document.head.insertAdjacentHTML("beforeend", '<style data-external-media>.thread-media{position:relative;margin-top:12px;overflow:hidden;border:1px solid var(--border);border-radius:14px;background:#000}.thread-media>img,.thread-media>video,.thread-media>iframe{width:100%;max-height:520px;display:block;border:0;object-fit:cover}.thread-media>iframe{height:360px}.thread-media>a.external-open{position:absolute;right:10px;bottom:10px;padding:7px 11px;border-radius:999px;color:#fff;background:rgba(0,0,0,.72);text-decoration:none;font-size:12px;font-weight:700}.thread-page>a{display:block;padding:13px 14px;color:var(--text);background:var(--surface);text-decoration:none}.thread-page>a strong,.thread-page>a span{display:block}.thread-page>a span{margin-top:4px;color:var(--muted);font-size:13px}</style>');
|
|
const mediaMarkup = (media) => {
|
|
if (!isObject(media)) return "";
|
|
if (media.type === "autumn-grid") return autumnGrid(media.label);
|
|
if (media.type === "image") {
|
|
const source = safeRemoteUrl(media.mediaUrl, "image");
|
|
if (!source) return "";
|
|
const pageUrl = safeRemoteUrl(media.pageUrl);
|
|
return '<div class="thread-media"><img src="' + escapeHTML(source) + '" alt="' + escapeHTML(media.alt || media.label || "串文图片") + '" loading="lazy" referrerpolicy="no-referrer"/>' + (pageUrl ? '<a class="external-open" href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer">打开来源</a>' : "") + "</div>";
|
|
}
|
|
if (media.type === "video") {
|
|
const source = safeRemoteUrl(media.mediaUrl, "video");
|
|
if (!source) return "";
|
|
const poster = safeRemoteUrl(media.posterUrl, "image");
|
|
const pageUrl = safeRemoteUrl(media.pageUrl);
|
|
return '<div class="thread-media"><video src="' + escapeHTML(source) + '"' + (poster ? ' poster="' + escapeHTML(poster) + '"' : "") + ' controls playsinline preload="metadata" aria-label="' + escapeHTML(media.alt || "串文视频") + '"></video>' + (pageUrl ? '<a class="external-open" href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer">打开来源</a>' : "") + "</div>";
|
|
}
|
|
if (media.type === "webpage") {
|
|
const pageUrl = safeRemoteUrl(media.pageUrl);
|
|
if (!pageUrl) return "";
|
|
const preview = safeRemoteUrl(media.mediaUrl, "image");
|
|
const body = media.embed === true ? '<iframe src="' + escapeHTML(pageUrl) + '" title="' + escapeHTML(media.title || "网页预览") + '" loading="lazy" referrerpolicy="no-referrer" sandbox="allow-forms allow-popups allow-scripts"></iframe>' : (preview ? '<img src="' + escapeHTML(preview) + '" alt="' + escapeHTML(media.alt || media.title || "网页预览图") + '" loading="lazy" referrerpolicy="no-referrer"/>' : "");
|
|
return '<div class="thread-media thread-page">' + body + '<a href="' + escapeHTML(pageUrl) + '" target="_blank" rel="noopener noreferrer"><strong>' + escapeHTML(media.title || pageUrl) + '</strong><span>' + escapeHTML(media.description || "打开网页") + "</span></a></div>";
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const actionsMarkup = (metrics) => '<div class="post-actions">' +
|
|
'<button class="post-action like-action" aria-label="点赞">' + icon("i-heart") + metric(metrics.likes) + "</button>" +
|
|
'<button class="post-action reply-action" aria-label="回复">' + icon("i-comment") + metric(metrics.replies) + "</button>" +
|
|
'<button class="post-action repost-action" aria-label="转发">' + icon("i-repost") + metric(metrics.reposts) + "</button>" +
|
|
'<button class="post-action share" aria-label="分享">' + icon("i-share") + "</button></div>";
|
|
|
|
const quoteMarkup = (quote) => {
|
|
if (!isObject(quote)) return "";
|
|
return '<div class="quote-card"><div class="quote-head">' + avatar(quote, "avatar-xs") + "<strong>" +
|
|
escapeHTML(quote.name) + '</strong><span class="handle-time">' + escapeHTML(quote.handle) +
|
|
"</span></div><p>" + escapeHTML(quote.text) + "</p></div>";
|
|
};
|
|
const rowMarkup = (thread, reply, hasNext) => {
|
|
const person = reply ? reply.author : thread.author;
|
|
const metrics = reply ? reply.metrics : thread.metrics;
|
|
const time = reply ? "· " + reply.time : escapeHTML(person.handle) + " · " + escapeHTML(thread.time);
|
|
return '<div class="thread-row' + (hasNext ? " has-next" : "") + '"><div class="avatar-column">' +
|
|
avatar(person, reply ? "avatar-sm" : "avatar-lg") + '</div><div class="post-body"><header class="post-head"><strong>' +
|
|
escapeHTML(person.name) + "</strong>" + (!reply && person.verified ? '<svg class="verified"><use href="#i-verified"/></svg>' : "") +
|
|
'<span class="handle-time">' + time + '</span><span class="head-spacer"></span>' +
|
|
(!reply && thread.followable ? '<button class="follow-button" data-follow>关注</button>' : "") +
|
|
(!reply ? '<button class="more-button" aria-label="更多操作">' + icon("i-more") + "</button>" : "") +
|
|
'</header><p class="post-text">' + escapeHTML(reply ? reply.text : thread.text) + "</p>" +
|
|
(!reply ? mediaMarkup(thread.media) : "") +
|
|
(!reply ? quoteMarkup(thread.quote) : "") + actionsMarkup(metrics || {}) +
|
|
(!reply ? '<form class="reply-form"><label class="sr-only">回复内容</label><input maxlength="' +
|
|
Math.max(1, Number(pageData.composer.replyMaxLength) || 180) + '" placeholder="回复' + escapeHTML(person.name) +
|
|
'…" /><button class="reply-send" aria-label="发送回复">' + icon("i-send") + "</button></form>" : "") +
|
|
"</div></div>";
|
|
};
|
|
|
|
const threadMarkup = (thread) => {
|
|
const replies = Array.isArray(thread.replies) ? thread.replies : [];
|
|
const search = [thread.author && thread.author.name, thread.author && thread.author.handle, thread.text, thread.preview]
|
|
.concat(replies.map((reply) => reply.text)).join(" ");
|
|
return '<article class="card thread-card" data-thread-id="' + escapeHTML(thread.id) + '" data-search="' + escapeHTML(search) + '">' +
|
|
rowMarkup(thread, null, replies.length > 0) + replies.map((reply, index) => rowMarkup(thread, reply, index < replies.length - 1)).join("") +
|
|
'<div class="reply-preview"><span class="stacked-avatars">' +
|
|
replies.slice(0, 3).map((reply) => avatar(reply.author, "avatar-xs")).join("") +
|
|
'</span><span>' + escapeHTML(thread.preview) + '</span></div><div class="post-menu"><button>' +
|
|
icon("i-bookmark") + "收藏</button><button>" + icon("i-eye-off") + "减少此类内容</button></div></article>";
|
|
};
|
|
|
|
const renderThreads = () => {
|
|
const data = pageData.threads || {};
|
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
qs("#threadList").innerHTML = items.map(threadMarkup).join("");
|
|
qs("#emptyTitle").textContent = data.emptyTitle || "";
|
|
qs("#emptyText").textContent = data.emptyText || "";
|
|
qs("#emptyState").style.display = items.length ? "none" : "block";
|
|
};
|
|
|
|
const renderRecommendations = () => {
|
|
const data = pageData.recommendations || {};
|
|
qs("#followTitle").textContent = data.followTitle || "";
|
|
qs("#suggestions").innerHTML = (Array.isArray(data.people) ? data.people : []).map((person) =>
|
|
'<div class="suggest-row">' + avatar(person, "avatar-sm") + '<span class="suggest-copy"><strong>' +
|
|
escapeHTML(person.name) + "</strong><span>" + escapeHTML(person.handle) +
|
|
'</span></span><button class="follow-button" data-follow>关注</button></div>'
|
|
).join("");
|
|
qs("#topicTitle").textContent = data.topicTitle || "";
|
|
qs("#topics").innerHTML = (Array.isArray(data.topics) ? data.topics : []).map((topic) =>
|
|
'<div class="topic"><span>' + escapeHTML(topic.meta) + "</span><strong>" + escapeHTML(topic.title) +
|
|
"</strong><small>" + escapeHTML(topic.count) + "</small></div>"
|
|
).join("");
|
|
};
|
|
|
|
const renderActivity = () => {
|
|
const data = pageData.activity || {};
|
|
qs("#activityTitle").textContent = data.title || "";
|
|
qs("#activityList").innerHTML = (Array.isArray(data.items) ? data.items : []).map((item) =>
|
|
'<div class="activity-row">' + avatar(item, "avatar-sm") + '<span class="activity-copy"><p><strong>' +
|
|
escapeHTML(item.name) + "</strong> " + escapeHTML(item.text) + "</p><time>" + escapeHTML(item.time) +
|
|
"</time></span>" + (item.followable ? '<button class="follow-button" data-follow>关注</button>' : "") + "</div>"
|
|
).join("");
|
|
};
|
|
|
|
const renderAll = () => {
|
|
renderSite();
|
|
renderNavigation();
|
|
renderComposer();
|
|
renderThreads();
|
|
renderRecommendations();
|
|
renderActivity();
|
|
bindDynamicInteractions();
|
|
};
|
|
|
|
const applySearch = () => {
|
|
const query = qs("#searchInput").value.trim().toLocaleLowerCase();
|
|
let visible = 0;
|
|
qsa(".thread-card").forEach((thread) => {
|
|
const match = !query || thread.dataset.search.toLocaleLowerCase().includes(query) || thread.textContent.toLocaleLowerCase().includes(query);
|
|
thread.hidden = !match;
|
|
if (match) visible += 1;
|
|
});
|
|
qs("#emptyState").style.display = visible ? "none" : "block";
|
|
};
|
|
|
|
const changeCount = (button, delta) => {
|
|
const count = qs(".action-count", button);
|
|
if (count) count.textContent = String(Math.max(0, (Number(count.textContent.replace(/,/g, "")) || 0) + delta));
|
|
};
|
|
const bindDynamicInteractions = () => {
|
|
qsa("[data-follow]").forEach((button) => button.addEventListener("click", () => {
|
|
const following = button.classList.toggle("following");
|
|
button.textContent = following ? "正在关注" : "关注";
|
|
showToast(following ? "已关注" : "已取消关注");
|
|
}));
|
|
qsa(".feed-tab").forEach((tab) => tab.addEventListener("click", () => {
|
|
qsa(".feed-tab").forEach((item) => item.classList.remove("active"));
|
|
tab.classList.add("active");
|
|
showToast("已切换到" + tab.textContent);
|
|
}));
|
|
qsa(".mobile-nav button").forEach((button) => button.addEventListener("click", () => {
|
|
qsa(".mobile-nav button").forEach((item) => item.classList.remove("active"));
|
|
button.classList.add("active");
|
|
}));
|
|
qsa(".like-action").forEach((button) => button.addEventListener("click", () => {
|
|
const liked = button.classList.toggle("liked");
|
|
changeCount(button, liked ? 1 : -1);
|
|
button.setAttribute("aria-pressed", String(liked));
|
|
}));
|
|
qsa(".repost-action").forEach((button) => button.addEventListener("click", () => {
|
|
const reposted = button.classList.toggle("reposted");
|
|
changeCount(button, reposted ? 1 : -1);
|
|
showToast(reposted ? "已转发" : "已撤销转发");
|
|
}));
|
|
qsa(".reply-action").forEach((button) => button.addEventListener("click", () => {
|
|
const form = qs(".reply-form", button.closest(".post-body"));
|
|
if (!form) return;
|
|
form.classList.toggle("open");
|
|
if (form.classList.contains("open")) qs("input", form).focus();
|
|
}));
|
|
qsa(".reply-form").forEach((form) => form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const input = qs("input", form);
|
|
const value = input.value.trim();
|
|
if (!value) return;
|
|
const bubble = document.createElement("div");
|
|
bubble.className = "reply-bubble";
|
|
bubble.innerHTML = "<strong>" + escapeHTML(pageData.site.currentUser.name) + " · 刚刚</strong>" + escapeHTML(value);
|
|
form.before(bubble);
|
|
input.value = "";
|
|
const button = qs(".reply-action", form.closest(".post-body"));
|
|
if (button) changeCount(button, 1);
|
|
showToast("回复已发布");
|
|
}));
|
|
qsa(".share").forEach((button) => button.addEventListener("click", async () => {
|
|
try {
|
|
if (navigator.clipboard) await navigator.clipboard.writeText(location.href);
|
|
showToast("链接已复制");
|
|
} catch (_error) {
|
|
showToast("链接已准备");
|
|
}
|
|
}));
|
|
qsa(".thread-card").forEach((thread) => {
|
|
const menu = qs(".post-menu", thread);
|
|
const more = qs(".more-button", thread);
|
|
if (menu && more) more.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
qsa(".post-menu.open").forEach((other) => {
|
|
if (other !== menu) other.classList.remove("open");
|
|
});
|
|
menu.classList.toggle("open");
|
|
});
|
|
});
|
|
qsa("[data-open-composer]").forEach((button) => {
|
|
if (!button.dataset.composerBound) {
|
|
button.dataset.composerBound = "true";
|
|
button.addEventListener("click", openCompose);
|
|
}
|
|
});
|
|
qsa("[data-panel]").forEach((button) => {
|
|
if (!button.dataset.panelBound) {
|
|
button.dataset.panelBound = "true";
|
|
button.addEventListener("click", openPanel);
|
|
}
|
|
});
|
|
qs("#searchInput").value = qs("#searchInput").value || "";
|
|
applySearch();
|
|
};
|
|
|
|
const overlay = qs("#composeOverlay");
|
|
const composeText = qs("#composeText");
|
|
const modalPublish = qs("#modalPublish");
|
|
const charCount = qs("#charCount");
|
|
let modalOpener = null;
|
|
function openCompose(event) {
|
|
modalOpener = event.currentTarget;
|
|
overlay.classList.add("open");
|
|
overlay.setAttribute("aria-hidden", "false");
|
|
document.body.classList.add("modal-open");
|
|
window.setTimeout(() => composeText.focus(), 80);
|
|
}
|
|
const closeCompose = () => {
|
|
overlay.classList.remove("open");
|
|
overlay.setAttribute("aria-hidden", "true");
|
|
document.body.classList.remove("modal-open");
|
|
if (modalOpener) modalOpener.focus();
|
|
};
|
|
composeText.addEventListener("input", () => {
|
|
const left = composeText.maxLength - composeText.value.length;
|
|
charCount.textContent = String(left);
|
|
charCount.classList.toggle("warn", left <= 30);
|
|
modalPublish.disabled = !composeText.value.trim();
|
|
});
|
|
modalPublish.addEventListener("click", () => {
|
|
const value = composeText.value.trim();
|
|
if (!value) return;
|
|
const user = pageData.site.currentUser;
|
|
pageData.threads.items.unshift({
|
|
id: "new-" + Date.now(),
|
|
author: {name: user.name, handle: user.handle, initial: user.initial, colors: user.colors, verified: false},
|
|
time: "刚刚",
|
|
text: value,
|
|
media: null,
|
|
quote: null,
|
|
metrics: {likes: 0, replies: 0, reposts: 0},
|
|
followable: false,
|
|
replies: [],
|
|
preview: "成为第一个回复的人"
|
|
});
|
|
saveData();
|
|
renderThreads();
|
|
bindDynamicInteractions();
|
|
composeText.value = "";
|
|
charCount.textContent = String(composeText.maxLength);
|
|
modalPublish.disabled = true;
|
|
closeCompose();
|
|
qs(".thread-card").scrollIntoView({behavior: "smooth", block: "center"});
|
|
showToast("串聊已发布");
|
|
});
|
|
qs("#closeCompose").addEventListener("click", closeCompose);
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) closeCompose();
|
|
});
|
|
|
|
const closePanels = () => qsa(".panel.open").forEach((panel) => {
|
|
panel.classList.remove("open");
|
|
panel.setAttribute("aria-hidden", "true");
|
|
});
|
|
function openPanel(event) {
|
|
event.stopPropagation();
|
|
const panel = qs("#" + event.currentTarget.dataset.panel);
|
|
if (!panel) return;
|
|
const willOpen = !panel.classList.contains("open");
|
|
closePanels();
|
|
if (willOpen) {
|
|
panel.classList.add("open");
|
|
panel.setAttribute("aria-hidden", "false");
|
|
if (panel.id === "searchPanel") window.setTimeout(() => qs("input", panel).focus(), 80);
|
|
}
|
|
}
|
|
qsa("[data-close-panel]").forEach((button) => button.addEventListener("click", closePanels));
|
|
qs("#searchInput").addEventListener("input", applySearch);
|
|
|
|
const settingsOverlay = qs("#settingsOverlay");
|
|
const jsonEditor = qs("#jsonEditor");
|
|
const jsonStatus = qs("#jsonStatus");
|
|
let activeModule = MODULES[0];
|
|
let editorDraft = clone(pageData);
|
|
const validateEditor = () => {
|
|
try {
|
|
const parsed = JSON.parse(jsonEditor.value);
|
|
if (!isObject(parsed)) throw new Error("模块必须是 JSON 对象");
|
|
validateRemoteMediaTree(parsed);
|
|
jsonStatus.textContent = "JSON 有效";
|
|
jsonStatus.className = "json-status valid";
|
|
return parsed;
|
|
} catch (error) {
|
|
jsonStatus.textContent = "JSON 无效:" + error.message;
|
|
jsonStatus.className = "json-status invalid";
|
|
return null;
|
|
}
|
|
};
|
|
const storeActiveDraft = () => {
|
|
const parsed = validateEditor();
|
|
if (!parsed) return false;
|
|
editorDraft[activeModule] = parsed;
|
|
return true;
|
|
};
|
|
const selectModule = (name, force) => {
|
|
if (!force && !storeActiveDraft()) return;
|
|
activeModule = name;
|
|
qsa(".module-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.module === name));
|
|
qs("#editorModuleTitle").textContent = MODULE_META[name][0];
|
|
jsonEditor.value = JSON.stringify(editorDraft[name], null, 2);
|
|
validateEditor();
|
|
};
|
|
const renderModuleTabs = () => {
|
|
qs("#moduleTabs").innerHTML = "<h3>页面模块</h3>" + MODULES.map((name) =>
|
|
'<button class="module-tab' + (name === activeModule ? " active" : "") + '" data-module="' + name + '"><strong>' +
|
|
MODULE_META[name][0] + "</strong><span>" + MODULE_META[name][1] + "</span></button>"
|
|
).join("");
|
|
qsa(".module-tab").forEach((tab) => tab.addEventListener("click", () => selectModule(tab.dataset.module, false)));
|
|
};
|
|
const openSettings = () => {
|
|
editorDraft = clone(pageData);
|
|
activeModule = MODULES[0];
|
|
renderModuleTabs();
|
|
selectModule(activeModule, true);
|
|
settingsOverlay.classList.add("open");
|
|
settingsOverlay.setAttribute("aria-hidden", "false");
|
|
document.body.classList.add("modal-open", "settings-open");
|
|
window.setTimeout(() => jsonEditor.focus(), 80);
|
|
};
|
|
const closeSettings = () => {
|
|
settingsOverlay.classList.remove("open");
|
|
settingsOverlay.setAttribute("aria-hidden", "true");
|
|
document.body.classList.remove("modal-open", "settings-open");
|
|
qs("#openSettings").focus();
|
|
};
|
|
qs("#openSettings").addEventListener("click", openSettings);
|
|
qs("#closeSettings").addEventListener("click", closeSettings);
|
|
settingsOverlay.addEventListener("click", (event) => {
|
|
if (event.target === settingsOverlay) closeSettings();
|
|
});
|
|
jsonEditor.addEventListener("input", validateEditor);
|
|
jsonEditor.addEventListener("keydown", (event) => {
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
const start = jsonEditor.selectionStart;
|
|
const end = jsonEditor.selectionEnd;
|
|
jsonEditor.setRangeText(" ", start, end, "end");
|
|
validateEditor();
|
|
}
|
|
});
|
|
qs("#applyJson").addEventListener("click", () => {
|
|
if (!storeActiveDraft() || !isCompleteData(editorDraft)) {
|
|
showToast("请修正 JSON 后再应用");
|
|
return;
|
|
}
|
|
pageData = clone(editorDraft);
|
|
saveData();
|
|
renderAll();
|
|
showToast("JSON 已应用到页面");
|
|
});
|
|
qs("#resetJson").addEventListener("click", () => {
|
|
pageData = clone(initialData);
|
|
editorDraft = clone(initialData);
|
|
removeStorage(storageKey);
|
|
removeStorage(themeKey);
|
|
themeInitialized = false;
|
|
renderAll();
|
|
renderModuleTabs();
|
|
selectModule(activeModule, true);
|
|
showToast("已恢复初始 JSON");
|
|
});
|
|
qs("#copyJson").addEventListener("click", async () => {
|
|
if (!storeActiveDraft()) return;
|
|
const text = JSON.stringify(editorDraft, null, 2);
|
|
try {
|
|
if (navigator.clipboard) await navigator.clipboard.writeText(text);
|
|
showToast("完整 JSON 已复制");
|
|
} catch (_error) {
|
|
jsonEditor.focus();
|
|
jsonEditor.select();
|
|
showToast("已选中当前模块,请手动复制");
|
|
}
|
|
});
|
|
qs("#downloadJson").addEventListener("click", () => {
|
|
if (!storeActiveDraft()) return;
|
|
const blob = new Blob([JSON.stringify(editorDraft, null, 2) + "\n"], {type: "application/json"});
|
|
const link = document.createElement("a");
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = "threads-page-data.json";
|
|
link.click();
|
|
window.setTimeout(() => URL.revokeObjectURL(link.href), 0);
|
|
showToast("JSON 已下载");
|
|
});
|
|
|
|
[qs("#themeToggle"), qs("#mobileTheme")].forEach((button) => button.addEventListener("click", toggleTheme));
|
|
document.addEventListener("click", (event) => {
|
|
if (!event.target.closest(".panel") && !event.target.closest("[data-panel]")) closePanels();
|
|
if (!event.target.closest(".post-menu") && !event.target.closest(".more-button")) {
|
|
qsa(".post-menu.open").forEach((menu) => menu.classList.remove("open"));
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
closePanels();
|
|
if (overlay.classList.contains("open")) closeCompose();
|
|
if (settingsOverlay.classList.contains("open")) closeSettings();
|
|
}
|
|
});
|
|
qsa('a[href^="#"]').forEach((link) => link.addEventListener("click", (event) => {
|
|
const target = link.getAttribute("href");
|
|
if (target !== "#feed") {
|
|
event.preventDefault();
|
|
showToast("此页面正在准备中");
|
|
}
|
|
}));
|
|
|
|
renderAll();
|
|
function installJsonMediaUpload(){
|
|
const editor=document.getElementById("jsonEditor"),apply=document.getElementById("applyJson");if(!editor||!apply||document.getElementById("uploadJsonMedia"))return;
|
|
const actions=apply.parentElement,button=document.createElement("button"),input=document.createElement("input"),reference=actions.querySelector("#downloadJson,#copyJson,#validateJson,#resetJson");
|
|
button.type="button";button.id="uploadJsonMedia";button.className=reference?reference.className:apply.className;button.textContent="上传图片/视频";button.title="先把光标放进要替换的内容对象,再选择图片或视频";input.type="file";input.accept="image/*,video/*";input.hidden=true;actions.insertBefore(button,apply);actions.appendChild(input);
|
|
const report=(message)=>{const status=document.getElementById("settingsStatus")||document.getElementById("jsonError")||document.getElementById("jsonStatus");if(status)status.textContent=message};
|
|
const findTarget=(text,cursor)=>{const stack=[],matches=[];let quoted=false,escaped=false;for(let index=0;index<text.length;index+=1){const char=text[index];if(quoted){if(escaped)escaped=false;else if(char==="\\")escaped=true;else if(char==='"')quoted=false;continue}if(char==='"'){quoted=true;continue}if(char==="{")stack.push(index);else if(char==="}"){const start=stack.pop();if(start===undefined||cursor<start||cursor>index+1)continue;try{const value=JSON.parse(text.slice(start,index+1)),own=(key)=>Object.prototype.hasOwnProperty.call(value,key);if(value&&!Array.isArray(value)&&(own("media")||own("visual")||(own("id")&&(own("text")||own("title"))&&(own("author")||own("channel")||own("user")))))matches.push({start,end:index+1,value})}catch(_error){/* full validator handles malformed JSON */}}}return matches.sort((left,right)=>(left.end-left.start)-(right.end-right.start))[0]||null};
|
|
button.addEventListener("click",()=>{input.value="";report("请选择图片或视频;文件会写入光标所在内容对象的 media 字段。");input.click()});
|
|
input.addEventListener("change",()=>{const file=input.files&&input.files[0];if(!file)return;const type=file.type.startsWith("image/")?"image":file.type.startsWith("video/")?"video":"";if(!type){report("仅支持图片或视频文件。");return}const target=findTarget(editor.value,editor.selectionStart);if(!target){report("请先把光标放进带 visual/media 的内容对象,或带 id 的帖子/视频对象内。");return}const reader=new FileReader();reader.onerror=()=>report("文件读取失败,请重新选择。");reader.onload=()=>{const previous=target.value.media&&typeof target.value.media==="object"?target.value.media:{},media={type,mediaUrl:String(reader.result)};for(const key of ["alt","label","caption","pageUrl"])if(typeof previous[key]==="string")media[key]=previous[key];if(!media.alt)media.alt=type==="image"?"用户上传的图片":"用户上传的视频";target.value.media=media;const lineStart=editor.value.lastIndexOf("\n",target.start-1)+1,indent=(editor.value.slice(lineStart,target.start).match(/^\s*/)||[""])[0],replacement=JSON.stringify(target.value,null,2).split("\n").map((line,index)=>index?indent+line:line).join("\n");editor.value=editor.value.slice(0,target.start)+replacement+editor.value.slice(target.end);editor.dispatchEvent(new Event("input",{bubbles:true}));const mediaIndex=replacement.indexOf('"media"');editor.focus();editor.setSelectionRange(target.start+Math.max(mediaIndex,0),target.start+Math.max(mediaIndex,0));report(`已把 ${file.name} 写入 JSON,请点击“应用并渲染”。${file.size>2*1024*1024?" 文件较大,建议同时下载 JSON 保存。":""}`)};reader.readAsDataURL(file)});
|
|
}
|
|
installJsonMediaUpload();
|
|
function installInlineMediaReplace() {
|
|
if (document.getElementById("dfInlineMediaInput")) return;
|
|
const visualHostSelector = ".post-media,.video-visual,.thumbnail-visual,.short-thumb,.watch-visual,.thread-media,.image-grid,.visual,.lead-visual,.mini-visual,.compact-media,.card-art,.story-visual,.art,.video-art,.media-art,.player-stage,.thumbnail,.thumb,.list-thumb,.focus-thumb,.involve-bg,.hero-art,#heroArt,#adminArt";
|
|
const getRootData = () => {
|
|
if (typeof pageData !== "undefined") return pageData;
|
|
if (typeof data !== "undefined") return data;
|
|
return null;
|
|
};
|
|
const input = document.createElement("input");
|
|
input.id = "dfInlineMediaInput";
|
|
input.type = "file";
|
|
input.hidden = true;
|
|
document.body.appendChild(input);
|
|
const style = document.createElement("style");
|
|
style.dataset.dfInlineMediaReplace = "true";
|
|
style.textContent = ".df-inline-media-host{position:relative!important}.df-inline-media-button{position:absolute;z-index:40;top:10px;right:10px;width:40px;height:40px;padding:0;border:1px solid rgba(255,255,255,.55);border-radius:50%;display:grid;place-items:center;color:#fff;background:rgba(8,15,28,.78);box-shadow:0 8px 24px rgba(0,0,0,.28);backdrop-filter:blur(8px);cursor:pointer;opacity:0;transform:translateY(-4px);transition:opacity .16s,transform .16s,background .16s}.df-inline-media-button:hover{background:rgba(20,110,240,.92)}.df-inline-media-button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.df-inline-media-host:hover>.df-inline-media-button,.df-inline-media-host:focus-within>.df-inline-media-button,.df-inline-media-button:focus-visible{opacity:1;transform:none}@media(hover:none){.df-inline-media-button{opacity:1;transform:none}}.df-inline-media-toast{position:fixed;z-index:2147483647;left:50%;bottom:28px;max-width:min(88vw,520px);padding:10px 14px;border-radius:10px;color:#fff;background:rgba(15,23,42,.94);box-shadow:0 12px 32px rgba(0,0,0,.28);font:600 13px/1.45 system-ui,sans-serif;transform:translateX(-50%)}";
|
|
document.head.appendChild(style);
|
|
const notify = (message) => {
|
|
if (typeof showToast === "function") { showToast(message); return; }
|
|
if (typeof toast === "function") { toast(message); return; }
|
|
let notice = document.getElementById("dfInlineMediaToast");
|
|
if (!notice) {
|
|
notice = document.createElement("div");
|
|
notice.id = "dfInlineMediaToast";
|
|
notice.className = "df-inline-media-toast";
|
|
document.body.appendChild(notice);
|
|
}
|
|
notice.textContent = message;
|
|
window.clearTimeout(notice._timer);
|
|
notice._timer = window.setTimeout(() => notice.remove(), 2600);
|
|
};
|
|
const sameSource = (value, source, absolute) => {
|
|
if (typeof value !== "string") return false;
|
|
if (value === source || value === absolute) return true;
|
|
try { return new URL(value, location.href).href === absolute; } catch (_error) { return false; }
|
|
};
|
|
const findMatches = (source, absolute) => {
|
|
const matches = [];
|
|
const stack = [];
|
|
const visit = (value) => {
|
|
if (Array.isArray(value)) { value.forEach(visit); return; }
|
|
if (!value || typeof value !== "object") return;
|
|
stack.push(value);
|
|
Object.entries(value).forEach(([key, item]) => {
|
|
if ((key === "mediaUrl" || key === "posterUrl") && sameSource(item, source, absolute)) {
|
|
const ancestors = stack.slice(0, -1).reverse();
|
|
const owner = ancestors.find((candidate) => candidate && typeof candidate === "object" && !Array.isArray(candidate) && ("id" in candidate || "title" in candidate || "text" in candidate || "name" in candidate || "author" in candidate)) || ancestors[0] || value;
|
|
matches.push({ owner, previous: value, key });
|
|
}
|
|
if (item && typeof item === "object") visit(item);
|
|
});
|
|
stack.pop();
|
|
};
|
|
visit(getRootData());
|
|
return matches;
|
|
};
|
|
const visualOwners = () => {
|
|
const owners = [];
|
|
const seen = new Set();
|
|
const visit = (value) => {
|
|
if (Array.isArray(value)) { value.forEach(visit); return; }
|
|
if (!value || typeof value !== "object") return;
|
|
const hasVisual = Object.prototype.hasOwnProperty.call(value, "visual") || (value.media && typeof value.media === "object");
|
|
const hasIdentity = ["id", "title", "text", "leadTitle", "feature", "name", "author"].some((key) => typeof value[key] === "string" && value[key].trim());
|
|
if (hasVisual && hasIdentity && !seen.has(value)) { seen.add(value); owners.push(value); }
|
|
Object.values(value).forEach((item) => { if (item && typeof item === "object") visit(item); });
|
|
};
|
|
visit(getRootData());
|
|
return owners;
|
|
};
|
|
const domIdentifiers = (element) => {
|
|
const values = new Set();
|
|
let cursor = element;
|
|
for (let depth = 0; cursor && cursor !== document.body && depth < 8; depth += 1, cursor = cursor.parentElement) {
|
|
Object.values(cursor.dataset || {}).forEach((value) => { if (value) values.add(String(value)); });
|
|
}
|
|
return values;
|
|
};
|
|
const chooseVisualTarget = (element) => {
|
|
const identifiers = domIdentifiers(element);
|
|
const context = element.closest("article,button,[data-post-id],[data-video-id],[data-short-id],.post,.video-card,.news-card,.story-card,.lead-story,.hero") || element.parentElement || element;
|
|
const contextText = String(context.textContent || "").replace(/\s+/g, " ").trim();
|
|
const visualNode = element.closest("[data-visual]") || element.querySelector && element.querySelector("[data-visual]");
|
|
const visualValue = visualNode && visualNode.dataset ? visualNode.dataset.visual : "";
|
|
let best = null;
|
|
let bestScore = 0;
|
|
visualOwners().forEach((owner) => {
|
|
let score = 0;
|
|
Object.entries(owner).forEach(([key, value]) => {
|
|
if (/id$/i.test(key) && identifiers.has(String(value))) score += 100;
|
|
});
|
|
["title", "text", "leadTitle", "feature", "visualLabel", "label", "author", "name", "summary"].forEach((key) => {
|
|
const value = typeof owner[key] === "string" ? owner[key].trim() : "";
|
|
if (value.length >= 2 && contextText.includes(value)) score += Math.min(40, value.length);
|
|
});
|
|
if (visualValue && owner.visual === visualValue) score += 20;
|
|
if (score > bestScore) { best = owner; bestScore = score; }
|
|
});
|
|
if (!best || bestScore <= 0) return null;
|
|
return { owner: best, previous: best.media && typeof best.media === "object" ? best.media : { type: "image" }, key: "mediaUrl" };
|
|
};
|
|
const chooseTarget = (element) => {
|
|
const source = element.getAttribute("src") || "";
|
|
const absolute = element.currentSrc || element.src || source;
|
|
const matches = findMatches(source, absolute);
|
|
if (!matches.length) return null;
|
|
const identifiers = domIdentifiers(element);
|
|
return matches.find(({ owner }) => Object.entries(owner).some(([key, value]) => /id$/i.test(key) && identifiers.has(String(value)))) || matches[0];
|
|
};
|
|
const persist = () => {
|
|
const root = getRootData();
|
|
if (typeof saveData === "function") return saveData();
|
|
if (typeof writeStorage === "function" && typeof storageKey !== "undefined") return writeStorage(storageKey, JSON.stringify(root));
|
|
if (typeof writeStorage === "function" && typeof dataKey !== "undefined") return writeStorage(dataKey, JSON.stringify(root));
|
|
return false;
|
|
};
|
|
let active = null;
|
|
const attachButton = (host, target) => {
|
|
if (!host || !target || host.querySelector(":scope > .df-inline-media-button")) return;
|
|
host.classList.add("df-inline-media-host");
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "df-inline-media-button";
|
|
button.setAttribute("aria-label", "替换图片或视频");
|
|
button.title = "上传图片或视频并替换";
|
|
button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5"/><path d="M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/></svg>';
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
active = { ...target };
|
|
input.accept = "image/*,video/*";
|
|
input.value = "";
|
|
input.click();
|
|
});
|
|
host.appendChild(button);
|
|
};
|
|
const decorateBuiltInVisuals = () => {
|
|
document.querySelectorAll(visualHostSelector).forEach((host) => {
|
|
if (host.querySelector("img[src],video[src],iframe[src],:scope > .df-inline-media-button")) return;
|
|
if (!host.querySelector("svg") && !host.dataset.visual) return;
|
|
const target = chooseVisualTarget(host);
|
|
if (!target) return;
|
|
attachButton(host, target);
|
|
});
|
|
};
|
|
const decorate = () => {
|
|
decorateBuiltInVisuals();
|
|
document.querySelectorAll("img[src],video[src]").forEach((media) => {
|
|
if (media.dataset.dfInlineMediaReady === "true") return;
|
|
const target = chooseTarget(media);
|
|
if (!target) return;
|
|
const host = media.closest(visualHostSelector) || media.parentElement;
|
|
if (!host) return;
|
|
media.dataset.dfInlineMediaReady = "true";
|
|
attachButton(host, target);
|
|
});
|
|
};
|
|
input.addEventListener("change", () => {
|
|
const file = input.files && input.files[0];
|
|
if (!file || !active) return;
|
|
const uploadedType = file.type.startsWith("image/") ? "image" : file.type.startsWith("video/") ? "video" : "";
|
|
if (!uploadedType) {
|
|
notify("请选择图片或视频文件。");
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onerror = () => notify("文件读取失败,请重新选择。");
|
|
reader.onload = () => {
|
|
const next = { ...(active.previous || {}) };
|
|
const result = String(reader.result);
|
|
next.type = uploadedType;
|
|
next.mediaUrl = result;
|
|
delete next.posterUrl;
|
|
if (!next.alt) next.alt = uploadedType === "video" ? "用户上传的视频" : "用户上传的图片";
|
|
active.owner.media = next;
|
|
const stored = persist();
|
|
renderAll();
|
|
notify(stored === false ? "媒体已替换;浏览器未能缓存,请下载 JSON 留存。" : "媒体已替换并写入页面 JSON。");
|
|
active = null;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
let queued = false;
|
|
const queueDecorate = () => {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(() => { queued = false; decorate(); });
|
|
};
|
|
new MutationObserver(queueDecorate).observe(document.body, { childList: true, subtree: true });
|
|
decorate();
|
|
}
|
|
installInlineMediaReplace();
|
|
})();
|