您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
通用 UI 组件和工具函数库
当前为
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.icu/scripts/539247/1609892/%E9%80%9A%E7%94%A8%E7%BB%84%E4%BB%B6%E5%BA%93.js
// ==UserScript== // @name 通用组件库 // @namespace http://greasyfork.icu/zh-CN/users/1296281 // @version 1.1.0 // @license GPL-3.0 // @description 通用 UI 组件和工具函数库 // @author ShineByPupil // @match * // @grant none // ==/UserScript== (function () { "use strict"; const colors = { primary: "#4C6EF5", success: "#67c23a", info: "#909399", warning: "#e6a23c", danger: "#f56c6c", }; const defaultColors = []; const lightColors = []; const darkColors = []; const mixColor = (color1, color2, percent) => { // 去掉井号并转换为 0~255 的整数 const c1 = color1.replace(/^#/, ""); const c2 = color2.replace(/^#/, ""); const r1 = parseInt(c1.substr(0, 2), 16); const g1 = parseInt(c1.substr(2, 2), 16); const b1 = parseInt(c1.substr(4, 2), 16); const r2 = parseInt(c2.substr(0, 2), 16); const g2 = parseInt(c2.substr(2, 2), 16); const b2 = parseInt(c2.substr(4, 2), 16); // 百分比转 0~1 const t = Math.min(Math.max(percent, 0), 100) / 100; // 插值计算 const r = Math.round(r1 + (r2 - r1) * t); const g = Math.round(g1 + (g2 - g1) * t); const b = Math.round(b1 + (b2 - b1) * t); // 转回两位十六进制,不足两位补零 const toHex = (x) => x.toString(16).padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; }; for (let key in colors) { const color = colors[key]; defaultColors.push(`--${key}-color: ${color};`); for (let i = 1; i <= 9; i++) { const p = i * 10; lightColors.push( `--${key}-color-light-${i}: ${mixColor(color, "#ffffff", p)};`, ); darkColors.push( `--${key}-color-light-${i}: ${mixColor(color, "#141414", p)};`, ); } } const commonCssTemplate = document.createElement("template"); commonCssTemplate.innerHTML = ` <style> :host { font-family: Inter, "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", 微软雅黑, Arial, sans-serif; } :host { ${defaultColors.join("\n")} ${lightColors.join("\n")} --primary-color-hover: var(--primary-color-linght); --border-color: #dcdfe6; --border-color-hover: #C0C4CC; --border-color-focus: var(--primary-color); --bg-color: #FFFFFF; --text-color: #333333; --overlay-bg: rgba(0, 0, 0, 0.5); --box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, .04), 0px 8px 20px rgba(0, 0, 0, .08); --placeholder-color: #a8abb2; } :host-context(.ex), :host-context(.dark), :host-context([data-theme="dark"]) { ${darkColors.join("\n")} --primary-color-hover: var(--primary-color-dark); --border-color: #4C4D4F; --border-color-hover: #6C6E72; --border-color-focus: var(--primary-color); --bg-color: #141414; --text-color: #CFD3DC; --placeholder-color: #8D9095; } button { color: inherit; cursor: pointer; } </style> `; class Input extends HTMLElement { input = null; constructor() { super(); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = `<input type="text" />`; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> :host { display: inline-flex; height: 32px; vertical-align: top; } input { width: 100%; height: 100%; color: var(--text-color); outline: none; box-sizing: border-box; padding: 4px 11px; border-radius: 4px; border: 1px solid var(--border-color); transition: all 0.3s; background-color: var(--bg-color); } input:hover { border-color: var(--border-color-hover); } input:focus { border-color: var(--border-color-focus); border-inline-end-width: 1px; } input::placeholder { color: var(--placeholder-color); } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append( htmlTemplate.content, commonCssTemplate.content.cloneNode(true), cssTemplate.content, ); this.input = this.shadowRoot.querySelector("input"); } connectedCallback() { this.input.addEventListener("input", (e) => { e.stopPropagation(); this.value = e.target.value; this.dispatchEvent(new CustomEvent("input", { detail: this.value })); }); Object.values(this.attributes).forEach((attr) => { if (!/^on/.test(attr.name)) { this.input.setAttribute(attr.name, attr.value); } }); const mo = new MutationObserver((mutationsList) => { for (const m of mutationsList) { if (m.type === "attributes") { const val = this.getAttribute(m.attributeName); if (val === null) { this.input.removeAttribute(m.attributeName); } else { this.input.setAttribute(m.attributeName, val); } } } }); mo.observe(this, { attributes: true }); } get value() { return this.input.value; } set value(val) { this.input.value = val; } } // todo class Option extends HTMLElement { constructor() { super(); } } // todo class Select extends HTMLElement { constructor() { super(); } } class Button extends HTMLElement { constructor() { super(); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = ` <button> <slot></slot> </button> `; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> :host { --bg-color: var(--bg-color); --bg-color-hover: var(--primary-color-light-9); --button-border-color: var(--border-color); --button-border-color-hover: var(--primary-color); --text-color-hover: var(--primary-color); } ${Object.keys(colors) .map((type) => { return ` :host([type='${type}']) { --text-color: #FFFFFF; --text-color-hover: #FFFFFF; --bg-color: var(--${type}-color); --bg-color-hover: var(--${type}-color-light-3); --button-border-color: var(--${type}-color); --button-border-color-hover: var(--${type}-color-light-3); } `; }) .join("\n")} :host { display: inline-flex; width: fit-content; height: 32px; } button { display: inline-flex; align-items: center; font-family: inherit; color: var(--text-color); padding: 8px 15px; background-color: var(--bg-color); border-radius: 5px; border: 1px solid var(--button-border-color); transition: all 0.3s; outline: none; } button:hover { color: var(--text-color-hover); background-color: var(--bg-color-hover); border-color: var(--button-border-color-hover); } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append( htmlTemplate.content, commonCssTemplate.content.cloneNode(true), cssTemplate.content, ); } } class Switch extends HTMLElement { // 事件来源类型: user | broadcast #currentChangeSource = "user"; static get observedAttributes() { return ["checked", "disabled", "@change"]; } constructor() { super(); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = ` <div class="track"> <div class="thumb"></div> </div>`; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> :host { --bg-color: #ccc; --cursor: pointer; } :host { display: inline-block; aspect-ratio: 2/1; height: 20px; } :host([checked]) { --bg-color: ${colors.primary}; } :host([checked]) .thumb { transform: translateX(calc(100% + 4px)); } :host([disabled]) { --cursor: not-allowed; } .track { width: 100%; height: 100%; background: var(--bg-color); border-radius: 14px; position: relative; transition: background .3s; cursor: var(--cursor); outline: none; } .thumb { aspect-ratio: 1/1; height: calc(100% - 4px); background: #fff; border-radius: 50%; position: absolute; top: 2px; left: 2px; transition: transform .3s; } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append(htmlTemplate.content, cssTemplate.content); } connectedCallback() { const track = this.shadowRoot.querySelector(".track"); track.addEventListener("click", () => this.toggle()); } attributeChangedCallback(name, oldValue, newValue) { if (name === "checked" && oldValue !== newValue) { const oldChecked = oldValue !== null; const newChecked = newValue !== null; this.dispatchEvent( new CustomEvent("change", { detail: { value: newChecked, oldValue: oldChecked, source: this.#currentChangeSource, }, }), ); this.#currentChangeSource = "user"; } } get checked() { return this.hasAttribute("checked"); } set checked(val) { val ? this.setAttribute("checked", "") : this.removeAttribute("checked"); } get disabled() { return this.hasAttribute("disabled"); } set disabled(val) { val ? this.setAttribute("disabled", "") : this.removeAttribute("disabled"); } toggle() { if (!this.disabled) this.checked = !this.checked; } // 静默更新方法(不触发事件) updateFromBroadcast(value) { this.#currentChangeSource = "broadcast"; this.checked = value; } } class MessageBox extends HTMLElement { static #instance = null; static observedAttributes = ["type"]; constructor() { super(); this.type = this.getAttribute("type"); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = ` <div class="message-box"> <mx-icon class="icon"></mx-icon> <span class="message"></span> </div> `; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> ${Object.keys(colors) .map((type) => { return ` :host([type='${type}']) { --text-color: var(--${type}-color); --bg-color: var(--${type}-color-light-7); --border-color: var(--${type}-color-light-4); } `; }) .join("\n")} .message-box { max-width: 300px; font-size: 14px; display: none; align-items: center; gap: 8px; position: fixed; top: 20px; left: 50%; transform: translate(-50%, 20px); opacity: 0; background-color: var(--bg-color); color: var(--text-color); border: 1px solid var(--border-color); padding: 10px 15px; border-radius: 5px; z-index: 100; } .message-box.show { transform: translate(-50%, 0); opacity: 1; transition: transform 0.3s ease, opacity 0.3s ease; } .message-box.hide { transform: translate(-50%, -20px); opacity: 0; transition: transform 0.6s ease, opacity 0.6s ease; } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append( htmlTemplate.content, commonCssTemplate.content.cloneNode(true), cssTemplate.content, ); this.box = this.shadowRoot.querySelector(".message-box"); this.icon = this.shadowRoot.querySelector(".icon"); this.message = this.shadowRoot.querySelector(".message"); } connectedCallback() { this.box.addEventListener("transitionend", (e) => { if (this.box.classList.contains("hide")) { this.box.style.display = "none"; this.box.classList.remove("hide"); } }); this.message.addEventListener("click", (e) => { navigator.clipboard.writeText(e.target.textContent); }); } attributeChangedCallback(attrName, oldVal, newVal) { if (attrName === "type") { const map = { primary: "info", success: "success", info: "info", warning: "warning", danger: "close", }; const iconType = map[newVal]; this.icon.setAttribute("type", iconType); } } static get instance() { if (!MessageBox.#instance) { const el = document.createElement("mx-message-box"); document.documentElement.appendChild(el); MessageBox.#instance = el; } return MessageBox.#instance; } #show(message, type = "info", duration) { const calcDuration = (message) => { // 最小 2 秒, 最大 5 秒, 基础 0.5 秒, 每个字符 50 ms const [min, max, base, perChar] = [2000, 5000, 500, 50]; const lengthTime = message.length * perChar; return Math.min(max, Math.max(min, base + lengthTime)); }; this.setAttribute("type", type); this.message.textContent = message; // 设置信息 this.message.title = message; this.box.style.display = "flex"; requestAnimationFrame(() => { requestAnimationFrame(() => { this.box.classList.add("show"); }); }); clearTimeout(this._hideTimer); this._hideTimer = setTimeout( () => { this.box.classList.remove("show"); this.box.classList.add("hide"); }, duration || calcDuration(message), ); } primary(message, duration) { this.#show(message, "primary", duration); } info(message, duration) { this.#show(message, "info", duration); } success(message, duration) { this.#show(message, "success", duration); } error(message, duration) { this.#show(message, "danger", duration); } warning(message, duration) { this.#show(message, "warning", duration); } } class Dialog extends HTMLElement { visible = false; #confirmBtn = null; #cancelBtn = null; #closeBtn = null; static get observedAttributes() { return ["cancel-text", "confirm-text"]; } constructor() { super(); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = ` <main> <header> <slot name="header"></slot> <button class="close">✕</button> </header> <article> <slot></slot> </article> <footer> <slot name="footer"> <slot name="button-before"></slot> <mx-button class="cancel">取消</mx-button> <slot name="button-center"></slot> <mx-button class="confirm" type="primary">确认</mx-button> <slot name="button-after"></slot> </slot> </footer> </main> <div class="mask"></div> `; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> :host { display: none; } main { min-width: 500px; padding: 16px; position: fixed; left: 50%; top: calc(20vh); transform: translateX(-50%); z-index: 3001; border-radius: 4px; background-color: var(--bg-color); color: var(--text-color); box-shadow: var(--box-shadow); } header { padding-bottom: 16px; font-size: 18px; } article { min-width: 500px; } footer { display: flex; justify-content: flex-end; gap: 12px; padding-top: 16px; } .close { font-size: 16px; aspect-ratio: 1/1; padding: 0; position: fixed; top: 16px; right: 16px; background-color: inherit; border: 0; } .close:hover { color: #F56C6C; } .mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 3000; background: var(--overlay-bg); } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append( htmlTemplate.content, commonCssTemplate.content.cloneNode(true), cssTemplate.content, ); this.#confirmBtn = this.shadowRoot.querySelector(".confirm"); this.#cancelBtn = this.shadowRoot.querySelector(".cancel"); this.#closeBtn = this.shadowRoot.querySelector(".close"); } connectedCallback() { // 按钮文字 { const cancelText = this.getAttribute("cancel-text") || "取消"; const confirmText = this.getAttribute("confirm-text") || "确认"; this.#cancelBtn.textContent = cancelText; this.#confirmBtn.textContent = confirmText; } // 事件初始化 { // 提交按钮 this.#confirmBtn?.addEventListener("click", (e) => { this.visible = false; this.style.display = "none"; this.dispatchEvent(new CustomEvent("confirm")); }); const cancel = () => { this.visible = false; this.style.display = "none"; this.dispatchEvent(new CustomEvent("cancel")); }; // 关闭按钮 this.#cancelBtn?.addEventListener("click", cancel); this.#closeBtn?.addEventListener("click", cancel); // ESC 键盘事件 document.addEventListener("keydown", (e) => { if (e.key === "Escape" && this.visible) { cancel(); } }); } } attributeChangedCallback(name, oldValue, newValue) { if (name === "visible" && oldValue !== newValue) { this.style.display = newValue !== null ? "block" : "none"; } } open() { this.visible = true; this.style.display = "block"; this.dispatchEvent(new CustomEvent("open")); } } class Icon extends HTMLElement { #paths = { info: "M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.344s-27.072-57.344-60.288-57.344c-33.28 0-60.16 23.104-60.16 57.344s26.88 57.344 60.16 57.344M590.912 699.2c0-6.848 2.368-24.64 1.024-34.752l-52.608 60.544c-10.88 11.456-24.512 19.392-30.912 17.28a12.992 12.992 0 0 1-8.256-14.72l87.68-276.992c7.168-35.136-12.544-67.2-54.336-71.296-44.096 0-108.992 44.736-148.48 101.504 0 6.784-1.28 23.68.064 33.792l52.544-60.608c10.88-11.328 23.552-19.328 29.952-17.152a12.8 12.8 0 0 1 7.808 16.128L388.48 728.576c-10.048 32.256 8.96 63.872 55.04 71.04 67.84 0 107.904-43.648 147.456-100.416z", success: "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.272 38.272 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336z", warning: "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.432 58.432 0 0 0-58.24 63.744l23.36 256.384a35.072 35.072 0 0 0 69.76 0l23.296-256.384A58.432 58.432 0 0 0 512 256m0 512a51.2 51.2 0 1 0 0-102.4 51.2 51.2 0 0 0 0 102.4", close: "M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 393.664L407.936 353.6a38.4 38.4 0 1 0-54.336 54.336L457.664 512 353.6 616.064a38.4 38.4 0 1 0 54.336 54.336L512 566.336 616.064 670.4a38.4 38.4 0 1 0 54.336-54.336L566.336 512 670.4 407.936a38.4 38.4 0 1 0-54.336-54.336z", }; static observedAttributes = ["type"]; constructor() { super(); const htmlTemplate = document.createElement("template"); htmlTemplate.innerHTML = `<svg viewBox="0 0 1024 1024"><path d=""></path></svg>`; const cssTemplate = document.createElement("template"); cssTemplate.innerHTML = ` <style> :host { display: inline-block; width: 1em; height: 1em; color: currentColor; } svg { width: 100%; height: 100%; fill: currentColor; } </style> `; this.attachShadow({ mode: "open" }); this.shadowRoot.append( htmlTemplate.content, commonCssTemplate.content.cloneNode(true), cssTemplate.content, ); this.path = this.shadowRoot.querySelector("path"); } connectedCallback() {} attributeChangedCallback(attributeName, oldValue, newValue) { if (attributeName === "type") { this.toggle(); } } toggle() { if (this.hasAttribute("type")) { this.type = this.getAttribute("type"); if (this.type in this.#paths) { this.path.setAttribute("d", this.#paths[this.type]); } else { console.warn("出现未知的 icon 类型", this); } } } } // 注册组件 [Input, Select, Button, Option, Switch, MessageBox, Dialog, Icon].forEach( (n) => { const name = `mx-${n.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()}`; if (!customElements.get(name)) { customElements.define(name, n); } else { console.error(`${name} 组件已注册`); } }, ); window.MxMessageBox = MessageBox.instance; })();