// ==UserScript== // @name 珠宝AI网页适配 // @namespace https://zbai.art/ // @version 0.2.4 // @description Make Zbai image creation controls reachable on mobile browsers. // @match http://zbai.art/* // @match https://zbai.art/* // @match https://jew.haistudio.ai/* // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { "use strict"; const MOBILE_QUERY = "(max-width: 820px)"; const TOUCH_QUERY = "(pointer: coarse)"; const ROOT_ID = "zbai-mobile-root"; const CLASS = { enabled: "zbai-mobile-enabled", compose: "zbai-mobile-compose-source", composeOpen: "zbai-mobile-compose-open", formatChoice: "zbai-mobile-format-choice", formatLabel: "zbai-mobile-format-label", formatRow: "zbai-mobile-format-row", history: "zbai-mobile-history-source", historyOpen: "zbai-mobile-history-open", lock: "zbai-mobile-lock", }; let activeSurface = ""; let toastTimer = 0; addStyles(); waitForBody(boot); function boot() { if (!isSupportedRoute() || document.getElementById(ROOT_ID)) return; const root = buildRoot(); document.body.append(root); syncMode(); refreshTargets(); root.addEventListener("click", onRootClick); listenForModeChange(MOBILE_QUERY); listenForModeChange(TOUCH_QUERY); window.addEventListener("resize", throttle(syncMode, 180)); document.addEventListener("click", delayPromoteFloatingMenus, true); const observer = new MutationObserver(throttle(refreshTargets, 180)); observer.observe(document.body, { attributes: true, childList: true, subtree: true }); } function buildRoot() { const root = document.createElement("div"); root.id = ROOT_ID; root.innerHTML = `
手机脚本已启用
`; return root; } function onRootClick(event) { const button = event.target.closest("[data-zbai-action]"); if (!button || !isMobile()) return; const action = button.dataset.zbaiAction; if (action === "close") closeSurface(); if (action === "compose") toggleCompose(); if (action === "upload") openUpload(); if (action === "history") toggleHistory(); if (action === "download") openDownload(); if (action === "auth") toggleAuth(); } function toggleCompose() { const panel = findComposePanel(); if (!panel) { toast("还没找到生成面板,页面加载完再点一次"); return; } if (activeSurface === "compose") { closeSurface(); return; } openSurface("compose", panel); focusPrompt(panel); } async function openUpload() { const panel = findComposePanel(); if (!panel) { toast("还没找到上传区,先进入图片创作页"); return; } openSurface("compose", panel); let upload = findUploadControl(panel); if (!upload) { findClickableByText(panel, "图生")?.click(); await delay(240); upload = findUploadControl(findComposePanel() || panel); } if (!upload) { toast("已打开图生面板,请在面板里点上传"); return; } upload.scrollIntoView({ block: "center", behavior: "smooth" }); upload.click(); } function toggleHistory() { const rail = findHistoryRail(); if (!rail) { toast("还没找到历史记录列"); return; } if (activeSurface === "history") { closeSurface(); return; } openSurface("history", rail); rail.scrollIntoView({ block: "nearest", inline: "nearest" }); } async function openDownload() { closeSurface(); const download = findDownloadControl(); if (download) { download.click(); return; } const result = findPrimaryResult(); if (!result) { toast("先打开或生成一张图片,再点下载"); return; } result.click(); await delay(280); const previewDownload = findDownloadControl(); if (previewDownload) { previewDownload.click(); return; } toast("预览已打开,请点预览里的下载图标"); } function openSurface(surface, node) { closeSurface(); activeSurface = surface; node.classList.add(surface === "compose" ? CLASS.composeOpen : CLASS.historyOpen); if (surface === "compose") node.scrollTop = 0; document.body.classList.add(CLASS.lock); document.getElementById(ROOT_ID)?.setAttribute("data-zbai-surface", surface); } function closeSurface() { activeSurface = ""; document.querySelector(`.${CLASS.composeOpen}`)?.classList.remove(CLASS.composeOpen); document.querySelector(`.${CLASS.historyOpen}`)?.classList.remove(CLASS.historyOpen); document.body.classList.remove(CLASS.lock); document.getElementById(ROOT_ID)?.removeAttribute("data-zbai-surface"); } function refreshTargets() { if (!isMobile() || !isSupportedRoute()) return; refreshRoute(); refreshAuthControl(); const compose = findComposePanel(); compose?.classList.add(CLASS.compose); markFormatControls(compose); findHistoryRail()?.classList.add(CLASS.history); promoteFloatingMenus(); } function refreshRoute() { const root = document.getElementById(ROOT_ID); if (!root) return; root.dataset.zbaiRoute = findComposePanel() || isCreateRoute() ? "compose" : "page"; } function refreshAuthControl() { const button = document.querySelector(`#${ROOT_ID} .zbai-mobile-auth`); if (!button) return; const loggedIn = isLoggedIn(); button.textContent = loggedIn ? "退出" : "登录"; button.setAttribute("aria-label", loggedIn ? "退出登录" : "登录"); } function syncMode() { const enabled = isMobile(); document.body.classList.toggle(CLASS.enabled, enabled); if (!enabled) closeSurface(); refreshTargets(); } function listenForModeChange(query) { const media = window.matchMedia(query); const onChange = () => syncMode(); if (typeof media.addEventListener === "function") media.addEventListener("change", onChange); else media.addListener?.(onChange); } function delayPromoteFloatingMenus() { window.setTimeout(promoteFloatingMenus, 60); } function promoteFloatingMenus() { if (!isMobile() || !activeSurface) return; const menus = document.querySelectorAll([ ".ant-select-dropdown", ".ant-dropdown", ".arco-select-popup", ".el-select-dropdown", "[role='listbox']", "[class*='select'][class*='dropdown']", "[class*='dropdown']", "[class*='popover']", "[class*='popper']", ].join(",")); [...menus].filter(isFloatingMenu).forEach((node) => { if (node.style.getPropertyValue("z-index") !== "2147483605") { node.style.setProperty("z-index", "2147483605", "important"); } }); } function isFloatingMenu(node) { if (!node || node.closest(`#${ROOT_ID}`)) return false; const style = getComputedStyle(node); const rect = node.getBoundingClientRect(); const position = style.position === "absolute" || style.position === "fixed"; return position && rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden"; } async function toggleAuth() { if (isLoggedIn()) { await openLogout(); return; } openLogin(); } function openLogin() { const login = findAuthTextControl(/^(登录|登录\/注册|注册\/登录)$/) || findAuthTextControl(/登录/); if (!login) { toast("还没找到原站登录入口,刷新首页后再点"); return; } login.click(); } async function openLogout() { let logout = findAuthTextControl(/退出登录|退出账号|退出/); if (logout) { logout.click(); return; } const avatar = findProfileAvatar(); if (!avatar) { toast("还没找到头像入口,刷新首页后再点"); return; } avatar.click(); await delay(260); logout = findAuthTextControl(/退出登录|退出账号|退出/); if (logout) { logout.click(); return; } toast("账号菜单已打开,请点退出"); } function isLoggedIn() { return Boolean(findProfileAvatar()); } function findProfileAvatar() { const candidates = document.querySelectorAll( 'img[src*="avatar" i], img[src*="profile" i], img[src*="head" i]' ); return [...candidates].find((image) => { if (!isUsable(image)) return false; const rect = image.getBoundingClientRect(); const src = image.currentSrc || image.src || ""; const style = getComputedStyle(image); const avatarSource = /avatar|profile|head|default_profile_picture/i.test(src); const headerAvatarShape = rect.top < 120 && rect.right > window.innerWidth * 0.66 && rect.width >= 24 && rect.width <= 72 && rect.height >= 24 && rect.height <= 72 && style.cursor === "pointer"; return avatarSource && headerAvatarShape; }); } function findAuthTextControl(pattern) { return [...document.querySelectorAll( "button, a, [role='button'], [role='menuitem'], li, div, span" )] .filter((node) => !node.closest(`#${ROOT_ID}`)) .find((node) => { const text = normalizedText(node); return text.length <= 16 && pattern.test(text); }); } function findComposePanel() { const anchors = [ ...document.querySelectorAll("textarea"), ...document.querySelectorAll('input[type="file"]'), ...[...document.querySelectorAll("button, label, [role='button']")] .filter((node) => normalizedText(node).includes("上传")), ]; for (const anchor of anchors) { const panel = walkParents(anchor, isComposePanel); if (panel) return panel; } return null; } function isComposePanel(node) { const text = normalizedText(node); const hasGeneratorConfig = text.includes("选择模型") || text.includes("选择模板") || text.includes("选个风格"); const hasCreate = text.includes("立即创作") || text.includes("开始创作"); const hasUpload = text.includes("上传") || Boolean(node.querySelector?.('input[type="file"]')); return hasGeneratorConfig && hasCreate && hasUpload; } function markFormatControls(panel) { if (!panel) return; const labels = [...panel.querySelectorAll("div, span, p, label")] .filter((node) => normalizedText(node) === "多格式"); labels.forEach((label) => { label.classList.add(CLASS.formatLabel); const row = walkParents(label, (node) => isFormatRow(node, panel)); if (!row) return; row.classList.add(CLASS.formatRow); markFormatChoices(row); }); } function isFormatRow(node, panel) { if (!node || node === panel) return false; const text = normalizedText(node); return text.includes("多格式") && text.includes("JPEG") && text.includes("PNG") && text.length <= 80; } function markFormatChoices(row) { [...row.querySelectorAll("button, label, div, span, p")] .filter((node) => /^(JPEG|PNG)$/.test(normalizedText(node))) .forEach((node) => { node.classList.add(CLASS.formatChoice); const parent = node.parentElement; if (parent && parent !== row && normalizedText(parent).length <= 20) { parent.classList.add(CLASS.formatChoice); } }); } function findPromptInput(scope) { return scope.querySelector( 'textarea[placeholder*="描述"], textarea[placeholder*="重绘"], textarea' ); } function findUploadControl(scope) { return findClickableByText(scope, "上传参考图") || scope.querySelector('input[type="file"]') || scope.querySelector('[class*="upload" i]'); } function focusPrompt(panel) { const prompt = findPromptInput(panel); if (!prompt) return; prompt.scrollIntoView({ block: "center" }); prompt.focus({ preventScroll: true }); } function findHistoryRail() { const images = [...document.images].filter((image) => { const rect = image.getBoundingClientRect(); return rect.left < 160 && rect.width > 24 && rect.width < 130 && rect.height > 24; }); for (const image of images) { const rail = walkParents(image, (node) => { const rect = node.getBoundingClientRect(); const imageCount = node.querySelectorAll("img").length; return imageCount >= 2 && rect.height > 220 && rect.width > 44 && rect.width < 190; }); if (rail) return rail; } return null; } function findDownloadControl() { const labeled = [ '[title*="下载"]', '[aria-label*="下载"]', '[download]', '[class*="download" i]', ].flatMap((selector) => [...document.querySelectorAll(selector)]); const byText = [...document.querySelectorAll("button, a, [role='button']")].filter((node) => normalizedText(node).includes("下载") ); const byRightPreviewIcon = [...document.querySelectorAll("button, a, [role='button'], i, span")] .filter(isLikelyPreviewDownload); return [...labeled, ...byText, ...byRightPreviewIcon].find(isUsable); } function findPrimaryResult() { return [...document.images] .map((image) => ({ image, rect: image.getBoundingClientRect() })) .filter(({ image, rect }) => { const src = image.currentSrc || image.src || ""; return isUsable(image) && rect.width > 150 && rect.height > 150 && !src.includes("logo") && !src.includes("bg-"); }) .sort((a, b) => b.rect.width * b.rect.height - a.rect.width * a.rect.height) .map(({ image }) => image)[0]; } function isLikelyPreviewDownload(node) { const rect = node.getBoundingClientRect(); if (!isUsable(node) || rect.left < window.innerWidth * 0.62 || rect.top > window.innerHeight * 0.66) { return false; } const signature = [ node.className, node.getAttribute("title"), node.getAttribute("aria-label"), node.innerHTML, ].join(" "); return /download|xiazai|icon-download||/i.test(signature) && !normalizedText(node).includes("上传"); } function findClickableByText(scope, text) { return [...scope.querySelectorAll("button, a, label, [role='button']")] .find((node) => normalizedText(node).includes(text) && isUsable(node)); } function walkParents(start, predicate) { let node = start; for (let depth = 0; node && depth < 18; depth += 1, node = node.parentElement) { if (predicate(node)) return node; } return null; } function normalizedText(node) { return (node?.innerText || node?.textContent || "").replace(/\s+/g, " ").trim(); } function isUsable(node) { if (!node || node.closest(`#${ROOT_ID}`)) return false; const rect = node.getBoundingClientRect(); const style = getComputedStyle(node); return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"; } function toast(message) { const box = document.querySelector(`#${ROOT_ID} .zbai-mobile-toast`); if (!box) return; box.textContent = message; box.classList.add("is-visible"); window.clearTimeout(toastTimer); toastTimer = window.setTimeout(() => box.classList.remove("is-visible"), 2600); } function addStyles() { const css = ` #${ROOT_ID} { display: none; } body.${CLASS.enabled}.${CLASS.lock} { overflow: hidden !important; } body.${CLASS.enabled} #${ROOT_ID} { display: block; position: fixed; inset: 0; z-index: 2147483600; pointer-events: none; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-auth { position: fixed; z-index: 2147483603; top: max(12px, env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); min-width: 64px; min-height: 40px; padding: 0 14px; border: 1px solid rgba(31, 35, 40, .14); border-radius: 8px; color: #fff; background: rgba(23, 25, 31, .94); box-shadow: 0 10px 28px rgba(19, 24, 33, .22); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0; pointer-events: auto; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-close { display: none; position: fixed; z-index: 2147483606; top: max(10px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); min-height: 40px; padding: 0 14px; border: 1px solid rgba(31, 35, 40, .14); border-radius: 8px; color: #17191f; background: rgba(255, 255, 255, .96); box-shadow: 0 10px 28px rgba(19, 24, 33, .18); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0; pointer-events: auto; } body.${CLASS.enabled} #${ROOT_ID}[data-zbai-surface] .zbai-mobile-close { display: block; } body.${CLASS.enabled} #${ROOT_ID}:not([data-zbai-route="compose"]) .zbai-mobile-dock { display: none; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock { position: fixed; z-index: 2147483602; right: max(12px, env(safe-area-inset-right)); bottom: calc(12px + env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left)); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; padding: 8px; border: 1px solid rgba(31, 35, 40, .12); border-radius: 8px; background: rgba(255, 255, 255, .94); box-shadow: 0 12px 36px rgba(19, 24, 33, .18); backdrop-filter: blur(12px); pointer-events: auto; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock button { min-width: 0; min-height: 44px; border: 0; border-radius: 6px; color: #17191f; background: #f3f4f7; font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-dock button:first-child { color: #fff; background: #17191f; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-ready { position: fixed; z-index: 2147483603; right: max(12px, env(safe-area-inset-right)); bottom: calc(78px + env(safe-area-inset-bottom)); padding: 5px 9px; border: 1px solid rgba(31, 35, 40, .12); border-radius: 7px; color: #17191f; background: rgba(255, 255, 255, .94); box-shadow: 0 8px 22px rgba(19, 24, 33, .16); font-size: 12px; font-weight: 600; line-height: 1.35; pointer-events: none; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-backdrop { position: fixed; inset: 0; border: 0; opacity: 0; background: rgba(10, 12, 18, .34); pointer-events: none; transition: opacity .18s ease; } body.${CLASS.enabled} #${ROOT_ID}[data-zbai-surface] .zbai-mobile-backdrop { opacity: 1; pointer-events: auto; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-toast { position: fixed; z-index: 2147483603; right: 18px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 18px; padding: 11px 13px; border-radius: 7px; color: #fff; background: rgba(23, 25, 31, .94); font-size: 13px; line-height: 1.4; opacity: 0; transform: translateY(8px); transition: opacity .16s ease, transform .16s ease; pointer-events: none; } body.${CLASS.enabled} #${ROOT_ID} .zbai-mobile-toast.is-visible { opacity: 1; transform: translateY(0); } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} { position: fixed !important; z-index: 2147483601 !important; inset: auto 0 0 0 !important; width: auto !important; min-width: 0 !important; max-width: none !important; height: min(88dvh, 860px) !important; max-height: calc(100dvh - 12px) !important; margin: 0 !important; padding-bottom: calc(88px + env(safe-area-inset-bottom)) !important; border-radius: 8px 8px 0 0 !important; overflow: auto !important; overscroll-behavior: contain; background: #fff !important; box-shadow: 0 -16px 42px rgba(19, 24, 33, .24) !important; transform: none !important; } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} textarea { min-height: 108px !important; } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} button { min-height: 40px; } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatLabel} { flex: 0 0 auto !important; min-width: 48px !important; white-space: nowrap !important; word-break: keep-all !important; writing-mode: horizontal-tb !important; } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatRow} { width: 100% !important; min-width: 0 !important; display: flex !important; flex-wrap: wrap !important; align-items: center !important; gap: 8px !important; pointer-events: auto !important; } body.${CLASS.enabled} .${CLASS.compose}.${CLASS.composeOpen} .${CLASS.formatRow} .${CLASS.formatChoice} { flex: 0 0 auto !important; min-width: 72px !important; min-height: 40px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; touch-action: manipulation; pointer-events: auto !important; } body.${CLASS.enabled} .ant-select-dropdown, body.${CLASS.enabled} .ant-dropdown, body.${CLASS.enabled} .el-select-dropdown, body.${CLASS.enabled} [role="listbox"], body.${CLASS.enabled} [class*="dropdown" i], body.${CLASS.enabled} [class*="popover" i], body.${CLASS.enabled} [class*="popper" i], body.${CLASS.enabled} [class*="select-dropdown" i], body.${CLASS.enabled} [data-radix-popper-content-wrapper] { z-index: 2147483605 !important; } body.${CLASS.enabled} .${CLASS.history}.${CLASS.historyOpen} { position: fixed !important; z-index: 2147483601 !important; inset: 0 auto 0 0 !important; width: min(40vw, 148px) !important; min-width: 92px !important; height: 100dvh !important; margin: 0 !important; padding: 12px 8px calc(92px + env(safe-area-inset-bottom)) !important; border-radius: 0 8px 8px 0 !important; overflow: auto !important; overscroll-behavior: contain; background: #f8f8fb !important; box-shadow: 10px 0 34px rgba(19, 24, 33, .22) !important; transform: none !important; } body.${CLASS.enabled} .${CLASS.history}.${CLASS.historyOpen} img { max-width: 100% !important; height: auto !important; } `; if (typeof GM_addStyle === "function") GM_addStyle(css); else { const style = document.createElement("style"); style.textContent = css; document.documentElement.append(style); } } function waitForBody(callback) { if (document.body) { callback(); return; } window.addEventListener("DOMContentLoaded", callback, { once: true }); } function isCreateRoute() { return location.pathname.startsWith("/create/"); } function isHomeRoute() { return location.pathname === "/" || location.pathname === ""; } function isSupportedRoute() { return true; } function isMobile() { const isTouchDevice = navigator.maxTouchPoints > 0; const mobileAgent = /Android|iPhone|iPad|iPod|Mobile|Lemur/i.test(navigator.userAgent); const phoneSizedScreen = Math.min(screen.width || 0, screen.height || 0) <= 900; return window.matchMedia(MOBILE_QUERY).matches || window.matchMedia(TOUCH_QUERY).matches || (isTouchDevice && (mobileAgent || phoneSizedScreen)); } function delay(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function throttle(callback, wait) { let timer = 0; return () => { if (timer) return; timer = window.setTimeout(() => { timer = 0; callback(); }, wait); }; } })();