annotatable-html 0.0.0-stage → 1.45.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ /* annotatable.theme.css —— 主题自定义文件
2
+ * 「annotatable new」生成文档时会自动应用本文件:
3
+ * 1. 下面的 :root 变量块会替换文档的默认配色;
4
+ * 2. 其余 CSS 会追加到文档样式末尾(可覆盖任何默认样式)。
5
+ * 不需要主题时删除本文件即可恢复默认。 */
6
+
7
+ :root{
8
+ --paper:#f6f4ee; /* 页面背景 */
9
+ --card:#ffffff; /* 文档卡片背景 */
10
+ --ink:#2b2721; /* 正文颜色 */
11
+ --muted:#8a8375; /* 次要文字 */
12
+ --line:#e7e2d6; /* 边框线 */
13
+ --accent:#b45309; /* 主题色(按钮/标题/强调) */
14
+ --accent-soft:#fdf3e3; /* 主题色浅底 */
15
+ --hl:#fde047; /* 划线高亮 */
16
+ --hl2:#fb923c; /* 有批语的高亮 */
17
+ --danger:#dc2626; /* 危险操作 */
18
+
19
+ /* 版式挂钩(v1.17.0,可选):只写想改的,其余保持模板默认 */
20
+ /* --wrap-max:980px; */ /* 正文版心最大宽度 */
21
+ /* --root-fs:16px; */ /* 根字号(rem 基准,全局缩放) */
22
+ /* --lh:1.75; */ /* 正文行高 */
23
+ /* --font-body:"Segoe UI",system-ui,sans-serif; */ /* 正文字体栈 */
24
+ /* --font-head:var(--font-body); */ /* 标题字体栈 */
25
+ }
26
+
27
+ /* ↓↓↓ 额外自定义样式写在下面(会覆盖默认样式)↓↓↓ */
28
+ /* 想可视化调参:运行 annotatable studio 打开主题工坊(实时预览+一键保存)。 */
@@ -0,0 +1,6 @@
1
+ ---
2
+ description: HTML4agent:生成可批注 HTML、读批注或接管指定页面
3
+ ---
4
+
5
+ 读取并执行 annotatable-html skill。将 $ARGUMENTS 作为用户的文档需求、文件路径或 localhost 页面地址。
6
+ 按当前任务选择生成、读取或恢复值守;已有页面不重建。流程与写回规则统一以 skill 为准。
@@ -0,0 +1,12 @@
1
+ // The official shell.env hook supplies context only; it never starts a model or changes permissions.
2
+ export const AnnotatableHtmlContext = async ({ serverUrl, directory }) => ({
3
+ 'shell.env': async (input, output) => {
4
+ if (!input.sessionID) {
5
+ delete output.env.ANNOTATABLE_OPENCODE_SESSION_ID;
6
+ return;
7
+ }
8
+ output.env.ANNOTATABLE_OPENCODE_SESSION_ID = input.sessionID;
9
+ output.env.ANNOTATABLE_OPENCODE_SERVER_URL = serverUrl.toString();
10
+ output.env.ANNOTATABLE_OPENCODE_DIRECTORY = directory;
11
+ }
12
+ });
@@ -0,0 +1,14 @@
1
+ # 既有扩展回答数据
2
+
3
+ 旧文档可能含 answers-store。普通批注回复不会创建或更新它;页面保存、旧页升级和回复须保留其内容。
4
+
5
+ 独立内容工具需要维护既有数据时,可显式调用兼容接口:
6
+
7
+ ```sh
8
+ annotatable answer 文档
9
+ annotatable answer 文档 数据.json
10
+ ```
11
+
12
+ 数据格式仍为 `[{annId,title,rungs:[{title,body,checkpoint?,answer?}]}]`,用于兼容已有文件。
13
+ 本接口只保存数据,不选择教学方式、分阶内容或检查点。用户未要求时不调用。
14
+ 旧 teach 命令已停用。课程生成与教学模式由独立工作流实现,批注工具只提供通用的读写与展示。
@@ -0,0 +1,47 @@
1
+ # 后台监听
2
+
3
+ 在目标会话里启动 poll;命中后读批注、回复,再挂同一命令。不要让前台 shell 长期阻塞。
4
+
5
+ ## Codex
6
+
7
+ ```sh
8
+ annotatable poll 文档或localhost页面地址 --codex
9
+ ```
10
+
11
+ 默认读取 CODEX_THREAD_ID;可用 --codex-thread ID 指定会话。桌面会话优先使用继承的 CODEX_APP_TOOLS_PIPE_PATH,通过桌面接口向同一会话发送“批注工具自动通知”。启动后台进程时保留此环境变量;不要改用其他服务恢复该会话。
12
+ 无桌面连接时需要 Node.js 22+ 和托管当前会话的 app-server。仅有历史记录、会话未加载时会提前报错。--codex-transport desktop|app-server 可指定入口。--codex-turn ID 可指定当前来源轮次;省略时工具只读查询历史。找不到原生程序时用 --codex-bin 指定其路径。
13
+ 收到自动通知或 annotatable_poll 工具输出中的 POLL_HIT 后回到主流程。CODEX_NOTIFY_ERROR 表示预检或通知失败;发送超时先核对会话,不要直接重发。CODEX_READY 只表示预检通过,CODEX_NOTIFIED 只表示接口接受;以空闲唤醒、文档中的完成回复和重挂监听确认结果。
14
+
15
+ 用户要求桌面退出后继续回复时,在目标桌面聊天内显式运行 `annotatable duty 文档或HTTP页面地址 --codex`(Windows、Node.js 22+)。使用输出的 pageUrl;后台程序管理保存服务和监听。收到自动续接通知后只读批注、回复并核对落盘,不再重挂 poll。同页同聊天只保留一个控制器。`duty 文档 --status` 查看状态,`duty 文档 --stop` 请求停止。该模式确认桌面进程退出后才无头接管;模型和权限不匹配时保留待办。它不设置系统自启。
16
+
17
+ ## OpenCode
18
+
19
+ 先运行 `annotatable skill --opencode`,重启 OpenCode 加载会话插件。
20
+
21
+ ```sh
22
+ annotatable poll 文档或localhost页面地址 --opencode
23
+ ```
24
+
25
+ 插件传递当前会话、服务地址和目录。手动连接可加 --opencode-url URL 和 --opencode-session ID。服务认证沿用 OPENCODE_SERVER_PASSWORD 与可选的 OPENCODE_SERVER_USERNAME;密码不写入 URL。
26
+ 收到带 POLL_HIT 的监听消息后回到主流程。OPENCODE_NOTIFY_ERROR 表示失败;OPENCODE_NOTIFIED 只表示接口接受。
27
+
28
+ Windows 使用 PowerShell 的 Start-Process -WindowStyle Hidden 启动 Node CLI,并把输出与错误重定向到日志。全局 CLI 路径可用 npm.cmd root -g 查询;脚本路径和文档路径含空格时分别加双引号。Node detached 不能保证 OpenCode 的 bash 工具及时返回。
29
+ 其他系统可用 shell 后台执行和输出重定向。
30
+
31
+ 需要原服务退出后继续时,在原会话运行 `annotatable duty 文档或HTTP页面地址 --opencode`。上下文参数与 poll 相同;同一控制器管理保存服务、在线通知及退出后的同编号恢复。后台确认原服务退出后才接班;重新发现唯一原服务后交还。原模型、提供方或权限变化时保留待办。它不新增插件或配置自启。
32
+
33
+ ## Claude Code
34
+
35
+ 普通监听使用 `annotatable poll 文档 --exit-code 1 --exit-code-timeout 0`。
36
+
37
+ 用户要求退出后继续时,用原生 Bash 工具以 `run_in_background=true` 运行 `annotatable duty 文档或HTTP页面地址 --claude`。缺少原会话信息时加 `--claude-session 原会话UUID`;原生程序可用 --claude-bin 指定。不要把命令挂在前台阻塞,也不要为在线通知另外启动 --resume 进程。
38
+
39
+ 首次注册会在该项目的 `.claude/settings.local.json` 安装一组 SessionStart、Stop 原生异步钩子,保留用户现有权限与钩子。首次命令的标准错误输出 CLAUDE_DUTY_READY;命中后输出处理指令,以退出码 1 通知原会话。当前进程尚未载入新钩子时,按通知由 agent 后台续挂 --listen;无需用户操作。原生钩子命中使用退出码 2 唤醒,之后 Stop 钩子自动接回,无需续挂命令。收到 POLL_HIT 就读批注、写 UTF-8 JSON 回复文件、运行 reply,再回读。不要重新注册已停止的控制器。
40
+
41
+ 后台确认原会话进程退出后,用原生 --resume 同一 UUID 无头回复。在原项目重开同一 UUID 即由 SessionStart 钩子自动接回,无需发送启动提示或手动重挂。钩子只查项目内已有会话锁,其他会话、无头子进程和已停止的注册直接退出。最后一个注册停止后移除本工具的项目钩子,保留用户配置。项目策略禁用钩子时如实报告,不修改策略。无需另挂 poll;控制器、保存服务和进程互斥均与 Codex、OpenCode 共用。
42
+
43
+ ## ZCode
44
+
45
+ 使用 `annotatable poll 文档`。当前不提供无头自动续接。
46
+
47
+ 普通超时和纯高亮不需要回答。监听心跳新鲜只代表进程在运行,确认自动回复须读回文档。
@@ -0,0 +1,37 @@
1
+ # 正文与数据操作
2
+
3
+ ## 批量回复
4
+
5
+ 写 replies.json,再执行 `annotatable reply 文档 replies.json`:
6
+
7
+ ```json
8
+ [{"id":"批注id","reply":"回复全文"}]
9
+ ```
10
+
11
+ 日常回复使用主流程的流式命令;文本很长时可用 --stdin 从文件传入。
12
+
13
+ ## 按批注修改正文
14
+
15
+ 写 edits.json,再执行 `annotatable edit 文档 edits.json`:
16
+
17
+ ```json
18
+ [{"id":"批注id","newText":"替换后的正文","reply":"修改说明"}]
19
+ ```
20
+
21
+ newText 为空表示删除原句。工具重算锚定并维护版本快照。混合指令与提问可在同一条目的 reply 中回答。
22
+ 用户正在编辑正文时,按工具的排队或让路结果处理,不抢写。收到 replay 重做请求时先重读正文,再生成新的改动。
23
+
24
+ ## 生成器重建
25
+
26
+ 纯文字修改使用 edit。确需重建时运行 `annotatable regen 活页 图纸直出` 查看手改清单;把手改同步进图纸后加 --go 写入。
27
+ 仅在用户明确要求丢弃手改时使用 --force。用户编辑期间,regen 退出码 3,应等收笔。
28
+
29
+ ## 版本与数据
30
+
31
+ 批注数据在 annotations-store;thread 保留完整对话,纯高亮不进入待回应清单。
32
+ 工具负责 rev、history、文档锁、原子写与侧车档案。不要手改这些字段,也不要手动删除 .annotatable 账本。
33
+ 默认 read 只输出待回应批注,无需读取整份 HTML 或扩展回答数据。
34
+
35
+ ## file:// 兜底
36
+
37
+ 无法起 serve 时才让用户直接打开文件,并授权浏览器写回。其余读取、回复和监听流程相同。
@@ -0,0 +1,330 @@
1
+ <!DOCTYPE html>
2
+ <!-- annotatable 主题工坊(v1.17.0)
3
+ * 由 `annotatable studio` 经 GET /__annot/studio 内存直出(core.studioHtml 装配)。
4
+ * 装配点:下方 style 块内的模板 CSS 标记、JS 里的模板全文与版本号占位。
5
+ * 实时预览 = 模板副本 + 工坊主题烤入(浏览器端按 lib/core.js applyTheme 同规则),
6
+ * 所见即所得;保存走 POST /__annot/theme(未从 CLI 启动时降级为下载文件)。 -->
7
+ <html lang="zh-CN">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>主题工坊 · annotatable 可批注模板</title>
12
+ <style id="studio-tpl-css">
13
+ /*__TPL_CSS__*/
14
+ </style>
15
+ <style>
16
+ /* 工坊自身骨架(st- 前缀,避免与模板类名相撞) */
17
+ html,body{height:100%}
18
+ body.st-body{margin:0;display:flex;flex-direction:column;background:#22242a;color:#e8e5df;
19
+ font:14px/1.6 "Segoe UI",system-ui,"PingFang SC","Microsoft YaHei",sans-serif;overflow:hidden}
20
+ .st-head{display:flex;align-items:baseline;gap:12px;padding:10px 16px;background:#1a1c21;border-bottom:1px solid #33363e}
21
+ .st-head h1{font-size:15px;margin:0;font-weight:700;color:#f0ede7}
22
+ .st-head .st-sub{font-size:12px;color:#9a968e}
23
+ .st-main{flex:1;display:flex;min-height:0}
24
+ .st-rail{width:320px;min-width:320px;overflow-y:auto;padding:14px 16px 24px;background:#26282f;border-right:1px solid #33363e}
25
+ .st-group{margin-bottom:18px}
26
+ .st-group>h2{font-size:12.5px;margin:0 0 8px;color:#c9a86b;letter-spacing:.08em;font-weight:700}
27
+ .st-chips{display:flex;gap:6px;flex-wrap:wrap}
28
+ .st-chip{cursor:pointer;border:1px solid #4a4d56;border-radius:8px;padding:4px 10px;font-size:12px;
29
+ color:#d8d5cf;background:#2e3138}
30
+ .st-chip:hover{border-color:#c9a86b}
31
+ .st-chip.on{border-color:#c9a86b;color:#f0e7d3;background:#3a3327}
32
+ .st-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 12px}
33
+ .st-cv{display:flex;align-items:center;gap:8px;font-size:12.5px;color:#c9c6bf}
34
+ .st-cv input[type=color]{width:34px;height:24px;border:1px solid #4a4d56;border-radius:5px;padding:0;background:none;cursor:pointer}
35
+ .st-row{display:flex;align-items:center;gap:8px;margin:7px 0;font-size:12.5px;color:#c9c6bf}
36
+ .st-row label{min-width:60px}
37
+ .st-row input[type=range]{flex:1;accent-color:#c9a86b}
38
+ .st-row .st-val{min-width:52px;text-align:right;color:#9a968e;font-variant-numeric:tabular-nums}
39
+ .st-row select,.st-row input[type=text]{flex:1;background:#2e3138;color:#e2dfd9;border:1px solid #4a4d56;
40
+ border-radius:6px;padding:4px 6px;font-size:12.5px}
41
+ .st-btns{display:flex;flex-direction:column;gap:8px}
42
+ .st-btn{cursor:pointer;border:0;border-radius:8px;padding:9px 12px;font-size:13.5px;font-weight:700;text-align:center}
43
+ .st-btn.primary{background:#c9a86b;color:#241d10}
44
+ .st-btn.primary:hover{background:#d8b87a}
45
+ .st-btn.ghost{background:#2e3138;color:#d8d5cf;border:1px solid #4a4d56}
46
+ .st-btn.ghost:hover{border-color:#c9a86b}
47
+ .st-note{font-size:12px;color:#8e8a82;line-height:1.7;margin-top:10px}
48
+ .st-css{width:100%;height:110px;background:#1d1f24;color:#a8c4a0;border:1px solid #33363e;border-radius:6px;
49
+ font:11.5px/1.5 ui-monospace,Consolas,monospace;padding:8px;resize:vertical;white-space:pre}
50
+ .st-prev{flex:1;display:flex;min-width:0;background:#fff}
51
+ .st-prev iframe{border:0;width:100%;height:100%;background:#fff}
52
+ .st-toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:#2e3138;color:#f0ede7;
53
+ border:1px solid #c9a86b;border-radius:10px;padding:10px 18px;font-size:13.5px;display:none;z-index:99;
54
+ box-shadow:0 6px 24px rgba(0,0,0,.4)}
55
+ @media print{ body.st-body .st-rail,body.st-body .st-head{display:none} }
56
+ </style>
57
+ </head>
58
+ <body class="st-body">
59
+ <div class="st-head">
60
+ <h1>🎨 主题工坊</h1>
61
+ <span class="st-sub">可批注模板可视化定制 · 左边调、右边看 · 保存后 new / md / inject 自动应用(<span id="st-ver"></span>)</span>
62
+ </div>
63
+ <div class="st-main">
64
+ <aside class="st-rail">
65
+ <div class="st-group">
66
+ <h2>配色预设</h2>
67
+ <div class="st-chips" id="st-presets"></div>
68
+ </div>
69
+ <div class="st-group">
70
+ <h2>颜色</h2>
71
+ <div class="st-grid" id="st-colors"></div>
72
+ </div>
73
+ <div class="st-group">
74
+ <h2>字体</h2>
75
+ <div class="st-row"><label>正文字体</label><select id="st-fbody"></select></div>
76
+ <div class="st-row" id="st-fbody-custom" style="display:none"><label>自定义</label><input type="text" id="st-fbody-x" placeholder='"Georgia", serif'></div>
77
+ <div class="st-row"><label>标题字体</label><select id="st-fhead"></select></div>
78
+ <div class="st-row" id="st-fhead-custom" style="display:none"><label>自定义</label><input type="text" id="st-fhead-x" placeholder='"Georgia", serif'></div>
79
+ </div>
80
+ <div class="st-group">
81
+ <h2>版式</h2>
82
+ <div class="st-row"><label>根字号</label><input type="range" id="st-fs" min="13" max="20" step="0.5"><span class="st-val" id="st-fs-v"></span></div>
83
+ <div class="st-row"><label>行高</label><input type="range" id="st-lh" min="1.4" max="2.4" step="0.05"><span class="st-val" id="st-lh-v"></span></div>
84
+ <div class="st-row"><label>页面宽度</label><input type="range" id="st-w" min="640" max="1400" step="20"><span class="st-val" id="st-w-v"></span></div>
85
+ </div>
86
+ <div class="st-group st-btns">
87
+ <button class="st-btn primary" id="st-save">💾 保存到当前目录</button>
88
+ <button class="st-btn primary" id="st-save-g">🌍 设为全局默认</button>
89
+ <button class="st-btn ghost" id="st-dl">⬇ 下载 annotatable.theme.css</button>
90
+ <button class="st-btn ghost" id="st-reset">↺ 恢复默认</button>
91
+ </div>
92
+ <div class="st-group">
93
+ <h2>theme.css 预览</h2>
94
+ <textarea class="st-css" id="st-cssout" readonly></textarea>
95
+ </div>
96
+ <p class="st-note">
97
+ · 右侧即真实模板渲染:批注、划线、🧭 导航窗格都能直接试;窗格默认泊位,拖出/缩放可验证页面宽度与导航让位互不干扰。<br>
98
+ · 「保存到当前目录」写入 annotatable.theme.css;「设为全局默认」写入 ~/.annotatable/theme.css(目录级优先于全局)。
99
+ </p>
100
+ </aside>
101
+ <div class="st-prev"><iframe id="st-frame" title="模板实时预览"></iframe></div>
102
+ </div>
103
+ <div class="st-toast" id="st-toast"></div>
104
+ <script>
105
+ 'use strict';
106
+ /* 当前模板全文(由 core.studioHtml 装配;脚本闭串已在源码层转义,防本块提前闭合) */
107
+ var TPL_SRC = "__TPL_SRC__";
108
+
109
+ /* 预览示例正文(标题供 🧭 导航窗格抓取;mark.annot 观察划线配色) */
110
+ var SAMPLE_HTML = [
111
+ '<p class="kicker">示例预览</p>',
112
+ '<h1>示例文档标题</h1>',
113
+ '<p class="meta">主题工坊示例 · 用于观察配色、字体与版式效果</p>',
114
+ '<p>这是一段用于观察<mark class="annot">正文颜色与字体</mark>的示例文字。段落间距、行高在这里最直观,改动滑杆后稍候片刻即可看到效果。</p>',
115
+ '<h2>一、排版元素</h2>',
116
+ '<p>正文直铺在页面底色上(无白卡片),留白交给版心。<strong>强调文字</strong>与<code>行内代码</code>随主题色联动。</p>',
117
+ '<blockquote>引用块:观察左边线与底色。</blockquote>',
118
+ '<h3>表格与代码</h3>',
119
+ '<div class="table-wrap"><table><thead><tr><th>项目</th><th>观察点</th></tr></thead><tbody>',
120
+ '<tr><td>表格</td><td>边线颜色、表头底色</td></tr>',
121
+ '<tr><td>代码块</td><td>底色与等宽字体</td></tr>',
122
+ '</tbody></table></div>',
123
+ '<pre><code>const theme = "studio"; // 观察代码块底色</code></pre>',
124
+ '<h2>二、批注层外观</h2>',
125
+ '<p>划线高亮:<mark class="annot">黄色为普通划线</mark>;<mark class="annot">橙色代表已有批语</mark>。试着在预览里选中一段文字划线,看看高亮是否顺眼。</p>',
126
+ '<ul><li>列表项观察行距与标记颜色</li><li>第二项</li></ul>',
127
+ '<p>页面宽度滑杆与 🧭 导航窗格互不干扰:窗格泊位时正文自动让位左移,拖窄、拖宽、拖出窗格都可以试。</p>'
128
+ ].join('\n');
129
+
130
+ /* ---------- 状态 ---------- */
131
+ function parseRoot(src) {
132
+ var m = src.match(/:root\s*\{([^}]*)\}/);
133
+ var vars = {};
134
+ if (m) m[1].replace(/\/\*[\s\S]*?\*\//g, '').split(';').forEach(function (kv) {
135
+ var i = kv.indexOf(':');
136
+ if (i > 0) vars[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
137
+ });
138
+ return vars;
139
+ }
140
+ var DEFAULTS = parseRoot(TPL_SRC);
141
+ var state = {};
142
+ Object.keys(DEFAULTS).forEach(function (k) { state[k] = DEFAULTS[k]; });
143
+
144
+ var COLOR_VARS = [
145
+ ['--bg', '页面底色'], ['--bg2', '次表面'], ['--card', '卡片'], ['--accent', '强调色'],
146
+ ['--text', '正文'], ['--border', '边线'], ['--hl', '划线高亮'], ['--hl2', '批语高亮'], ['--danger', '危险']
147
+ ];
148
+ var FONT_OPTS = [
149
+ ['', '默认(跟随模板)', ''],
150
+ ['hei', '黑体(系统无衬线)', '"Microsoft YaHei","PingFang SC","HarmonyOS Sans SC",sans-serif'],
151
+ ['song', '宋体(衬线)', '"Noto Serif SC","Source Han Serif SC","SimSun",serif'],
152
+ ['kai', '楷体', '"Kaiti SC","STKaiti","KaiTi",serif'],
153
+ ['mono', '等宽', 'ui-monospace,Consolas,"Courier New",monospace'],
154
+ ['x', '自定义…', null]
155
+ ];
156
+ var PRESETS = [
157
+ ['暖白棕(默认)', {}],
158
+ ['冷灰蓝', { '--bg': '#f3f5f8', '--bg2': '#e7ebf1', '--card': '#ffffff', '--accent': '#4a6fa5', '--text': '#2a3440', '--border': '#d3dae2', '--hl': '#ffd54f', '--hl2': '#ff9e40', '--danger': '#d32f2f' }],
159
+ ['护眼米绿', { '--bg': '#e9efe2', '--bg2': '#dce5d1', '--card': '#f7faf3', '--accent': '#4a7c59', '--text': '#2f3b2f', '--border': '#c8d6c2', '--hl': '#ffe082', '--hl2': '#ffb74d', '--danger': '#c62828' }],
160
+ ['暗色', { '--bg': '#1e2025', '--bg2': '#26292f', '--card': '#2b2e35', '--accent': '#d8a657', '--text': '#e6e2d8', '--border': '#3a3e47', '--hl': '#a98f2f', '--hl2': '#b05f34', '--danger': '#e05252' }]
161
+ ];
162
+
163
+ /* ---------- 控件 ---------- */
164
+ function el(id) { return document.getElementById(id); }
165
+ function buildColors() {
166
+ var g = el('st-colors');
167
+ COLOR_VARS.forEach(function (cv) {
168
+ var d = document.createElement('label');
169
+ d.className = 'st-cv';
170
+ var inp = document.createElement('input');
171
+ inp.type = 'color'; inp.id = 'c' + cv[0].slice(1);
172
+ var cap = document.createElement('span'); cap.textContent = cv[1];
173
+ d.appendChild(inp); d.appendChild(cap); g.appendChild(d);
174
+ inp.addEventListener('input', function () { state[cv[0]] = inp.value; markPreset(null); onChange(); });
175
+ });
176
+ }
177
+ function buildFonts(selId, customRowId, customId, varName) {
178
+ var sel = el(selId), row = el(customRowId), txt = el(customId);
179
+ FONT_OPTS.forEach(function (fo) {
180
+ var o = document.createElement('option');
181
+ o.value = fo[0]; o.textContent = fo[1];
182
+ sel.appendChild(o);
183
+ });
184
+ function syncSel() {
185
+ var cur = state[varName] || '';
186
+ if (cur === DEFAULTS[varName]) { sel.value = ''; row.style.display = 'none'; return; }
187
+ var hit = FONT_OPTS.filter(function (fo) { return fo[2] === cur; })[0];
188
+ if (hit) { sel.value = hit[0]; row.style.display = 'none'; }
189
+ else { sel.value = 'x'; row.style.display = 'flex'; txt.value = cur; }
190
+ }
191
+ sel.addEventListener('change', function () {
192
+ var fo = FONT_OPTS.filter(function (f) { return f[0] === sel.value; })[0];
193
+ if (fo && fo[2]) { state[varName] = fo[2]; row.style.display = 'none'; }
194
+ else if (fo) { state[varName] = ''; row.style.display = 'none'; }
195
+ else { row.style.display = 'flex'; txt.focus(); }
196
+ onChange();
197
+ });
198
+ txt.addEventListener('input', function () { state[varName] = txt.value.trim(); onChange(); });
199
+ syncSel();
200
+ return syncSel;
201
+ }
202
+ function buildSlider(id, vid, varName, unit) {
203
+ var s = el(id), v = el(vid);
204
+ var def = parseFloat(DEFAULTS[varName]) || 0;
205
+ if (def < parseFloat(s.min) || def > parseFloat(s.max)) { s.min = Math.floor(def); s.max = Math.ceil(def) + 2; }
206
+ s.value = def; v.textContent = def + unit;
207
+ s.addEventListener('input', function () {
208
+ state[varName] = s.value + unit;
209
+ v.textContent = s.value + unit;
210
+ onChange();
211
+ });
212
+ return function sync() { def = parseFloat(DEFAULTS[varName]) || 0; s.value = def; v.textContent = def + unit; };
213
+ }
214
+ var syncFonts = [], syncSliders = [];
215
+ function buildPresets() {
216
+ var box = el('st-presets');
217
+ PRESETS.forEach(function (pr, ix) {
218
+ var b = document.createElement('button');
219
+ b.type = 'button'; b.className = 'st-chip'; b.textContent = pr[0];
220
+ b.addEventListener('click', function () {
221
+ COLOR_VARS.forEach(function (cv) { state[cv[0]] = pr[1][cv[0]] || DEFAULTS[cv[0]]; });
222
+ markPreset(ix); syncColorInputs(); onChange();
223
+ });
224
+ box.appendChild(b);
225
+ });
226
+ markPreset(0);
227
+ }
228
+ function markPreset(ix) {
229
+ var chips = el('st-presets').children;
230
+ for (var i = 0; i < chips.length; i++) chips[i].classList.toggle('on', i === ix);
231
+ }
232
+ function syncColorInputs() {
233
+ COLOR_VARS.forEach(function (cv) {
234
+ var inp = el('c' + cv[0].slice(1));
235
+ var v = state[cv[0]] || '';
236
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) inp.value = v;
237
+ });
238
+ }
239
+
240
+ /* ---------- 主题 CSS 与预览 ---------- */
241
+ function themeCss() {
242
+ var diffs = [];
243
+ Object.keys(DEFAULTS).forEach(function (k) {
244
+ if (state[k] !== DEFAULTS[k] && state[k] !== '') diffs.push(' ' + k + ':' + state[k] + ';');
245
+ });
246
+ if (!diffs.length) return '';
247
+ return ':root{\n' + diffs.join('\n') + '\n}\n';
248
+ }
249
+ function buildPreviewDoc() {
250
+ var doc = TPL_SRC;
251
+ var css = themeCss();
252
+ if (css) {
253
+ var rm = css.match(/:root\s*\{[^}]*\}/);
254
+ if (rm) doc = doc.replace(/:root\s*\{[^}]*\}/, function () { return rm[0]; });
255
+ var rest = rm ? css.replace(rm[0], '') : css;
256
+ if (rest.trim()) doc = doc.replace('</style>', function () { return rest + '\n</style>'; });
257
+ }
258
+ doc = doc.replace(/<article>[\s\S]*?<\/article>/, function () { return '<article>\n' + SAMPLE_HTML + '\n </article>'; });
259
+ doc = doc.replace(/<title>[\s\S]*?<\/title>/, '<title>主题工坊 · 预览</title>');
260
+ var iS = doc.lastIndexOf('<script>');
261
+ doc = doc.slice(0, iS + 8) + '\nwindow.__ANNOT_NO_BINDNUDGE__=1;' + doc.slice(iS + 8);
262
+ return doc;
263
+ }
264
+ var rebuildTimer = null;
265
+ function onChange() {
266
+ el('st-cssout').value = themeCss() || '(当前与默认一致,保存空主题即可恢复默认)';
267
+ clearTimeout(rebuildTimer);
268
+ rebuildTimer = setTimeout(function () { el('st-frame').srcdoc = buildPreviewDoc(); }, 400);
269
+ }
270
+
271
+ /* ---------- 保存 ---------- */
272
+ function toast(msg) {
273
+ var t = el('st-toast');
274
+ t.textContent = msg; t.style.display = 'block';
275
+ clearTimeout(t._h);
276
+ t._h = setTimeout(function () { t.style.display = 'none'; }, 2600);
277
+ }
278
+ function saveTheme(scope) {
279
+ var css = themeCss();
280
+ fetch('/__annot/theme', {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({ scope: scope, css: css })
284
+ }).then(function (r) {
285
+ if (!r.ok) return r.text().then(function (tx) { throw new Error(tx); });
286
+ return r.json();
287
+ }).then(function (o) {
288
+ toast(scope === 'global' ? '✅ 已设为全局默认(' + o.path + ')' : '✅ 已保存到当前目录(' + o.path + ')');
289
+ }).catch(function () {
290
+ if (!css) { toast('当前与默认一致,无需保存'); return; }
291
+ download(css); // 未从 CLI 启动(或服务未开)→ 下载兜底
292
+ toast('服务不可达,已转为下载 annotatable.theme.css,请放进文档目录');
293
+ });
294
+ }
295
+ function download(css) {
296
+ var a = document.createElement('a');
297
+ a.href = URL.createObjectURL(new Blob([css], { type: 'text/css' }));
298
+ a.download = 'annotatable.theme.css';
299
+ document.body.appendChild(a); a.click(); a.remove();
300
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 3000);
301
+ }
302
+
303
+ /* ---------- 启动 ---------- */
304
+ buildPresets();
305
+ buildColors();
306
+ syncColorInputs();
307
+ syncFonts.push(buildFonts('st-fbody', 'st-fbody-custom', 'st-fbody-x', '--font-body'));
308
+ syncFonts.push(buildFonts('st-fhead', 'st-fhead-custom', 'st-fhead-x', '--font-head'));
309
+ syncSliders.push(buildSlider('st-fs', 'st-fs-v', '--root-fs', 'px'));
310
+ syncSliders.push(buildSlider('st-lh', 'st-lh-v', '--lh', ''));
311
+ syncSliders.push(buildSlider('st-w', 'st-w-v', '--wrap-max', 'px'));
312
+ el('st-save').addEventListener('click', function () { saveTheme('cwd'); });
313
+ el('st-save-g').addEventListener('click', function () { saveTheme('global'); });
314
+ el('st-dl').addEventListener('click', function () {
315
+ var css = themeCss();
316
+ if (!css) { toast('当前与默认一致,无需下载'); return; }
317
+ download(css);
318
+ });
319
+ el('st-reset').addEventListener('click', function () {
320
+ Object.keys(DEFAULTS).forEach(function (k) { state[k] = DEFAULTS[k]; });
321
+ syncColorInputs(); syncFonts.forEach(function (f) { f(); }); syncSliders.forEach(function (f) { f(); });
322
+ markPreset(0); onChange();
323
+ toast('已恢复模板默认外观');
324
+ });
325
+ el('st-ver').textContent = '当前模板内置版 __PKG_VER__';
326
+ el('st-frame').srcdoc = buildPreviewDoc();
327
+ el('st-cssout').value = '(当前与默认一致,保存空主题即可恢复默认)';
328
+ </script>
329
+ </body>
330
+ </html>