// ==UserScript==
// @name iconfont一键复制SVG
// @namespace http://tampermonkey.net/
// @version 1.1.8
// @description 适用于 iconfont 快速复制 SVG 代码。支持图标库文件复制,矢量插画库文件复制。默认复制图标为20px,方便调整。
// @author 2690874578@qq.com
// @match https://www.iconfont.cn/*
// @icon http://img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg
// @require https://cdn.jsdelivr.net/npm/sweetalert2@11.7.3/dist/sweetalert2.all.min.js
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// @license GPL-3.0-only
// ==/UserScript==
(async function () {
"use strict";
// 脚本级全局常量
// minify js and css
// https://www.minifier.org/
// minify html
// https://www.atatus.com/tools/html-minify
const SCRIPT_SETTINGS_POPUP = `
<dialog id="script-popup"><form method="dialog"><h1 class="set-tit1">插件设置</h1><fieldset><legend class="set-tit2">复制大小</legend><input name="size" placeholder="" type="number" max="5000" min="4" class="set-size" value="20"></fieldset><fieldset class="set-radio"><legend class="set-tit2">复制格式</legend><input id="radio-svg" type="radio" name="format" value="svg" class="svg-ra" checked="checked"><label class="s-txt" for="radio-svg">SVG</label><input id="radio-png" type="radio" name="format" value="png" class="svg-ra"><label class="s-txt" for="radio-png">PNG</label></fieldset><fieldset class="btn-outdiv"><button id="dialog-cancel" class="btb-cancel" onclick="document.querySelector('#radio-cancel').click()">取消</button><button id="dialog-confirm" class="btn-ok" onclick="document.querySelector('#radio-confirm').click()">确定</button><input id="radio-cancel" type="radio" name="action" value="cancel" class="hidden"> <input id="radio-confirm" type="radio" name="action" value="confirm" class="hidden"></fieldset></form><style>
dialog#script-popup{*{all:revert;margin:0;padding:0;border-top-width:0;padding-right:0;padding-left:0;border-left-width:0;border-bottom-width:0;padding-bottom:0;margin:0;padding-top:0;border-right-width:0}.hidden{display:none}ul{list-style:none}img{border:none}a{text-decoration:none}.fl{float:left}.fr{float:right}.clear{clear:both;overflow:hidden;height:0}.center{width:1300px;margin:0 auto}.lv{color:#379e3e!important}.hong{color:#f00!important}a:hover img{filter:alpha(opacity=80);opacity:.8;-webkit-transition:all 0.2s linear;-moz-transition:all 0.2s linear;-ms-transition:all 0.2s linear;transition:all 0.2s linear}&{position:fixed;width:340px;padding:40px 30px;background-color:#fff;border:none;border-radius:4px;box-sizing:border-box;box-shadow:rgb(0 0 0 / .2) 0 12px 28px 0,rgb(0 0 0 / .1) 0 2px 4px 0,rgb(255 255 255 / .05) 0 0 0 1px inset}legend{font-weight:700}.set-tit1{max-width:100%;text-align:center;text-transform:none;word-wrap:break-word;color:#333;font-size:30px;font-style:normal;font-weight:700;margin-bottom:32px}.set-tit2{font-size:18px;color:#333;margin-bottom:8px}.set-size{border:2px solid #e8ebf3;box-sizing:border-box;padding:0 16px;width:100%;margin-bottom:32px;height:46px;border-radius:8px;font-size:14px}.set-size:focus{border:2px solid #4569ff;outline:none;box-shadow:inset 0 1px 1px rgb(0 0 0 / .06),0 0 0 3px rgb(62 157 255 / 30%)}.svg-ra{width:16px;height:16px;vertical-align:middle;margin:0 0 4px 0;padding:0}.set-radio label{margin-left:.5em;margin-right:32px}.s-txt{color:#666;text-align:center;font-size:14px;font-style:normal;font-weight:400;line-height:27px}.btn-outdiv{display:flex;align-items:flex-start;gap:8px;align-self:stretch;margin-top:32px}.btb-cancel{display:flex;width:73px;height:39px;padding:10px;justify-content:center;align-items:center;gap:10px;background-color:#fff;border:1px solid #dcdee2;outline:0;color:#999;transition:all 0.3s ease;border-radius:8px}.btb-cancel:hover{background-color:#e2e8f0;transition:all 0.3s ease}input[type='radio']:checked{border-color:red}.btn-ok{display:flex;height:39px;padding:10px;justify-content:center;align-items:center;gap:10px;flex:1 0 0%;background-color:#4569ff;transition:all 0.3s ease;color:#fff;border:0;outline:0;border-radius:8px}.btn-ok:hover{background:linear-gradient(0deg,rgb(0 0 0 / .1) 0%,rgb(0 0 0 / .1) 100%),#3f5dfa;transition:all 0.3s ease}}
</style></dialog>
`;
const SMALL_DELAY = 200,
MEDIUM_DELAY = 500,
LARGE_DELAY = 1000,
XML = new XMLSerializer(),
// 支持的图像导出格式
OUT_FMTS = ["svg", "png"],
// 设置表: [设置的名称, 默认值]
MENU = {
SVG_SIZE: ["svg_size", 20],
OUT_FMT: ["out_fmt", "svg"],
};
/**
* 脚本配置初始化 -------------------------------------------------------------------------
*/
const CFG = (() => {
/**
* 判断 size 是否为 1 - 1000 内的整数
* @param {string | number} size
* @returns {boolean}
*/
function _is_size_valid(size) {
const _size = parseFloat(size);
if (_size === NaN) return false;
if (_size - parseInt(size) !== 0) return false;
if (_size < 4 || _size > 5000) return false;
return true;
}
/**
* 生成 x 合规函数生成器
* @param {(x: string) => T} parse
* @param {(x: string) => boolean} is_valid
* @param {() => T} getter
* @returns {(alter_on_invalid: (string) => void) => (size: string) => number}
*/
function _gen_validator(parse, is_valid, getter) {
return (alter_on_invalid) => (x) => {
const _x = parse(x);
if (!is_valid(x)) {
console.warn(`无效的插件设置项:`, x);
alter_on_invalid(x);
return getter();
}
return _x;
};
}
/**
* 从对话框关闭事件提取表单数据为字典
* @param {CloseEvent} event
* @returns {Map<string, string>}
*/
function _extract_form_data(event) {
const form = event.target.querySelector("form");
const data = new FormData(form);
return new Map(data.entries());
}
function _is_format_valid(format) {
return OUT_FMTS.includes(format);
}
/**
* 生成提示函数
* @param {string} text 应该有 {x} 占位符
* @param {string} title
* @returns {(x: string) => void}
*/
function _alert_invalid_x(text, title) {
return (x) => alert_error(text.replace("{x}", x), title);
}
/**
* 解包表单数据,合法化,对非法的弹窗提示
* @param {Map<string, string>} data
* @returns {{ size: number, format: string }}
*/
function _unpack(data) {
const alert_size = _alert_invalid_x(
"尺寸 {x} 不是有效数字!",
"无效尺寸!"
);
const alert_format = _alert_invalid_x(
"图像格式 {x} 不受支持!",
"无效格式!"
);
const get_stored_x = (x) => () => GM_getValue(...x);
const validate_size = _gen_validator(
parseInt,
_is_size_valid,
get_stored_x(MENU.SVG_SIZE)
);
const pass = (x) => x;
const validate_format = _gen_validator(
pass,
_is_format_valid,
get_stored_x(MENU.OUT_FMT)
);
const size = validate_size(alert_size)(data.get("size"));
const format = validate_format(alert_format)(data.get("format"));
return { size, format };
}
/**
* 弹出脚本配置弹窗以获取配置
* @param {(data: { size: number, format: string }, ...tasks: (data: { size: number, format: string }) => Promise<void>) => Promise<void>} on_success
* @param {...(data: { size: number, format: string }) => Promise<void>} tasks
* @returns {Promise<{ valid: boolean, size: number, format: string }>}
*/
async function ask_for_config(on_success, ...tasks) {
const event = await show_settings();
const data = _extract_form_data(event);
console.info(`从对话框中提取的表单数据:`, data);
if (data.get("action") === "cancel") {
show_msg("取消设置", "warning");
return { valid: false };
}
const { size, format } = _unpack(data);
on_success({ size, format }, ...tasks);
return {
valid: true,
size,
format,
};
}
// 在 GM_registerMenuCommand 函数中注册
/**
* 显式插件设置成功
* @param {{ size: number, format: string }} _
* @returns {Promise<void>}
*/
async function _show_config_ok(_) {
show_msg("插件设置成功~");
console.info("插件设置成功弹窗已经触发");
}
// 在 GM_registerMenuCommand 函数中注册
/**
* 设置复制图标的提示文本
* @param {{ size: number, format: string }} config
* @returns {Promise<void>}
*/
async function _set_icon_title(config) {
const { format } = config;
const _format = format.toUpperCase();
const icons = $("span.svg-copy");
for (const span of icons) {
span.title = "复制" + _format;
}
console.info(`全部图标 title 已经更新完成 -> ${_format}`);
}
/**
* 当插件设置成功时批量执行任务
* @param {{ size: number, format: string }} config
* @param {...(data: { size: number, format: string }) => Promise<void>} tasks
*/
async function on_config_success(config, ...tasks) {
console.info("插件设置成功,正在批量执行后置任务,使用最新配置:", config);
for (const task of tasks) {
task(config);
}
}
/**
* 获取存储的值(失败时使用默认值)
* @returns {{ size: number, format: string }}
*/
function get_stored_config() {
return {
size: GM_getValue(...MENU.SVG_SIZE),
format: GM_getValue(...MENU.OUT_FMT),
};
}
/**
* 为弹窗对话框插入存储的配置
* @param {HTMLDialogElement} popup
* @param {{ size: number, format: string }} config
*/
function insert_config(popup, config) {
const $ = (s) => popup.querySelector(s);
$('input[name="size"]').value = config.size;
$(`[id="radio-${config.format}"]`).click();
}
/**
* 弹出脚本设置弹窗,返回弹窗关闭事件
* @returns {Promise<CloseEvent>}
*/
function show_settings() {
const popup = $("#script-popup")[0];
const config = get_stored_config();
insert_config(popup, config);
popup.showModal();
return new Promise((resolve, _) => {
popup.addEventListener("close", resolve);
});
}
GM_registerMenuCommand("插件设置", async () => {
const config = await ask_for_config(
on_config_success,
_show_config_ok,
_set_icon_title,
);
if (!config.valid) return;
GM_setValue(MENU.SVG_SIZE[0], config.size);
GM_setValue(MENU.OUT_FMT[0], config.format);
});
return {
/**
* @returns {number}
*/
get SVG_SIZE() {
return GM_getValue(...MENU.SVG_SIZE);
},
/**
* @returns {string}
*/
get OUT_FMT() {
return GM_getValue(...MENU.OUT_FMT);
},
};
})();
/**
* 公用函数 ----------------------------------------------------------------------------
*/
function fire(...args) {
if (!(Swal instanceof Function)) {
// debugger;
const msg = "弹窗库 SweetAlert2 未加载!";
alert(msg + "\n你将无法看到正常弹窗,但功能仍会执行!");
console.warn("弹窗消息:", ...args);
return Promise.reject(new Error(msg));
}
return Swal.fire(...args);
}
/**
* 弹出小型提示框,2秒后消失
* @param {string} text 提示文本
* @param {"success" | "warning" | "error" | "info" | "question"} status 状态
* @returns {Promise}
*/
function show_msg(text = "复制成功,可以粘贴咯~", status = "success") {
return fire({
text,
toast: true,
timer: 2000,
showConfirmButton: false,
icon: status,
position: "top",
customClass: {
popup: "copy-popup",
htmlContainer: "copy-container",
icon: "copy-icon",
},
});
}
/**
* 显示警告弹窗
* @param {string} text
* @param {string} title
*/
function alert_error(text, title = null) {
fire({
icon: "error",
title,
text,
});
}
/**
* 异步的等待 delay_ms 毫秒
* @param {number} delay_ms
* @returns {Promise<void>}
*/
function sleep(delay_ms) {
return new Promise((resolve) => setTimeout(resolve, delay_ms));
}
/**
* 将 svg 元素序列化为大小为 20x20 的 svg 代码
* @param {SVGElement} svg
* @returns {string}
*/
function svgToStr(svg) {
// 设置大小
svg.setAttribute("width", `${CFG.SVG_SIZE}`);
svg.setAttribute("height", `${CFG.SVG_SIZE}`);
// 序列化
return XML.serializeToString(svg);
}
/**
* 元素选择器
* @param {string} selector 选择器
* @returns {HTMLElement[]} 元素
*/
function $(selector) {
const self = this?.querySelectorAll ? this : document;
return [...self.querySelectorAll(selector)];
}
/**
* 安全元素选择器,直到元素存在时才返回元素列表,最多等待5秒
* @param {string} selector 选择器
* @returns {Promise<Array<HTMLElement>} 元素列表
*/
async function $$(selector) {
const self = this?.querySelectorAll ? this : document;
for (let i = 0; i < 10; i++) {
let elems = [...self.querySelectorAll(selector)];
if (elems.length > 0) {
return elems;
}
await sleep(200);
}
const not_found_error = new Error(
`"${selector}" not found in 2s`
);
console.error(not_found_error);
return [];
}
/**
* 域名主函数 ----------------------------------------------------------------------
*/
/**
* iconfont 主函数
*/
async function iconfont() {
console.log("进入 iconfont");
init_task();
// 域名级全局常量
const PATHS = ["search", "illustrations", "collections"];
const STYLE_TEXT = `
.force-hide{visibility:hidden!important}.block-icon-list li:hover div.icon-cover{display:grid;grid-template-columns:auto auto}.block-icon-list li .icon-cover span.cover-item-line{height:auto;line-height:50px}.svg-copy.disabled{color:#6d6d6d!important}.icon-fuzhidaima:before{font-size:24px}.copy-icon{border:none!important;margin:0 1.25em!important;margin:0 0 0 10px!important}.copy-container{margin:8px 16px!important;padding:0!important;font-size:14px!important}.copy-popup{top:60px;padding:4px 10px!important;height:44px!important;font-size:12px!important;width:fit-content!important;align-content:center;box-shadow:rgb(0 0 0 / .2) 0 12px 28px 0,rgb(0 0 0 / .1) 0 2px 4px 0,rgb(255 255 255 / .05) 0 0 0 1px inset!important}
`;
/**
* 阻塞直到图标区存在
*/
while (true) {
if ($(".block-icon-list > li")[0]) break;
await sleep(SMALL_DELAY);
}
console.log("图标区出现了,开始执行任务");
function addStyle() {
const id = "iconfont-svg-copy-style";
if ($(`#${id}`)[0]) return;
const style = document.createElement("style");
style.id = id;
style.innerHTML = STYLE_TEXT;
document.head.append(style);
}
/**
* 初始化 writeText 钩子
* @returns {Function}
*/
function initHookOnWriteText() {
const writeText = navigator.clipboard.writeText;
const boundedWriteText = (text) =>
writeText.call(navigator.clipboard, text);
/**
*
* @param {(value) => any} resolve
* @returns {(text: string) => string}
*/
function wrapHookedWriteText(resolve) {
/**
* @param {string} text
* @returns {string}
*/
return async function (text) {
console.log("进入 hooked 的 writeText 函数");
// 无论成功与否,writeText 都要改回去
Object.defineProperty(navigator.clipboard, "writeText", {
value: writeText,
writable: false,
enumerable: true,
configurable: true,
});
// 没有取得 svg 字符串,解决为空字符串
if (!`${text}`.includes("<svg")) {
resolve("");
return;
}
// 成功取得 svg 字符串,解决为SVG代码
try {
// await boundedWriteText(text);
resolve(text);
} catch (e) {
return e;
}
};
}
function hookWriteText() {
return new Promise((resolve) => {
// 劫持 writeText 函数,直到一次调用后失效
Object.defineProperty(navigator.clipboard, "writeText", {
value: wrapHookedWriteText(resolve),
writable: false,
enumerable: true,
configurable: true,
});
});
}
return hookWriteText;
}
/**
* 返回期约,直到 writeText 被调用且复制内容包含 "<svg" 时才解决为 svg_str。
* 如果调用 writeText 的内容不包含 "<svg",则解决为 ""。
*/
const hookWriteText = initHookOnWriteText();
/**
* 在弹窗中获取 svg
* @param {HTMLElement} card
*/
async function copyInPopup(card) {
// 禁用复制按钮
const icon = $.call(card, ".svg-copy")[0];
icon.classList.add("disabled");
icon.removeEventListener("click", on_copy_icon_clicked, true);
// 触发弹窗
const download = $.call(card, "[title='下载'], [title='Download']")[0];
download.click();
// 等待弹窗加载完毕
while (true) {
if ($("[id*='dlg_'], [id*='mask_dlg_']").length) {
break;
}
await sleep(SMALL_DELAY);
}
// 隐藏弹窗
const dialogs = await $$("[id*='dlg_']");
dialogs.forEach((elem) => elem.classList.add("force-hide"));
let popup;
for (let elem of dialogs) {
if (elem.id.startsWith("dlg_")) {
popup = elem;
break;
}
}
if (!popup) throw new Error("#dlg_ not found");
// 取得复制SVG按钮
const copy_btn = (await $$("#btn-copy-svg"))[0];
if (!copy_btn) {
alert_error("此插画无法复制!可能是版权受限!", "复制失败");
return;
}
let svg_str = "";
let i = 1;
do {
const copy_task = hookWriteText();
copy_btn.click();
await sleep(SMALL_DELAY);
svg_str = await copy_task;
console.info(`try copy svg in popup: ${i}`);
i += 1;
// debugger;
} while (svg_str === "" && i < 10);
if (svg_str === "") {
alert_error("复制失败!可能是网络异常!请稍后再试!", "复制失败");
return;
}
// debugger;
await copy_svg_to_aim_fmt(svg_str);
// 关闭弹窗
$(".mp-e2e-dialog-close")[0].click();
// 重新启用复制按钮
icon.classList.remove("disabled");
icon.addEventListener("click", on_copy_icon_clicked, true);
}
/**
* 复制 blobs 为一个剪贴板对象
* @param {Blob[]} blobs
*/
async function copy_blobs(blobs) {
console.log("blob to be written:", blobs);
const bundle = {};
blobs.forEach((blob) => {
bundle[blob.type] = blob;
});
const item = new ClipboardItem(bundle);
// 复制到剪贴板
try {
await navigator.clipboard.write([item]);
} catch (err) {
console.error(err);
}
// 提示复制成功
show_msg();
}
/**
* svg str 转 png blob
* @param {string} svg
* @returns {Promise<Blob[]>}
*/
async function svg_to_png(svg) {
// prepare an image for format convertion
const img = new Image();
const svg_blob = new Blob([svg], { type: "image/svg+xml" });
const svg_url = URL.createObjectURL(svg_blob);
img.src = svg_url;
// draw white background
const canvas = document.createElement("canvas");
const size = CFG.SVG_SIZE;
const sizes = [size, size];
[canvas.width, canvas.height] = sizes;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, ...sizes);
// draw image
await new Promise((resolve) => {
img.onload = resolve;
});
ctx.drawImage(img, 0, 0);
// release svg resource
URL.revokeObjectURL(svg_url);
// convert to png
const png_blob = await new Promise((resolve) =>
canvas.toBlob(resolve)
);
return png_blob;
}
/**
* 复制 svg 代码为目标格式到剪贴板
* @param {string} svg_str
*/
async function copy_svg_to_aim_fmt(svg_str) {
let blobs = [];
switch (CFG.OUT_FMT) {
case "svg":
blobs.push(new Blob([svg_str], { type: "text/plain" }));
break;
case "png":
blobs.push(await svg_to_png(svg_str));
break;
default:
break;
}
await copy_blobs(blobs);
}
/**
* 当点击复制图标时复制 svg 到剪贴板
* @param {PointerEvent} event
*/
function on_copy_icon_clicked(event) {
// 取得svg
const card = event.target.closest("li"),
svg = $.call(card, "svg")[0];
// 如果是在 iframe 中的,那就要通过模拟点击下载的方式来获取
if (!svg) {
copyInPopup(card);
return;
}
// 序列化
const svg_str = svgToStr(svg);
copy_svg_to_aim_fmt(svg_str);
}
function addCopyIcon() {
// 获取卡片
const cards = $(".block-icon-list > li");
if (!cards[0]) throw new Error("无法选中图标块");
// 制作按钮元素模板
const template = document.createElement("span");
template.title = "复制" + CFG.OUT_FMT.toUpperCase();
template.classList.add(
"cover-item",
"iconfont",
"cover-item-line",
"icon-fuzhidaima",
"svg-copy"
);
cards.forEach((card) => {
// 添加复制图标
const icon_copy = template.cloneNode();
// 增加复制功能
icon_copy.addEventListener("click", on_copy_icon_clicked, true);
card.querySelector(".icon-cover").append(icon_copy);
});
}
function add_script_settings_popup(popup_html) {
// 弹窗已存在,退出
if ($("#script-popup")[0]) return;
// 弹窗不存在,创建并插入
document.body.insertAdjacentHTML("beforeend", popup_html);
}
async function mainTask() {
console.log("mainTask entered");
const first_path = location.pathname.split("/")[1];
console.log("当前一级路径:" + first_path);
// 无关路径
if (!first_path || !PATHS.includes(first_path)) return;
// 等待直到图标块出现
while (true) {
if ($(".block-icon-list > li")[0]) break;
await sleep(SMALL_DELAY);
}
// 如果已经存在按钮,退出主函数
if ($(".icon-cover span.svg-copy")[0]) return;
console.log("正在建造 [复制SVG] 图标...");
addStyle();
addCopyIcon();
console.log("[复制SVG] 图标 建造完成");
}
function delayedTask() {
setTimeout(mainTask, 0);
}
function getIconsBox(block = true) {
const s = ".block-icon-list";
if (block) return $(`${s} li`)[0].closest(s);
return $$(`${s} li`).then((elems) => elems[0].closest(s));
}
function monitorIconsChanging() {
const observer = new MutationObserver(delayedTask);
observer.observe(getIconsBox(), { childList: true });
}
const onMainChanged = (function () {
let icons_box = getIconsBox();
async function inner() {
const new_box = await getIconsBox(false);
if (icons_box === new_box) return;
icons_box = new_box;
mainTask();
monitorIconsChanging();
}
function delayed() {
setTimeout(inner, LARGE_DELAY);
}
return delayed;
})();
async function monitorMainChanging() {
const elem = (await $$("#magix_vf_main"))[0],
observer = new MutationObserver(onMainChanged);
observer.observe(elem, { attributes: true });
}
function init_task() {
console.info("执行初始化任务");
add_script_settings_popup(SCRIPT_SETTINGS_POPUP);
}
function main() {
console.log("进入 iconfont.main");
mainTask();
monitorMainChanging();
monitorIconsChanging();
addEventListener("popstate", mainTask, true);
}
main();
}
function execute_route(route, host) {
const action = route[host];
if (!action) {
console.log(`未知域名,不能处理:${host}`);
return;
}
action();
}
/**
* 路由,主函数入口
*/
(() => {
console.log("进入 route");
const host = location.hostname;
const route = {
"www.iconfont.cn": iconfont,
};
execute_route(route, host);
})();
/**
* [更新日志]
*
* 更新日期:2023/4/23
* 更新版本:1.1.2
* - 美化SVG尺寸设置弹窗
*
* 更新日期:2023/7/24
* 更新版本:1.1.3
* - 新增复制为PNG选项
*
* 更新日期:2024/6/15
* 更新版本:1.1.4
* - 重写配置弹窗,合并尺寸和图像类型到一个弹窗中
*
* 更新日期:2024/6/15
* 更新版本:1.1.5
* - 修复非图标页面不能打开设置弹窗的 BUG
*
* 更新日期:2024/6/16
* 更新版本:1.1.6
* - 修复复制图标 title 不随复制格式变化而变化的 BUG
*
* 更新日期:2024/7/11
* 更新版本:1.1.7
* - 修复复制部分插画失败的问题
* - 当无法复制时弹窗提示(copyInPopup情形下)
*
* 更新日期:2024/7/11
* 更新版本:1.1.8
* - 调整 sweetalert2 依赖
*/
})();