dsh-maoying 0.1.23 → 0.1.25
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.
- package/README.md +1 -1
- package/docs/creator-panel.md +6 -2
- package/index.mjs +2 -1
- package/lib/base.css +15 -1
- package/lib/client.js +77 -41
- package/lib/panel-core.mjs +25 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ npm install -g dsh-maoying # 或装成全局 maoying 命令
|
|
|
73
73
|
|
|
74
74
|
DSH 左侧栏底部点「**毛颖创作台**」打开全页面板(改文件、跑流程之外,给肉眼看的驾驶舱):
|
|
75
75
|
|
|
76
|
-
- **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、正文与章纲就地编辑保存(正文 textarea
|
|
76
|
+
- **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、正文与章纲就地编辑保存(正文 textarea 自动保存——待写空章节与写作中(status=writing)章节打开即直接进编辑器、约 30 秒防抖落盘、手动/自动保存都停留在编辑态续写、回车换行自动加段首两个全角空格缩进、编辑栏实时显示正文字数、章节标题可在编辑器顶部修改(与正文一次原子保存);章纲 textarea 手动保存;文件在编辑期间被外部改过时拒绝保存并放回磁盘最新内容,不覆盖人工改动)、五维叙事字段、一键状态流转。
|
|
77
77
|
- **概览**:书稿卡右上「编辑」按钮展开 project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑(原「小说设置」tab 并入),保存保留文件里原有注释;表单载入后 project.yml 被外部(作者手改 / agent 推进阶段)改过时拒绝保存并提示重改,不覆盖人工改动。概览另设「分卷规划」卡,列出 volumes.yml 各卷定位与计划章节范围——拍板过的分卷有处可查,卡头「编辑源文件」可整文件编辑 volumes.yml。
|
|
78
78
|
- **受管文件写面**:角色/设定/科学设定卡与六本账本(关系/时间线/记忆/线索板/伏笔/分卷)均可从面板编辑源文件——墙头「新建」、卡详情「编辑源文件/删除文件」、各账本卡头「编辑源文件」(时间线/关系图空态也给入口);保存时结构校验(账本须是条目数组、实体卡须带 frontmatter 围栏,不合即拒存),外部改动保护与正文编辑同口径;章节阅读页可新建章纲(本章目标/节拍/出场角色/需埋需收伏笔/与前章接口五件骨架预填)、删除章纲与删除章节——删除均确认弹层,不可逆,后悔靠 git,静态体检会把孤儿引用标出来。
|
|
79
79
|
- **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版 ↗」徽章直连发布页,旁边「一键升级」按钮直接用 pnpm 把最新包装进当前 DSH 档(仅支持 profile 目录安装,pnpm 缺失时自动回落 corepack;检测到全局 `maoying` CLI 时顺带以同一精确版本更新,未装则跳过;装完需重启 DeepSeek 才生效);「检测更新」按钮随时手动现测线上最新版(绕过 1h 缓存——面板打开时只自动拉一次,页面开久了也不聋);齿轮打开系统设置,界面语言中文/English 可切、字号六档(100%–150%)整体缩放,均立即生效并记忆。
|
package/docs/creator-panel.md
CHANGED
|
@@ -54,7 +54,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
54
54
|
| `phase` | POST | `{ path, phase }` | `{ ok }`——project.yml phase 行替换;七态枚举 |
|
|
55
55
|
| `project-save` | POST | `{ path, values, base_hash? }`(values 为 8 平键 title/author/genre/length/language/phase/mode/description;base_hash 为表单载入时的 project.yml 指纹,即快照 `project_hash`) | `{ ok, project_hash }`——mutateProject 逐键改写保行尾注释、缺键按模板补齐;phase/mode 越枚举与空书名 422;base_hash 与写入前现读原文不一致 → 409「外部修改冲突」拒写不落盘(人工改动保护:表单载入后文件被作者/agent 改过即拒绝静默覆盖;缺省 base_hash 跳过检查,兼容旧调用方)。project_hash 为写后新指纹,客户端可作下一轮基线 |
|
|
56
56
|
| `style-save` | POST | `{ path, text, base_hash? }`(text 为 style.md 全文;base_hash 为编辑器载入时的 style.md 指纹,即 `style` 路由的 `hash`) | `{ ok, hash }`——style.md 整文件覆写:纯 markdown 基准文档、无行级键位可言,整文件替换即最小手术(写前读、原子写,与 agent 的写法兼容);text 非 string 422,空文本允许保存(空文件 = 无基准,agent 侧优雅降级);base_hash 与写入前现读原文不一致 → 409「外部修改冲突」拒写不落盘(人工改动保护,与 project-save 同口径;文件不存在且带 base_hash 同样 409;缺省 base_hash 跳过检查 = 创建路径)。hash 为写后新指纹,客户端作下一轮基线 |
|
|
57
|
-
| `chapter-save` | POST | `{ path, file, body, base_hash? }`(file 为 chapters/ 下文件名;body 为正文全文;base_hash 为进入编辑态时 `chapter` 路由的 `hash
|
|
57
|
+
| `chapter-save` | POST | `{ path, file, body, base_hash?, title? }`(file 为 chapters/ 下文件名;body 为正文全文;base_hash 为进入编辑态时 `chapter` 路由的 `hash`;title 可选,提供则同刀替换围栏内 title 行——非空 string 否则 422、title 行缺失/多处 409、行尾注释保留、值经 flowScalar 引号落值;缺省不动 title) | `{ ok, hash }`——正文整段替换:frontmatter 围栏(含闭合行)之前逐字节保留、围栏后整段替换(正文是自由形态文本,无行级键位可手术,整段替换即最小干预,对 git diff 的扰动天然只落在正文段内);body 非 string / 章节缺 frontmatter 围栏 422、章节不存在 404、穿越 403;正文末尾无换行补一个 `\n`;base_hash 与写入前现读原文不一致 → 409「章节文件已被外部修改…」拒写不落盘(人工改动保护,与 project-save 同口径;缺省 base_hash 跳过检查,兼容旧调用方)。hash 为写后新指纹,客户端作下一轮基线(title 与正文一次原子落盘,0.1.25 起) |
|
|
58
58
|
| `outline-save` | POST | `{ path, file, body, base_hash? }`(file 为章节文件名或 stem,目标恒为 outlines/ 下同 stem 章纲;body 为章纲全文;base_hash 为章纲载入时的指纹,即 `chapter` 路由的 `outline.hash`) | `{ ok, hash }`——章纲整文件替换:纯 markdown 无 frontmatter(spec §outlines),整文件替换即最小手术(与 style-save 同一判断);body 非 string / stem 不合 vXX-cXXX 规范 422、章纲不存在 404(本路由只覆写不新建,新建走 `file-create`)、穿越 403;base_hash 检测同上 → 409「章纲文件已被外部修改…」拒写不落盘。hash 为写后新指纹,客户端作下一轮基线 |
|
|
59
59
|
| `file-source` | GET | `path`, `rel`(受管白名单内的相对路径,见下表) | `{ ok, rel, text, hash }`——受管文件全文 + 原文 sha256 指纹(hash 为 `file-save` 的 `base_hash` 基线,读与指纹同刻同源);不存在 404、白名单外/穿越 403 |
|
|
60
60
|
| `file-save` | POST | `{ path, rel, text, base_hash? }`(text 为文件全文;base_hash 为编辑器载入时 `file-source` 的 `hash`) | `{ ok, rel, hash }`——受管文件整文件覆写:text 非 string 422、结构校验(`validateManagedText`:账本 yml 须解析为条目数组且条目全平键、md 实体卡须带成对围栏;校验先于基线检查)422、文件不存在 404(新建走 `file-create`)、base_hash 过期 → 409「`{rel}` 已被外部修改,请刷新后基于最新内容重改再存」拒写不落盘;尾无换行补一个 `\n`。hash 为写后新指纹 |
|
|
@@ -132,7 +132,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
132
132
|
九个 tab(顺序即默认排布):
|
|
133
133
|
|
|
134
134
|
1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介,卡右上「编辑」按钮展开书稿元信息表单——原「小说设置」tab 并入,见 §4.1「小说设置并入概览」条目);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条;静态体检黄条(check 路由问题清单,见 §4.1「人工改动保护 + 静态体检黄条」(0.1.14)条目);分卷规划卡卡头「编辑源文件」(volumes.yml 整文件编辑弹层,0.1.20 写面)。
|
|
135
|
-
2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章),未建章的孤儿章纲照列(「未建章」徽章 + 「建章」一键:POST chapter-create 落 pending 空章并重拉快照;行点击开章纲全文弹层,0.1.15 起),共创模式的主工作清单;行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文 + 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted);正文与章纲对照栏均可就地编辑保存(POST chapter-save / outline-save 携 base_hash 409 人工改动检测——frontmatter 围栏逐字节保留,外部改动拒写不落盘、草稿保留并亮「载入磁盘版(放弃草稿)/基于最新版重改」两个出口,0.1.19 起、两出口见 §4.1 末条):正文编辑态自动保存(约
|
|
135
|
+
2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章),未建章的孤儿章纲照列(「未建章」徽章 + 「建章」一键:POST chapter-create 落 pending 空章并重拉快照;行点击开章纲全文弹层,0.1.15 起),共创模式的主工作清单;行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文 + 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted);正文与章纲对照栏均可就地编辑保存(POST chapter-save / outline-save 携 base_hash 409 人工改动检测——frontmatter 围栏逐字节保留,外部改动拒写不落盘、草稿保留并亮「载入磁盘版(放弃草稿)/基于最新版重改」两个出口,0.1.19 起、两出口见 §4.1 末条):正文编辑态自动保存(约 30s 防抖——0.1.22 起 2s、0.1.23 起放宽到 30s 且手动保存同样停留编辑态不清稿;写盘内容即新基线、纯缩进占位不落盘,编辑 hint 常显正文实时 CJK 字数、与服务端全库字数同口径),待写空章节(body 全空白)与写作中(frontmatter status=writing,0.1.25 起)章节打开即直接进编辑态、不再经「编辑」按钮中转,回车换行自动加段首两个全角空格缩进,编辑器顶部标题输入框可改章节名(与正文一次原子保存),编辑器排版与只读正文一致(所见即所得)并随内容自动长高;章纲对照栏仍手动保存(textarea + 取消/保存);0.1.20 写面:无章纲空态加「新建章纲」(outlines/<stem>.md 五件骨架预填)、章纲对照栏卡头「删除章纲」、阅读顶栏「删除章节」——后两者均确认弹层(不可逆,后悔靠 git,check 会标孤儿引用)。
|
|
136
136
|
3. **角色**:卡墙(姓名/role 徽章四色/年龄性别/性格一句/动机一行),点击看全卡(含 arc/markers/background);0.1.20 写面:墙头「新建角色」(模板预填)+ 全卡弹层底部「编辑源文件/删除文件」。
|
|
137
137
|
4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 全文直显(0.1.10 去掉两行截断);卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数;0.1.20 写面:卡头「编辑源文件」(foreshadows.yml)。
|
|
138
138
|
5. **关系图**:canvas 力导向图——节点=角色(radius 随出场无关、固定 18px,名字 label 下方),边=relations(intensity 1-5 → 线宽 1-4px,type 标在边中点 hover 显示 description);节点按角色配色(0.1.20 起):主角 #f53f3f 红 / 配角 #165dff 蓝 / 反派 #1d2129 黑 / 龙套与未标注 #fadc19 黄(查表 miss 落 minor,全节点恒有色),拖拽固定态统一加粗描边;脚注带四色图例;拖拽节点、空白处滚轮缩放、双击复位;右上角 +/- 缩放按钮(钳制同滚轮 0.4-2.5)与全屏按钮(Fullscreen API 能力检测,不支持则不渲染;进入全屏走 ResizeObserver 自动重铺);intensity 缺省 3。角色多于 30 个时只画与 relations 相连的节点,其余进「未入图」列表。防重叠(0.1.20 起):节点最小间距 62 硬碰撞分离(含名字 label 纵向净空,力式斥力之上兜底,弹簧 rest 120→130 配合多留缝隙);边为二次曲线——按两侧空旷度选朝向(迟滞防抖)绕开途中节点,同一对角色的重复边偏移放大 + 交替朝向互相分开(type 标签随曲线移到 t=0.5 点);hover 命中按曲线几何采样。0.1.20 写面:卡头「编辑源文件」(relations.yml,空态同样给入口)。
|
|
@@ -180,6 +180,10 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
|
|
|
180
180
|
|
|
181
181
|
- **正文编辑直开 + 自动保存 + 段首缩进**(0.1.22):章节阅读页正文编辑三处升级——①**空章节直开编辑态**:待写章节(body 全空白)打开即直接进正文编辑态,不再经顶部「编辑」按钮中转;预填段首两个全角空格让首段也有缩进,基线与预填同值(不判脏,退出确认与自动保存均不误触);正在编辑绝不打扰(409 放弃草稿等编辑中重拉被 editingRef 守卫排除),同章本轮只自动进一次,切章/重开面板算新一轮可再进,手动「取消」退出后本轮不再自动重进。②**正文自动保存**:编辑中改动 2s 防抖落盘(调度 effect 无 dep 数组每轮渲染重排定时器,连续打字永远差 2s 才保存;409 冲突/保存在途不排),调用点快照 draft 防在途打字被误标已保存,**写盘内容即新基线**(连续自动保存不假脏),成功不退出编辑态、不清草稿,编辑 hint 缀「已自动保存(HH:MM:SS)」时刻标;纯缩进占位(trim 为空)不落盘;409 人工改动检测语义不变——冲突即停排、草稿保留、两出口照旧。③**回车自动段首缩进**:编辑器内回车换行自动插入两个全角空格(U+3000×2,网文段落缩进惯例),光标落到缩进之后(draft 提交后下一轮渲染恢复选区);IME 组态中的回车(keyCode 229 / isComposing)是选词确认不劫持,取不到 selectionStart 的退化环境交给浏览器默认行为。编辑器排版与只读正文一致(同 UI 字体、13px/22px 行高,所见即所得)并随内容自动长高(≥320px,overflow hidden + resize none),主题色边框保留可辨识仍在编辑;脏退出确认兜底不动(自动保存窗口之外切章/取消/返回仍先确认)。纯前端,路由不变;I18N zh/en 各 301 → 302 条(reader.autoSaved;reader.bodyEditHint 文案同步改写)。
|
|
182
182
|
|
|
183
|
+
- **手动保存不退出编辑态 + 防抖 30s + 实时字数**(0.1.23):正文编辑三点迭代——①**手动/自动保存行为统一**:保存成功(「保存」按钮或防抖定时器)都停留编辑态续写、不清草稿,时刻标照缀 hint——手动点保存与自动保存等价,只是人工触发(用户语:「这个功能实际上和自动保存是一样的,只不过是人工点一下而已」);退出编辑态只走「取消」(保存后草稿已回基线、脏检查归零,无确认直退),0.1.22 之前「手动保存退出编辑态」的旧语义废止。②**自动保存防抖 2s → 30s**:写作中途的短暂停顿(翻资料、想句子)不再频繁落盘,「每轮渲染重排定时器、连续打字永远差一个窗口」的调度语义不变,纯缩进占位不落盘照旧。③**编辑 hint 常显正文实时 CJK 字数**:复用既有 `common.charCount`(`{n} 字` / `{n} chars`,fmtNum 千分位),客户端内联与 lib/novel-parse.mjs `countCjk` 同口径的正则(基本区 + 扩展 A + 扩展 B,u 标志按码点匹配增补平面生僻字),面板本地数与服务端全库字数口径一致;显示形如「直接编辑正文… · 1,234 字 · 已自动保存(HH:MM:SS)」,字数常显、保存时刻照旧按有无显示。纯前端,路由不变;I18N 键数不变(reader.bodyEditHint 文案更新:2 秒 → 30 秒 / ~2s → ~30s)。
|
|
184
|
+
|
|
185
|
+
- **写作中直开 + 章节标题编辑**(0.1.25,两点并条):①**写作中章节直开编辑态**:直开判定从「正文待写」扩到「正在写」——frontmatter `status=writing` 的章节打开阅读页同样直接进正文编辑态,不只空章节(0.1.22 起的空章节直开照旧)。状态读 `GET chapter` 出参的 `d.frontmatter.status`,不用渲染体的 `ch` 判——`loadDetail` 是 useCallback 闭包,`ch` 是渲染体快照的过期值,`frontmatter` 才是本次响应的同源真值;空正文优先判定(`body.trim() === ""` 在前):空正文(待写)预填段首两个全角空格让首段有缩进,写作中的非空正文 draft/基线都取磁盘原文、不预填不动原文一字;其余语义不变:三重守卫(正在编辑绝不打扰 / 同章本轮只自动进一次 / `autoEditRef` 去重)照旧,取消退出后本轮不重进,切章/重开面板算新一轮。②**章节标题编辑**:编辑器顶部加标题输入框(`.myt-chapterEditTitle`,14px/600 字重,主题色边框与正文编辑区同族),章节名(frontmatter title 行)随正文**一次原子保存**——服务端 `mutateChapterBody` 加可选 `options.title`:同刀在围栏内替换 title 行(`rewriteValueKeepComment` 保行尾注释、值经 novel-parse `flowScalar` 引号落值,防「半角冒号+空格」等标题被 YAML 吃形;title 行缺失/多处由 `replaceLineOnce` 自带 409 拒写;非 string / trim 空串 422),一次原子写的动机是**防两条保存链的 hash 竞态**——正文、标题各发一轮 POST,中间那次落盘会让另一轮的 base_hash 过期假冲突;`title === undefined` 时行为逐字节不变(兼容旧调用方),返回形状仍 `{ ok, hash }`。客户端:`titleDraft`/`titleBaseRef` 随直开与「编辑」进入同源初始化(`frontmatter.title`)、切章清零;保存时标题基线有变才随载荷发 title(undefined 经 JSON.stringify 自然丢弃),trim 空串本地先拦(复用 `novel.titleRequired`),成功后 `frontmatter.title` 本地同步、标题基线推进;脏检查三处(取消/返回/DIRTY_EXIT 上报)与自动保存调度跳过条件都扩成「正文或标题有改动」——空章节只改标题不进自动保存(纯缩进占位守卫),等手动保存一次原子落盘。纯前端,路由不变(仍 23 条);I18N zh/en 各 302 → 303 条(reader.titlePlaceholder);测试 +4(panel-core:title 同刀替换保注释 / title 校验 422 / title 行缺失·多处 409 三例;routes:chapter-save title 透传一例)。
|
|
186
|
+
|
|
183
187
|
## 5. 验收基线
|
|
184
188
|
|
|
185
189
|
- Node 侧:临时 workspace(/tmp)+ 假 workspaceRegistry 不行——路由从 ctx.workspaceRegistry 取;单测直接 import lib/panel-core.mjs 的函数(parseStateSnapshot / mutateChapterStatus / mutateForeshadowStatus / mutatePhase / rebuildIndex)跑断言;路由层用 node 起一个最小 ctx 假件(webServer.register 收集 handler,workspaceRegistry 返回临时 root)模拟请求验证围栏 403 / 状态流转 / 防穿越。
|
package/index.mjs
CHANGED
|
@@ -1280,9 +1280,10 @@ export async function registerCreatorRoutes(ctx) {
|
|
|
1280
1280
|
// (含)之前逐字节保留、围栏后整段替换(正文是自由形态文本,无行级
|
|
1281
1281
|
// 键位可手术);base_hash 过期 → 409 拒写(人工改动保护,与
|
|
1282
1282
|
// project-save 同口径);body 非 string / 缺围栏 422、章节缺 404。
|
|
1283
|
+
// title 可选(0.1.25),提供则与正文同刀原子替换围栏内 title 行。
|
|
1283
1284
|
const body = await readBody(req, res)
|
|
1284
1285
|
const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
|
|
1285
|
-
json(res, 200, mutateChapterBody(novelDir, body.file, body.body, { baseHash: body.base_hash }))
|
|
1286
|
+
json(res, 200, mutateChapterBody(novelDir, body.file, body.body, { baseHash: body.base_hash, title: body.title }))
|
|
1286
1287
|
return
|
|
1287
1288
|
}
|
|
1288
1289
|
|
package/lib/base.css
CHANGED
|
@@ -461,12 +461,26 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
|
|
|
461
461
|
.myt-dimLabel { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); margin-bottom: 2px; }
|
|
462
462
|
.myt-dimText { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
|
|
463
463
|
|
|
464
|
-
/* ── 0.1.19 引入正文/章纲面板编辑态;0.1.22
|
|
464
|
+
/* ── 0.1.19 引入正文/章纲面板编辑态;0.1.22 起正文自动保存(防抖 0.1.23 起 30s)─
|
|
465
465
|
编辑态与只读态的区分靠主题色边框强调(只读态是无边框淡底的
|
|
466
466
|
.myt-chapterBody pre);正文 textarea 排版与只读正文对齐(同 UI 字体、
|
|
467
467
|
13px/22px 行高)所见即所得,高度随内容自动长高——当年的等宽字体 +
|
|
468
468
|
resize: vertical 已成史。章纲编辑区(.myt-outlineArea)仍手动保存。 */
|
|
469
469
|
.myt-bodyEdit { display: flex; flex-direction: column; gap: 8px; }
|
|
470
|
+
/* 0.1.25 标题编辑:编辑器顶部章节名输入框——与正文一次原子保存 */
|
|
471
|
+
.myt-chapterEditTitle {
|
|
472
|
+
width: 100%;
|
|
473
|
+
box-sizing: border-box;
|
|
474
|
+
padding: 8px 10px;
|
|
475
|
+
border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 45%, transparent);
|
|
476
|
+
border-radius: 10px;
|
|
477
|
+
background: var(--dsw-alias-bg-layer-1, var(--dsw-alias-bg-layer-2));
|
|
478
|
+
color: var(--dsw-alias-label-primary);
|
|
479
|
+
font-family: inherit;
|
|
480
|
+
font-size: 14px;
|
|
481
|
+
line-height: 22px;
|
|
482
|
+
font-weight: 600;
|
|
483
|
+
}
|
|
470
484
|
.myt-chapterEditArea {
|
|
471
485
|
width: 100%;
|
|
472
486
|
box-sizing: border-box;
|
package/lib/client.js
CHANGED
|
@@ -1365,7 +1365,7 @@ window.__ModuleLoader__.load({
|
|
|
1365
1365
|
"chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
|
|
1366
1366
|
"reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
|
|
1367
1367
|
"reader.edit": "编辑", "reader.editTip": "编辑章节正文(frontmatter 保留不动)",
|
|
1368
|
-
"reader.save": "保存", "reader.bodyEditHint": "直接编辑正文:改动约
|
|
1368
|
+
"reader.save": "保存", "reader.titlePlaceholder": "章节标题(可改)", "reader.bodyEditHint": "直接编辑正文:改动约 30 秒自动保存;回车换行自动加段首两个全角空格缩进。frontmatter(--- 围栏)保留不动;文件被外部改过时保存会被拒绝并给出处理出口。", "reader.autoSaved": "已自动保存({time})",
|
|
1369
1369
|
"outline.editTip": "编辑章纲", "outline.editHint": "直接编辑章纲全文;编辑期间文件被外部改过时保存会被拒绝,并放回磁盘最新内容。",
|
|
1370
1370
|
"reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
|
|
1371
1371
|
"reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
|
|
@@ -1483,7 +1483,7 @@ window.__ModuleLoader__.load({
|
|
|
1483
1483
|
"chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
|
|
1484
1484
|
"reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
|
|
1485
1485
|
"reader.edit": "Edit", "reader.editTip": "Edit the chapter text (frontmatter is preserved)",
|
|
1486
|
-
"reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly: changes auto-save after ~
|
|
1486
|
+
"reader.save": "Save", "reader.titlePlaceholder": "Chapter title (editable)", "reader.bodyEditHint": "Edit the chapter text directly: changes auto-save after ~30s; Enter starts a new paragraph indented with two full-width spaces. The frontmatter (--- fence) is preserved; if the file was changed externally, saving is rejected with recovery options.", "reader.autoSaved": "Auto-saved ({time})",
|
|
1487
1487
|
"outline.editTip": "Edit outline", "outline.editHint": "Edit the outline text directly; if the file was changed externally while editing, saving is rejected and the latest content is restored.",
|
|
1488
1488
|
"reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
|
|
1489
1489
|
"reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
|
|
@@ -1553,8 +1553,9 @@ window.__ModuleLoader__.load({
|
|
|
1553
1553
|
// ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
|
|
1554
1554
|
// 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
|
|
1555
1555
|
// 遮挡;整页视图天然在面板文档流内,无遮挡问题。0.1.19 起正文/章纲可在
|
|
1556
|
-
// 本页编辑保存;0.1.22
|
|
1557
|
-
//
|
|
1556
|
+
// 本页编辑保存;0.1.22 起正文改自动保存(回车自动段首缩进 + 空章节/写作中
|
|
1557
|
+
// 直开编辑态,防抖 0.1.23 起放宽到 30s、手动保存同样停留编辑态),章纲仍
|
|
1558
|
+
// 手动保存;409 人工改动保护链路照 StyleTab。──
|
|
1558
1559
|
function ChapterReader(props) {
|
|
1559
1560
|
var file = props.file;
|
|
1560
1561
|
var wsPath = props.wsPath;
|
|
@@ -1578,9 +1579,12 @@ window.__ModuleLoader__.load({
|
|
|
1578
1579
|
// 草稿绝不丢(见 saveBody/saveOutline 的 catch)。
|
|
1579
1580
|
var _bc = useState(false), bodyConflict = _bc[0], setBodyConflict = _bc[1];
|
|
1580
1581
|
var _oc = useState(false), olConflict = _oc[0], setOlConflict = _oc[1];
|
|
1581
|
-
// 正文自动保存(0.1.22
|
|
1582
|
-
// hint
|
|
1582
|
+
// 正文自动保存(0.1.22 引入;0.1.23 起手动保存同样写入时刻):最近一次
|
|
1583
|
+
// 保存成功的时刻(HH:MM:SS),缀在编辑 hint 后;切章时清零。
|
|
1583
1584
|
var _as = useState(""), autoSavedAt = _as[0], setAutoSavedAt = _as[1];
|
|
1585
|
+
// 章节标题编辑(0.1.25):编辑器顶部输入框的草稿与进入编辑态时的基线——
|
|
1586
|
+
// 与正文一次原子保存(chapter-save 的可选 title),不做独立保存链。
|
|
1587
|
+
var _td = useState(""), titleDraft = _td[0], setTitleDraft = _td[1];
|
|
1584
1588
|
var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
|
|
1585
1589
|
// 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
|
|
1586
1590
|
// (只在保存冲突「放弃草稿」出口置位;顶栏刷新、状态流转等其他重拉不动
|
|
@@ -1590,16 +1594,18 @@ window.__ModuleLoader__.load({
|
|
|
1590
1594
|
// 进入编辑态时的原文基线:草稿 ≠ 基线即脏,供取消/返回的退出确认。
|
|
1591
1595
|
var bodyBaseRef = useRef("");
|
|
1592
1596
|
var olBaseRef = useRef("");
|
|
1597
|
+
// 进入编辑态时的标题基线(0.1.25):titleDraft ≠ 基线即标题脏。
|
|
1598
|
+
var titleBaseRef = useRef("");
|
|
1593
1599
|
// 当前选中章的实时值:在途保存/流转/建章回调用它比对闭包里的旧 file,
|
|
1594
1600
|
// 不一致则跳过 UI 状态回填,防旧章数据覆写新章视图(B 标题+A正文)。
|
|
1595
1601
|
var fileRef = useRef(file);
|
|
1596
1602
|
fileRef.current = file;
|
|
1597
1603
|
// editing 实时值:loadDetail 是 useCallback 闭包,直接读 editing state 是
|
|
1598
|
-
//
|
|
1604
|
+
// 过期值——空章节/写作中直开编辑态的守卫必须走 ref。
|
|
1599
1605
|
var editingRef = useRef(false);
|
|
1600
1606
|
editingRef.current = editing;
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1607
|
+
// 空章节/写作中直开去重:记本次访问已自动进入过编辑态的 file,同章不反复
|
|
1608
|
+
// 打扰(手动取消退出后不再自动重进);切章清空,切走再切回可再次进入。
|
|
1603
1609
|
var autoEditRef = useRef("");
|
|
1604
1610
|
// 回车缩进的光标恢复:setDraft 提交换行+缩进后 DOM 未更新,下一轮渲染
|
|
1605
1611
|
// (draft 已就位)再把光标挪到插入的缩进之后;areaRef 指向正文 textarea。
|
|
@@ -1643,15 +1649,24 @@ window.__ModuleLoader__.load({
|
|
|
1643
1649
|
olBaseRef.current = ol0;
|
|
1644
1650
|
setOlDraft(ol0);
|
|
1645
1651
|
}
|
|
1646
|
-
//
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
|
|
1652
|
+
// 空章节/写作中直开编辑态(0.1.22 空章节、0.1.25 扩到写作中):
|
|
1653
|
+
// 正文待写(body 全空白)或 frontmatter.status === "writing" 的章打开
|
|
1654
|
+
// 即进编辑,不再经顶部「编辑」按钮中转。三重守卫缺一不可——正在编辑
|
|
1655
|
+
// 绝不打扰(409 放弃草稿等编辑中重拉天然被排除)、同章本轮只自动进
|
|
1656
|
+
// 一次、其余章节照常走「编辑」按钮。状态读 GET 出参 d.frontmatter,
|
|
1657
|
+
// 不用渲染体的 ch——loadDetail 是 useCallback 闭包,ch 是过期值。
|
|
1658
|
+
// 空正文预填两个全角空格让首段也有段首缩进(基线与预填同值:不判脏、
|
|
1659
|
+
// 纯缩进占位不触发自动保存落盘);写作中非空正文 draft/基线都取磁盘
|
|
1660
|
+
// 原文不预填。空正文优先判定:两条件同时满足走预填路径。
|
|
1661
|
+
if (!editingRef.current && autoEditRef.current !== f && d && typeof d.body === "string" &&
|
|
1662
|
+
(d.body.trim() === "" || (d.frontmatter && d.frontmatter.status === "writing"))) {
|
|
1652
1663
|
autoEditRef.current = f;
|
|
1653
|
-
|
|
1654
|
-
|
|
1664
|
+
var autoBody = d.body.trim() === "" ? " " : d.body; // 空正文预填缩进,非空原文直入
|
|
1665
|
+
bodyBaseRef.current = autoBody;
|
|
1666
|
+
var autoTitle = (d.frontmatter && d.frontmatter.title) || ""; // 标题基线同源初始化(0.1.25)
|
|
1667
|
+
titleBaseRef.current = autoTitle;
|
|
1668
|
+
setTitleDraft(autoTitle);
|
|
1669
|
+
setDraft(autoBody);
|
|
1655
1670
|
setEditing(true);
|
|
1656
1671
|
setBodyErr("");
|
|
1657
1672
|
}
|
|
@@ -1665,7 +1680,7 @@ window.__ModuleLoader__.load({
|
|
|
1665
1680
|
// 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
|
|
1666
1681
|
// 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
|
|
1667
1682
|
useEffect(function () {
|
|
1668
|
-
//
|
|
1683
|
+
// 重开面板算新一轮访问:直开去重清空,空章节/写作中重开再次自动进编辑态。
|
|
1669
1684
|
if (reopenSeq > 0) { autoEditRef.current = ""; loadDetail(file, { silent: editing }); }
|
|
1670
1685
|
}, [reopenSeq]);
|
|
1671
1686
|
// 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
|
|
@@ -1673,13 +1688,13 @@ window.__ModuleLoader__.load({
|
|
|
1673
1688
|
useEffect(function () {
|
|
1674
1689
|
setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false; setBodyConflict(false);
|
|
1675
1690
|
setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false; setOlConflict(false);
|
|
1676
|
-
bodyBaseRef.current = ""; olBaseRef.current = "";
|
|
1677
|
-
autoEditRef.current = ""; setAutoSavedAt(""); //
|
|
1691
|
+
bodyBaseRef.current = ""; olBaseRef.current = ""; titleBaseRef.current = "";
|
|
1692
|
+
autoEditRef.current = ""; setAutoSavedAt(""); setTitleDraft(""); // 直开去重、保存时刻与标题草稿随切章清零,新章新一轮
|
|
1678
1693
|
}, [file]);
|
|
1679
1694
|
// 脏标记上报:Shell 的切 tab 守卫(gotoTab)读 DIRTY_EXIT 拦截;本组件
|
|
1680
1695
|
// 不随切 tab 卸载(整页视图),标记随每轮渲染同步,卸载时清零。
|
|
1681
1696
|
useEffect(function () {
|
|
1682
|
-
DIRTY_EXIT.dirty = (editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current);
|
|
1697
|
+
DIRTY_EXIT.dirty = (editing && (draft !== bodyBaseRef.current || titleDraft !== titleBaseRef.current)) || (olEditing && olDraft !== olBaseRef.current); // 标题草稿计入脏(0.1.25)
|
|
1683
1698
|
return function () { DIRTY_EXIT.dirty = false; };
|
|
1684
1699
|
});
|
|
1685
1700
|
var ch = null;
|
|
@@ -1709,45 +1724,52 @@ window.__ModuleLoader__.load({
|
|
|
1709
1724
|
if (!detail.data || typeof detail.data.body !== "string") return;
|
|
1710
1725
|
setBodyErr("");
|
|
1711
1726
|
setAutoSavedAt(""); // 上一次编辑会话的自动保存时刻不带进新一轮
|
|
1727
|
+
var t0 = (detail.data.frontmatter && detail.data.frontmatter.title) || ""; // 标题基线同源初始化(0.1.25)
|
|
1728
|
+
titleBaseRef.current = t0;
|
|
1729
|
+
setTitleDraft(t0);
|
|
1712
1730
|
bodyBaseRef.current = detail.data.body; // 进入编辑态即记基线,供脏检查
|
|
1713
1731
|
setDraft(detail.data.body);
|
|
1714
1732
|
setEditing(true);
|
|
1715
1733
|
}
|
|
1716
1734
|
function cancelBodyEdit() {
|
|
1717
1735
|
if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
|
|
1718
|
-
if (draft !== bodyBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; //
|
|
1719
|
-
setEditing(false); setDraft(""); setBodyErr(""); setBodyConflict(false);
|
|
1736
|
+
if ((draft !== bodyBaseRef.current || titleDraft !== titleBaseRef.current) && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认(正文或标题,0.1.25)
|
|
1737
|
+
setEditing(false); setDraft(""); setBodyErr(""); setBodyConflict(false); setTitleDraft("");
|
|
1720
1738
|
}
|
|
1721
|
-
//
|
|
1739
|
+
// 返回章节列表:正文/标题或章纲任一有未保存草稿,先确认再退。
|
|
1722
1740
|
function leaveReader() {
|
|
1723
|
-
if ((editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current)) {
|
|
1741
|
+
if ((editing && (draft !== bodyBaseRef.current || titleDraft !== titleBaseRef.current)) || (olEditing && olDraft !== olBaseRef.current)) {
|
|
1724
1742
|
if (!window.confirm(tt("common.dirtyExit"))) return;
|
|
1725
1743
|
}
|
|
1726
1744
|
onBack();
|
|
1727
1745
|
}
|
|
1728
|
-
function saveBody(
|
|
1729
|
-
var auto = !!(opts && opts.auto); // 自动保存(2s 防抖调度):成功不退出编辑态、不清草稿
|
|
1746
|
+
function saveBody() {
|
|
1730
1747
|
if (bodyBusy || !detail.data) return;
|
|
1748
|
+
// 标题(0.1.25):与正文一次原子保存——基线有变才随载荷发 title(undefined
|
|
1749
|
+
// 经 JSON.stringify 自然丢弃,服务端缺省不动 title 行);trim 后空串 422
|
|
1750
|
+
// 在本地先拦(复用 novel.titleRequired),不发无效请求。
|
|
1751
|
+
var newTitle = titleDraft !== titleBaseRef.current ? titleDraft.trim() : undefined;
|
|
1752
|
+
if (newTitle !== undefined && newTitle === "") { setBodyErr(tt("novel.titleRequired")); return; }
|
|
1731
1753
|
setBodyBusy(true); setBodyErr("");
|
|
1732
1754
|
var bodyToSave = draft; // 调用点快照:在途期间继续打字的文本不该被误标已保存
|
|
1733
1755
|
// 失败经卡内 bodyErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
1734
|
-
apiPost("chapter-save", { path: wsPath, file: file, body: bodyToSave, base_hash: detail.data.hash }, { silent: true }).then(function (res) {
|
|
1756
|
+
apiPost("chapter-save", { path: wsPath, file: file, body: bodyToSave, base_hash: detail.data.hash, title: newTitle }, { silent: true }).then(function (res) {
|
|
1735
1757
|
// 时序守卫:保存期间切了章——旧章结果不得覆写当前 detail(快照 reload 无害照做)。
|
|
1736
1758
|
if (fileRef.current === file) {
|
|
1737
1759
|
// 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
|
|
1738
|
-
//
|
|
1760
|
+
// 为新正文/新标题,编辑器即时看到刚落盘的内容(不等重拉,防闪回旧文)。
|
|
1739
1761
|
setDetail({ loading: false, data: Object.assign({}, detail.data, {
|
|
1740
1762
|
body: bodyToSave,
|
|
1741
1763
|
hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
|
|
1764
|
+
frontmatter: newTitle !== undefined ? Object.assign({}, detail.data.frontmatter || {}, { title: newTitle }) : detail.data.frontmatter,
|
|
1742
1765
|
}), err: "" });
|
|
1743
|
-
bodyBaseRef.current = bodyToSave; //
|
|
1766
|
+
bodyBaseRef.current = bodyToSave; // 写盘内容即新基线:连续保存不假脏
|
|
1767
|
+
if (newTitle !== undefined) titleBaseRef.current = newTitle; // 标题基线同刀推进
|
|
1744
1768
|
setBodyConflict(false);
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
setEditing(false); setDraft(""); setAutoSavedAt("");
|
|
1750
|
-
}
|
|
1769
|
+
// 0.1.23 起手动/自动保存统一:都停留编辑态续写、不清草稿——手动点保存
|
|
1770
|
+
// 与自动保存等价,只是人工触发;退出编辑态只走「取消」(保存后草稿
|
|
1771
|
+
// 已回基线,脏检查归零,无确认直退)。
|
|
1772
|
+
setAutoSavedAt(new Date().toTimeString().slice(0, 8));
|
|
1751
1773
|
}
|
|
1752
1774
|
return reload(); // 字数/统计随正文变,快照由 reload 刷新
|
|
1753
1775
|
}).catch(function (err) {
|
|
@@ -1763,20 +1785,24 @@ window.__ModuleLoader__.load({
|
|
|
1763
1785
|
}
|
|
1764
1786
|
}).finally(function () { setBodyBusy(false); });
|
|
1765
1787
|
}
|
|
1766
|
-
// 正文自动保存调度(0.1.22
|
|
1767
|
-
//
|
|
1788
|
+
// 正文自动保存调度(0.1.22 引入;0.1.23 防抖 2s → 30s——写作中途的短暂
|
|
1789
|
+
// 停顿不再频繁落盘):无 dep 数组,每轮渲染重排——任何一次渲染都先清掉
|
|
1790
|
+
// 上一个 30s 定时器再按最新 draft 重排,连续打字永远差 30s 才落盘;切章、
|
|
1768
1791
|
// 409 冲突、保存在途不排。draft 回到基线(刚保存/刚进编辑)不动作;纯缩进
|
|
1769
1792
|
// 占位(trim 为空)不落盘,且旧「已保存」时刻一并撤下。setAutoSavedAt("")
|
|
1770
1793
|
// 传同值时 React 不重渲,不会循环。
|
|
1771
1794
|
useEffect(function () {
|
|
1772
1795
|
if (!editing || bodyConflict || bodyBusy) return;
|
|
1773
1796
|
if (!detail || !detail.data) return;
|
|
1774
|
-
|
|
1775
|
-
|
|
1797
|
+
// 正文与标题都回基线才不动作;纯缩进占位(trim 为空)不落盘——含义:
|
|
1798
|
+
// 空章节只改标题时不进自动保存,等手动保存一次原子落盘(标题挂在
|
|
1799
|
+
// chapter-save 上,不值得为它单独发一轮正文空串写)。
|
|
1800
|
+
if ((draft === bodyBaseRef.current && titleDraft === titleBaseRef.current) || draft.trim() === "") {
|
|
1801
|
+
if (draft !== bodyBaseRef.current || titleDraft !== titleBaseRef.current) setAutoSavedAt(""); // 有未保存改动但不满足自动保存:旧时刻不亮
|
|
1776
1802
|
return;
|
|
1777
1803
|
}
|
|
1778
1804
|
setAutoSavedAt(""); // 新改动入列,清掉旧的「已保存」标
|
|
1779
|
-
var t = setTimeout(function () { if (fileRef.current === file) saveBody(
|
|
1805
|
+
var t = setTimeout(function () { if (fileRef.current === file) saveBody(); }, 30000);
|
|
1780
1806
|
return function () { clearTimeout(t); };
|
|
1781
1807
|
});
|
|
1782
1808
|
// 回车缩进的光标恢复:DOM 换新后把光标放到插入的「\n 」缩进之后,
|
|
@@ -1883,6 +1909,11 @@ window.__ModuleLoader__.load({
|
|
|
1883
1909
|
if (!detail.data || !detail.data.outline) return null;
|
|
1884
1910
|
return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, conflict: olConflict, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline, drop: dropOutlineDraft, rebase: rebaseOutlineDraft };
|
|
1885
1911
|
}
|
|
1912
|
+
// 编辑中正文的实时 CJK 字数(0.1.23,hint 行常显):与 lib/novel-parse.mjs
|
|
1913
|
+
// 的 countCjk 同口径——基本区 + 扩展 A + 扩展 B,u 标志按码点匹配(增补平面
|
|
1914
|
+
// 生僻字不漏计),面板本地数与服务端全库字数对得上。
|
|
1915
|
+
var draftCjk = draft.match(/[\u4e00-\u9fff\u3400-\u4dbf\u{20000}-\u{2a6df}]/gu);
|
|
1916
|
+
var draftWords = draftCjk ? draftCjk.length : 0;
|
|
1886
1917
|
return h("div", { className: "myt-reader" },
|
|
1887
1918
|
h("div", { className: "myt-readerBar" },
|
|
1888
1919
|
h("button", { type: "button", className: "myt-btn", onClick: leaveReader, title: tt("reader.backTip") },
|
|
@@ -1927,6 +1958,10 @@ window.__ModuleLoader__.load({
|
|
|
1927
1958
|
})(),
|
|
1928
1959
|
editing
|
|
1929
1960
|
? h("div", { key: "body", className: "myt-bodyEdit" },
|
|
1961
|
+
// 章节标题输入框(0.1.25):与正文一次原子保存,非独立保存链
|
|
1962
|
+
h("input", { type: "text", className: "myt-chapterEditTitle", value: titleDraft,
|
|
1963
|
+
placeholder: tt("reader.titlePlaceholder"), "aria-label": tt("reader.titlePlaceholder"), spellCheck: false,
|
|
1964
|
+
onChange: function (e) { setTitleDraft(e.target.value); } }),
|
|
1930
1965
|
h("textarea", {
|
|
1931
1966
|
ref: areaRef,
|
|
1932
1967
|
className: "myt-chapterEditArea", rows: 20, spellCheck: false,
|
|
@@ -1948,6 +1983,7 @@ window.__ModuleLoader__.load({
|
|
|
1948
1983
|
}),
|
|
1949
1984
|
h("div", { className: "myt-editHint" },
|
|
1950
1985
|
tt("reader.bodyEditHint"),
|
|
1986
|
+
" · " + tf("common.charCount", { n: fmtNum(draftWords) }), // 实时字数常显(0.1.23)
|
|
1951
1987
|
autoSavedAt ? " · " + tf("reader.autoSaved", { time: autoSavedAt }) : null),
|
|
1952
1988
|
bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
|
|
1953
1989
|
bodyConflict ? h("div", { className: "myt-setActions" },
|
package/lib/panel-core.mjs
CHANGED
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
* rebuildIndex 复用 lib/novel-parse 的 buildIndexYaml 重建
|
|
36
36
|
* createChapterFromOutline 从章纲建章(chapters/ 落 pending 空章,0.1.15)
|
|
37
37
|
* saveStyleText 写作风格基准整文件覆写(novel/style.md,0.1.19)
|
|
38
|
-
* mutateChapterBody
|
|
38
|
+
* mutateChapterBody 章节正文整段替换(面板正文编辑态保存);可选
|
|
39
|
+
* title 同刀替换围栏内 title 行(0.1.25 起)
|
|
39
40
|
* mutateOutlineBody 章纲整文件替换(面板章纲对照栏编辑态保存)
|
|
40
41
|
*
|
|
41
42
|
* 受管文件面(0.1.20「全量可编辑写面」:白名单内的账本 yml / md 实体卡 /
|
|
@@ -59,7 +60,7 @@
|
|
|
59
60
|
import { mkdirSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
60
61
|
import path from 'node:path'
|
|
61
62
|
|
|
62
|
-
import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, sha256Hex, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
|
|
63
|
+
import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, flowScalar, parseFlatYamlArray, parseScalarText, sha256Hex, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
|
|
63
64
|
import { loadYamlLib, resolveDshHome } from './yaml-loader.mjs'
|
|
64
65
|
|
|
65
66
|
/** 章节状态机(与 lib/novel-parse 的表同源:只取键)。 */
|
|
@@ -806,11 +807,20 @@ export function createChapterFromOutline(novelDir, file) {
|
|
|
806
807
|
* - options.baseHash 是编辑态载入时的原文指纹(chapter 路由的 hash),
|
|
807
808
|
* 归一(trim + 小写)后与写入前现读不一致 → 409 拒写不落盘(人工改动
|
|
808
809
|
* 保护,与 mutateProject 同口径);未传跳过检查(旧调用方兼容)。
|
|
810
|
+
* - options.title 可选(0.1.25):提供(string 且 trim 非空,否则 422)则
|
|
811
|
+
* 与正文同一次原子落盘、同刀替换围栏内 title 行——行尾注释原样保留、
|
|
812
|
+
* 值经 flowScalar 引号落值(标题可含空格/冒号),title 行缺失或多处
|
|
813
|
+
* 409 拒写;一次原子写避免「正文/标题两条保存链」的 hash 竞态。
|
|
809
814
|
* 正文末尾无换行补一个 \n( POSIX 文本文件惯例,textarea 草稿常缺尾换行)。
|
|
810
815
|
* 返回 { ok, hash }——写后新指纹,客户端下一轮 base_hash 直接取它。
|
|
811
816
|
*/
|
|
812
817
|
export function mutateChapterBody(novelDir, file, body, options = {}) {
|
|
813
818
|
if (typeof body !== 'string') fail(422, 'body 必须是字符串(章节正文全文)')
|
|
819
|
+
// 可选 title(0.1.25)入口校验:非 string 或 trim 空串 422(先拒再读文件)。
|
|
820
|
+
if (options.title !== undefined && (typeof options.title !== 'string' || options.title.trim() === '')) {
|
|
821
|
+
fail(422, 'title 不能为空(章节标题)')
|
|
822
|
+
}
|
|
823
|
+
const newTitle = options.title === undefined ? undefined : options.title.trim()
|
|
814
824
|
const { name, target } = locateChapter(novelDir, file)
|
|
815
825
|
const text = readTarget(target, `章节 ${name}`)
|
|
816
826
|
const fence = splitFrontmatter(text)
|
|
@@ -820,11 +830,23 @@ export function mutateChapterBody(novelDir, file, body, options = {}) {
|
|
|
820
830
|
if (baseHash !== '' && baseHash !== sha256Hex(text)) {
|
|
821
831
|
fail(409, `章节文件已被外部修改,请刷新后基于最新内容重改再存:${name}`)
|
|
822
832
|
}
|
|
833
|
+
// title 同刀手术(0.1.25,与 mutateChapterStatus 同款保注释刀):只换值,
|
|
834
|
+
// 行尾注释原样保留;标题可含空格/冒号,经 flowScalar 引号落值防类型漂移;
|
|
835
|
+
// title 行缺失或多处由 replaceLineOnce 自带 409 拒写。
|
|
836
|
+
let fenceText = fence.raw
|
|
837
|
+
if (newTitle !== undefined) {
|
|
838
|
+
fenceText = replaceLineOnce(
|
|
839
|
+
fence.raw,
|
|
840
|
+
/^title:[^\n]*?$/,
|
|
841
|
+
(line) => rewriteValueKeepComment(line, /^(title:)/, flowScalar(newTitle)) ?? line,
|
|
842
|
+
{ what: `${name} frontmatter 的 title 行` },
|
|
843
|
+
)
|
|
844
|
+
}
|
|
823
845
|
// 围栏闭合行从原文原样切出(与 splitFrontmatter 的闭合正则同形)——
|
|
824
846
|
// CRLF/行尾空白等字节一字不动,改写它的刀不属于正文编辑。
|
|
825
847
|
const cut = fence.index + fence.raw.length
|
|
826
848
|
const closeM = /\r?\n---[ \t]*(?:\r?\n|$)/.exec(text.slice(cut))
|
|
827
|
-
let preserved = text.slice(0, cut + closeM[0].length)
|
|
849
|
+
let preserved = text.slice(0, fence.index) + fenceText + text.slice(cut, cut + closeM[0].length)
|
|
828
850
|
// 退化形状:闭合 --- 是文件末行且无换行(罕见),补一个换行防正文黏上围栏行。
|
|
829
851
|
if (!preserved.endsWith('\n')) preserved += '\n'
|
|
830
852
|
const next = preserved + (body.endsWith('\n') ? body : `${body}\n`)
|