diff --git a/plugins/word-count/.gitignore b/plugins/word-count/.gitignore new file mode 100644 index 000000000..f30a31d75 --- /dev/null +++ b/plugins/word-count/.gitignore @@ -0,0 +1,6 @@ +publish.log +publish.err +publish.pid +dist/ +run-publish.ps1 + diff --git a/plugins/word-count/CHANGELOG.md b/plugins/word-count/CHANGELOG.md new file mode 100644 index 000000000..e884d95b0 --- /dev/null +++ b/plugins/word-count/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 1.0.1 - 2026-09-28 + +- 移除界面左上角「AI生成」水印 + +## 1.0.0 - 2026-09-27 + +- 主界面粘贴/输入实时统计 +- 写作向指标:汉字、英文单词、预估阅读时长 +- 辅助指标:总字符、不含空白、数字、标点、行、段落 +- 搜索框 over 指令与主搜索即时结果 +- 复制统计报告、深浅色主题适配 diff --git a/plugins/word-count/count.js b/plugins/word-count/count.js new file mode 100644 index 000000000..042bd6242 --- /dev/null +++ b/plugins/word-count/count.js @@ -0,0 +1,140 @@ +/** + * 写作向字数统计核心(纯函数,无 DOM / 无 ztools 依赖) + * + * 口径说明: + * - 字符数:Unicode 码点数(emoji 不拆成代理对) + * - 汉字:Unicode Script=Han + * - 英文单词:拉丁字母连续段(含常见撇号缩写),数字不计入单词 + * - 数字:连续数字段按 1 个「数」计 + * - 标点:Unicode P* 标点类别(中英标点均计入) + * - 阅读时长:汉字 400 字/分 + 英文单词 200 词/分,向上取整 + */ +(function (global) { + "use strict"; + + var RE_HAN = /\p{Script=Han}/gu; + var RE_LATIN_WORD = /\p{Script=Latin}+(?:['’]\p{Script=Latin}+)*/gu; + var RE_NUMBER = /\d+/g; + var RE_PUNCT = /\p{P}/gu; + var RE_SPACE = /\s/u; + + function toCodePoints(text) { + return Array.from(text); + } + + function countMatches(text, re) { + var m = text.match(re); + return m ? m.length : 0; + } + + function formatReadingTime(minutes) { + if (!isFinite(minutes) || minutes <= 0) return "0 秒"; + if (minutes < 1) { + var secs = Math.max(1, Math.round(minutes * 60)); + return secs + " 秒"; + } + if (minutes < 60) { + var mins = Math.round(minutes); + return mins + " 分钟"; + } + var h = Math.floor(minutes / 60); + var rest = Math.round(minutes % 60); + return rest > 0 ? h + " 小时 " + rest + " 分钟" : h + " 小时"; + } + + /** + * @param {string} text + * @returns {{ + * charCount: number, + * charNoSpace: number, + * whitespace: number, + * han: number, + * latinWords: number, + * numbers: number, + * punctuation: number, + * lines: number, + * paragraphs: number, + * readingMinutes: number, + * readingLabel: string, + * summary: string + * }} + */ + function countText(text) { + if (typeof text !== "string") text = ""; + var codePoints = toCodePoints(text); + var charCount = codePoints.length; + var whitespace = 0; + for (var i = 0; i < codePoints.length; i++) { + if (RE_SPACE.test(codePoints[i])) whitespace++; + } + var charNoSpace = charCount - whitespace; + + var han = countMatches(text, RE_HAN); + var latinWords = countMatches(text, RE_LATIN_WORD); + var numbers = countMatches(text, RE_NUMBER); + var punctuation = countMatches(text, RE_PUNCT); + + var lines = text.length === 0 ? 0 : text.split(/\r\n|\r|\n/).length; + var paragraphs = + text.trim() === "" + ? 0 + : text + .split(/\r\n[\s]*\r\n|\r[\s]*\r|\n[\s]*\n/) + .filter(function (p) { + return p.trim() !== ""; + }).length; + + var readingMinutes = han / 400 + latinWords / 200; + if (charCount > 0 && readingMinutes < 1 / 60) { + readingMinutes = 1 / 60; + } + + var summary = + "汉字 " + + han + + " · 英文 " + + latinWords + + " · 阅读约 " + + formatReadingTime(readingMinutes); + + return { + charCount: charCount, + charNoSpace: charNoSpace, + whitespace: whitespace, + han: han, + latinWords: latinWords, + numbers: numbers, + punctuation: punctuation, + lines: lines, + paragraphs: paragraphs, + readingMinutes: readingMinutes, + readingLabel: formatReadingTime(readingMinutes), + summary: summary, + }; + } + + function formatFullReport(stats) { + return [ + "总字符 " + stats.charCount, + "不含空白 " + stats.charNoSpace, + "汉字 " + stats.han, + "英文单词 " + stats.latinWords, + "数字 " + stats.numbers, + "标点 " + stats.punctuation, + "行 " + stats.lines, + "段落 " + stats.paragraphs, + "预估阅读 " + stats.readingLabel, + ].join(" · "); + } + + var api = { + countText: countText, + formatReadingTime: formatReadingTime, + formatFullReport: formatFullReport, + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } + global.WordCount = api; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/plugins/word-count/index.css b/plugins/word-count/index.css new file mode 100644 index 000000000..d831e1c25 --- /dev/null +++ b/plugins/word-count/index.css @@ -0,0 +1,305 @@ +:root { + --bg: #f6f4f0; + --bg-elevated: #ffffff; + --ink: #171614; + --ink-muted: #8a847c; + --line: #ebe6de; + --accent: #2b5cff; + --hero: #f0f3ff; + --hero-ink: #1a3fa0; + --shadow: 0 1px 2px rgba(23, 22, 20, 0.04), 0 8px 20px rgba(23, 22, 20, 0.05); + --radius: 12px; + --font: "SF Pro Text", "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui, + sans-serif; +} + +html[data-theme="dark"] { + --bg: #111110; + --bg-elevated: #1b1a18; + --ink: #f2efe9; + --ink-muted: #8f8880; + --line: #2a2825; + --accent: #6b8cff; + --hero: #1c2233; + --hero-ink: #c5d2ff; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 20px rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--ink); + font-family: var(--font); + -webkit-font-smoothing: antialiased; +} + +body { + line-height: 1.4; + overflow: hidden; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.app { + max-width: 920px; + margin: 0 auto; + height: 100vh; + height: 100dvh; + padding: 12px 14px 12px; + display: flex; + flex-direction: column; + gap: 10px; + position: relative; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-shrink: 0; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; +} + +.brand-mark { + width: 28px; + height: 28px; + border-radius: 8px; + background: var(--ink); + color: var(--bg); + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; +} + +.brand h1 { + margin: 0; + font-size: 15px; + font-weight: 650; + letter-spacing: -0.01em; +} + +.header-actions { + display: flex; + gap: 6px; +} + +.btn { + appearance: none; + border: 1px solid var(--line); + background: var(--bg-elevated); + color: var(--ink); + border-radius: 999px; + padding: 5px 12px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; +} + +.btn:hover { + border-color: color-mix(in srgb, var(--ink-muted) 55%, var(--line)); +} + +.btn:active { + transform: scale(0.97); +} + +.btn.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.btn.primary:hover { + filter: brightness(1.06); +} + +.btn.ghost { + background: transparent; +} + +.main { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1; + min-height: 0; +} + +.editor-wrap { + background: var(--bg-elevated); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + flex: 1 1 auto; + min-height: 120px; + display: flex; +} + +.editor { + width: 100%; + height: 100%; + min-height: 120px; + border: 0; + resize: none; + padding: 12px 14px; + font: 14px/1.6 var(--font); + color: var(--ink); + background: transparent; + outline: none; +} + +.editor::placeholder { + color: var(--ink-muted); + opacity: 0.75; +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + flex-shrink: 0; +} + +.stats.secondary { + grid-template-columns: repeat(6, 1fr); +} + +.stat-card { + background: var(--bg-elevated); + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px 9px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.stat-card.hero { + background: var(--hero); + border-color: transparent; + padding: 12px 14px 11px; +} + +.stat-label { + font-size: 11px; + font-weight: 500; + color: var(--ink-muted); + letter-spacing: 0.02em; + white-space: nowrap; +} + +.stat-card.hero .stat-label { + color: color-mix(in srgb, var(--hero-ink) 72%, var(--ink-muted)); +} + +.stat-value { + font-size: 22px; + font-weight: 650; + line-height: 1.1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stat-card.hero .stat-value { + font-size: 28px; + color: var(--hero-ink); +} + +.stat-value.reading { + font-size: 22px; +} + +.stats.secondary .stat-value { + font-size: 16px; + font-weight: 600; +} + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%) translateY(8px); + background: var(--ink); + color: var(--bg); + padding: 8px 14px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 20; +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +@media (max-width: 720px) { + body { + overflow: auto; + } + + .app { + height: auto; + min-height: 100vh; + } + + .stats.secondary { + grid-template-columns: repeat(3, 1fr); + } + + .stat-card.hero .stat-value { + font-size: 24px; + } + + .stat-value.reading { + font-size: 18px; + } +} + +@media (max-width: 420px) { + .app { + padding: 10px; + } + + .stats { + grid-template-columns: 1fr 1fr 1fr; + } + + .stats.secondary { + grid-template-columns: 1fr 1fr; + } +} diff --git a/plugins/word-count/index.html b/plugins/word-count/index.html new file mode 100644 index 000000000..8489ba585 --- /dev/null +++ b/plugins/word-count/index.html @@ -0,0 +1,84 @@ + + + + + + 字数统计 + + + + +
+
+
+ +

字数统计

+
+
+ + + +
+
+ +
+
+ + +
+ +
+
+
汉字
+
0
+
+
+
英文
+
0
+
+
+
阅读
+
0 秒
+
+
+ +
+
+
总字符
+
0
+
+
+
不含空白
+
0
+
+
+
数字
+
0
+
+
+
标点
+
0
+
+
+
行
+
0
+
+
+
段落
+
0
+
+
+
+ + +
+ + + + + diff --git a/plugins/word-count/index.js b/plugins/word-count/index.js new file mode 100644 index 000000000..f7862182f --- /dev/null +++ b/plugins/word-count/index.js @@ -0,0 +1,221 @@ +(function () { + "use strict"; + + var input = document.getElementById("input-text"); + var toastEl = document.getElementById("toast"); + var btnCopy = document.getElementById("btn-copy"); + var btnClear = document.getElementById("btn-clear"); + var btnPaste = document.getElementById("btn-paste"); + + var lastStats = null; + var debounceTimer = null; + var toastTimer = null; + + function z() { + return typeof window !== "undefined" && window.ztools ? window.ztools : null; + } + + function setTheme(isDark) { + document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light"); + } + + function applyThemeFromHost() { + var api = z(); + if (!api) { + var prefersDark = + window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + setTheme(prefersDark); + return; + } + try { + if (typeof api.isDarkColors === "function") { + setTheme(!!api.isDarkColors()); + } else if (typeof api.getThemeInfo === "function") { + var info = api.getThemeInfo(); + setTheme(!!(info && info.isDark)); + } + if (typeof api.onThemeChange === "function") { + api.onThemeChange(function (themeInfo) { + setTheme(!!(themeInfo && themeInfo.isDark)); + }); + } + } catch (e) { + console.warn("theme init failed", e); + } + } + + function renderStats(stats) { + lastStats = stats; + var keys = [ + "han", + "latinWords", + "readingLabel", + "charCount", + "charNoSpace", + "numbers", + "punctuation", + "lines", + "paragraphs", + ]; + keys.forEach(function (key) { + var el = document.querySelector('[data-key="' + key + '"]'); + if (!el) return; + el.textContent = String(stats[key]); + }); + } + + function refresh(text) { + var stats = WordCount.countText(text || ""); + renderStats(stats); + return stats; + } + + function scheduleRefresh() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { + refresh(input.value); + }, 80); + } + + function showToast(message) { + var api = z(); + if (api && typeof api.showToast === "function") { + try { + api.showToast(message); + return; + } catch (e) { + /* fall through */ + } + } + if (!toastEl) return; + toastEl.textContent = message; + toastEl.hidden = false; + toastEl.classList.add("show"); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function () { + toastEl.classList.remove("show"); + setTimeout(function () { + toastEl.hidden = true; + }, 200); + }, 1600); + } + + async function copyReport() { + var stats = lastStats || refresh(input.value); + var text = WordCount.formatFullReport(stats); + var api = z(); + if (api && typeof api.copyText === "function") { + try { + var ok = api.copyText(text); + showToast(ok ? "已复制" : "复制失败"); + return; + } catch (e) { + /* fall through */ + } + } + try { + await navigator.clipboard.writeText(text); + showToast("已复制"); + } catch (e) { + showToast("复制失败"); + } + } + + async function pasteFromClipboard() { + var api = z(); + if (api && api.clipboard && typeof api.clipboard.getHistory === "function") { + try { + var history = await api.clipboard.getHistory(1, 1, "text"); + var items = (history && (history.items || history.list || history)) || []; + if (Array.isArray(items) && items[0]) { + var content = items[0].content || items[0].text || items[0].data || ""; + if (typeof content === "string" && content) { + input.value = content; + refresh(input.value); + showToast("已粘贴"); + return; + } + } + } catch (e) { + /* fall through */ + } + } + try { + var text = await navigator.clipboard.readText(); + input.value = text || ""; + refresh(input.value); + showToast(text ? "已粘贴" : "剪贴板为空"); + } catch (e) { + showToast("无法读取剪贴板"); + input.focus(); + } + } + + function clearAll() { + input.value = ""; + refresh(""); + input.focus(); + } + + function setText(text) { + input.value = typeof text === "string" ? text : ""; + refresh(input.value); + } + + function onPluginEnter(param) { + if (!param) return; + var payload = param.payload; + if (typeof payload === "string" && payload) { + setText(payload); + } else if (payload && typeof payload === "object") { + var maybe = + payload.text || + payload.content || + payload.payload || + (typeof payload.data === "string" ? payload.data : ""); + if (typeof maybe === "string" && maybe) setText(maybe); + } + } + + function bindHostEvents() { + var api = z(); + if (!api) return; + try { + if (typeof api.onPluginEnter === "function") { + api.onPluginEnter(onPluginEnter); + } else if (typeof api.onPluginReady === "function") { + api.onPluginReady(onPluginEnter); + } + } catch (e) { + console.warn("onPluginEnter bind failed", e); + } + } + + input.addEventListener("input", scheduleRefresh); + btnCopy.addEventListener("click", function () { + copyReport(); + }); + btnClear.addEventListener("click", clearAll); + btnPaste.addEventListener("click", function () { + pasteFromClipboard(); + }); + + document.addEventListener("keydown", function (e) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "enter") { + e.preventDefault(); + copyReport(); + } + }); + + applyThemeFromHost(); + bindHostEvents(); + refresh(input.value); + + try { + var params = new URLSearchParams(window.location.search); + var q = params.get("text"); + if (q) setText(q); + } catch (e) { + /* ignore */ + } +})(); diff --git a/plugins/word-count/logo.png b/plugins/word-count/logo.png new file mode 100644 index 000000000..27e23a5aa Binary files /dev/null and b/plugins/word-count/logo.png differ diff --git a/plugins/word-count/plugin.json b/plugins/word-count/plugin.json new file mode 100644 index 000000000..f65a31646 --- /dev/null +++ b/plugins/word-count/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "word-count", + "title": "字数统计", + "description": "写作向字数统计:汉字、英文单词、标点与预估阅读时长,支持粘贴输入与搜索框即时结果。", + "version": "1.0.1", + "author": "ZToolsWordCount", + "main": "index.html", + "logo": "logo.png", + "preload": "preload.js", + "features": [ + { + "code": "word-count", + "explain": "统计汉字 / 英文单词 / 预估阅读时长", + "cmds": [ + "字数统计", + "字数", + "wc", + "wordcount", + { + "type": "over", + "label": "字数统计", + "minLength": 1, + "maxLength": 10000 + } + ] + } + ] +} diff --git a/plugins/word-count/preload.js b/plugins/word-count/preload.js new file mode 100644 index 000000000..728e59bd1 --- /dev/null +++ b/plugins/word-count/preload.js @@ -0,0 +1,82 @@ +/** + * ZTools preload:注册主搜索即时字数结果 + * 与 count.js 保持同一套统计口径(此处内联一份轻量实现,避免加载时序问题) + */ +(function () { + "use strict"; + + function countTextLite(text) { + if (typeof text !== "string") text = ""; + var han = (text.match(/\p{Script=Han}/gu) || []).length; + var latinWords = (text.match(/\p{Script=Latin}+(?:['’]\p{Script=Latin}+)*/gu) || []) + .length; + var charCount = Array.from(text).length; + var readingMinutes = han / 400 + latinWords / 200; + if (charCount > 0 && readingMinutes < 1 / 60) readingMinutes = 1 / 60; + var readingLabel; + if (readingMinutes <= 0) readingLabel = "0 秒"; + else if (readingMinutes < 1) readingLabel = Math.max(1, Math.round(readingMinutes * 60)) + " 秒"; + else if (readingMinutes < 60) readingLabel = Math.round(readingMinutes) + " 分钟"; + else { + var h = Math.floor(readingMinutes / 60); + var rest = Math.round(readingMinutes % 60); + readingLabel = rest > 0 ? h + " 小时 " + rest + " 分钟" : h + " 小时"; + } + return { + charCount: charCount, + han: han, + latinWords: latinWords, + readingLabel: readingLabel, + }; + } + + function pickPayload(queryData) { + if (!queryData) return ""; + if (typeof queryData === "string") return queryData; + return ( + queryData.payload || + queryData.text || + queryData.query || + queryData.content || + (typeof queryData.data === "string" ? queryData.data : "") || + "" + ); + } + + function registerMainPush() { + if (!window.ztools || typeof window.ztools.onMainPush !== "function") return; + + window.ztools.onMainPush( + function (queryData) { + var text = pickPayload(queryData); + if (!text || !text.trim()) return []; + // 避免对极短指令词误报 + if (text.trim().length < 2) return []; + var s = countTextLite(text); + return [ + { + title: "字数统计", + description: + "汉字 " + + s.han + + " · 英文 " + + s.latinWords + + " · 阅读约 " + + s.readingLabel, + icon: "logo.png", + featureCode: "word-count", + }, + ]; + }, + function (selectData) { + return true; + } + ); + } + + try { + registerMainPush(); + } catch (e) { + console.warn("onMainPush register failed", e); + } +})(); diff --git a/plugins/word-count/test-count.js b/plugins/word-count/test-count.js new file mode 100644 index 000000000..5debffcc3 --- /dev/null +++ b/plugins/word-count/test-count.js @@ -0,0 +1,43 @@ +const { countText, formatFullReport, formatReadingTime } = require("./count.js"); + +function assert(cond, msg) { + if (!cond) { + console.error("FAIL:", msg); + process.exitCode = 1; + } else { + console.log("OK:", msg); + } +} + +let s = countText(""); +assert( + s.charCount === 0 && s.han === 0 && s.latinWords === 0 && s.lines === 0, + "empty" +); + +s = countText("你好世界"); +assert(s.han === 4 && s.latinWords === 0 && s.charCount === 4, "pure han"); + +s = countText("Hello world's test"); +assert(s.latinWords === 3 && s.han === 0, "latin words with apostrophe: " + s.latinWords); + +s = countText("Hello 世界 123!"); +assert( + s.han === 2 && s.latinWords === 1 && s.numbers === 1 && s.punctuation >= 1, + "mixed " + JSON.stringify({ han: s.han, words: s.latinWords, num: s.numbers, p: s.punctuation }) +); + +s = countText("😀字"); +assert(s.charCount === 2 && s.han === 1, "emoji code points: " + s.charCount); + +s = countText("a\nb\n\nc"); +assert(s.lines === 4 && s.paragraphs === 2, "lines/para " + s.lines + "/" + s.paragraphs); + +s = countText("一".repeat(400)); +assert(s.readingMinutes === 1, "400 han = 1 min, got " + s.readingMinutes); + +assert(formatReadingTime(0.01).includes("秒"), "seconds label"); +assert(formatReadingTime(1.2).includes("分钟"), "minutes label"); + +console.log("report:", formatFullReport(countText("Hello 世界"))); +console.log("done"); diff --git a/plugins/word-count/test-ui-structure.js b/plugins/word-count/test-ui-structure.js new file mode 100644 index 000000000..7c7ff4b5d --- /dev/null +++ b/plugins/word-count/test-ui-structure.js @@ -0,0 +1,46 @@ +const fs = require("fs"); +const html = fs.readFileSync("index.html", "utf8"); +const js = fs.readFileSync("index.js", "utf8"); +const css = fs.readFileSync("index.css", "utf8"); + +const keys = [...html.matchAll(/data-key="([^"]+)"/g)].map((m) => m[1]); +const needed = [ + "han", + "latinWords", + "readingLabel", + "charCount", + "charNoSpace", + "numbers", + "punctuation", + "lines", + "paragraphs", +]; +const missing = needed.filter((k) => !keys.includes(k)); +console.log("data-keys missing:", missing.length ? missing.join(",") : "none"); + +const ids = ["input-text", "btn-copy", "btn-clear", "btn-paste", "toast", "stats"]; +for (const id of ids) { + console.log("id", id, html.includes('id="' + id + '"') ? "OK" : "MISSING"); +} + +const banned = [ + "AI生成", + "写作向", + "中文字符", + "拉丁词", + "400 汉字", + "实时统计", + "等待输入", + "口径:", + "stat-hint", + "footer-note", + "tagline", + "editor-meta", +]; +for (const s of banned) { + const hit = html.includes(s) || css.includes(s) || js.includes(s); + console.log("ban", s, hit ? "FOUND" : "clean"); +} + +console.log("dark theme:", /html\[data-theme/.test(css)); +console.log("toast styles:", css.includes(".toast"));