dsh-maoying 0.1.18 → 0.1.20

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/lib/client.js CHANGED
@@ -72,7 +72,7 @@ window.__ModuleLoader__.load({
72
72
  var TABS = [
73
73
  ["overview", "tabs.overview"], ["chapters", "tabs.chapters"], ["characters", "tabs.characters"],
74
74
  ["foreshadows", "tabs.foreshadows"], ["relations", "tabs.relations"], ["timeline", "tabs.timeline"],
75
- ["world", "tabs.world"], ["index", "tabs.index"],
75
+ ["world", "tabs.world"], ["style", "tabs.style"], ["index", "tabs.index"],
76
76
  ];
77
77
 
78
78
  // 角色徽章四色(role 是自由文本):按关键词归 tone,命中即返。
@@ -243,6 +243,12 @@ window.__ModuleLoader__.load({
243
243
  if (path === "chapter-create") return tf("toast.chapterCreated", { title: (d && d.title) || "—" });
244
244
  if (path === "foreshadow-status") return tt("toast.foreshadowStatus");
245
245
  if (path === "phase") return tf("toast.phaseAdvanced", { phase: d && d.phase && PHASES.indexOf(d.phase) !== -1 ? tt("phase." + d.phase) : (d && d.phase) || "—" });
246
+ if (path === "style-save") return tt("toast.styleSaved");
247
+ if (path === "chapter-save") return tt("toast.chapterSaved");
248
+ if (path === "outline-save") return tt("toast.outlineSaved");
249
+ if (path === "file-save") return tf("toast.fileSaved", { rel: (d && d.rel) || "" });
250
+ if (path === "file-create") return tf("toast.fileCreated", { rel: (d && d.rel) || "" });
251
+ if (path === "file-delete") return tf("toast.fileDeleted", { rel: (d && d.rel) || "" });
246
252
  return tt("toast.success");
247
253
  }
248
254
 
@@ -542,8 +548,8 @@ window.__ModuleLoader__.load({
542
548
  }
543
549
 
544
550
  // ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
545
- // 数据流:workspaces 每次打开面板都重拉(懒挂载下关闭不卸载,见下方
546
- // activate 监听);state 跟随选中工作区;写操作 POST 成功后
551
+ // 数据流:workspaces 与 state 每次打开面板都重拉(懒挂载下关闭不卸载,
552
+ // 见下方 activate 监听);state 另跟随选中工作区;写操作 POST 成功后
547
553
  // 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
548
554
  function PageShell(props) {
549
555
  var onClose = props.onClose;
@@ -556,6 +562,8 @@ window.__ModuleLoader__.load({
556
562
  var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
557
563
  // 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
558
564
  var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
565
+ // 面板重开序号:每次 activate 广播自增,透传 ChapterReader 驱动本章重拉。
566
+ var _rs = useState(0), reopenSeq = _rs[0], setReopenSeq = _rs[1];
559
567
  // 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
560
568
  var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
561
569
  var _fs = useState(function () {
@@ -751,20 +759,27 @@ window.__ModuleLoader__.load({
751
759
  });
752
760
  }
753
761
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
754
- // 每次打开面板都重拉工作区列表:懒挂载下关闭只是摘掉 active 属性,
755
- // 组件不卸载,上面的 mount effect 整个页面生命周期只跑一次,期间外部
756
- // (CLI / 其他面板)新建或删除的工作区在重开时看不见。mountPanel 每次
757
- // 打开都会广播 dsh-panel-activate(source: "maoying"),借它触发重拉;
758
- // 首次打开的广播早于本组件挂载(render 异步调度),收不到也无妨——
759
- // mount effect 已覆盖首拉。其余面板的同名广播按 source 过滤掉。
762
+ // 每次打开面板都重拉工作区列表与整份 state 快照:懒挂载下关闭只是摘掉
763
+ // active 属性,组件不卸载,上面的 mount effect 整个页面生命周期只跑一
764
+ // 次,期间外部(CLI / agent / 其他面板)新建/删除的工作区、改动的小说
765
+ // 内容在重开时看不见——state 原本只跟 effectiveWs 走,同工作区重开不
766
+ // 会重拉,用户得手点右上角刷新。mountPanel 每次打开都会广播
767
+ // dsh-panel-activate(source: "maoying"),借它触发三重重拉:workspaces、
768
+ // state 快照、阅读页本章(reopenSeq 自增);首次打开的广播早于本组件挂
769
+ // 载(render 异步调度),收不到也无妨——mount effect 已覆盖首拉。其余
770
+ // 面板的同名广播按 source 过滤掉。重拉不动编辑中的草稿:loadState 本就
771
+ // 不碰草稿,阅读页走 silent 刷新(见 ChapterReader)。
760
772
  useEffect(function () {
761
773
  var onActivate = function (event) {
762
774
  var source = event && event.detail && event.detail.source;
763
- if (source === "maoying") loadWorkspaces();
775
+ if (source !== "maoying") return;
776
+ loadWorkspaces();
777
+ reload(); // 重开即最新小说内容,不必手点右上角刷新
778
+ setReopenSeq(function (n) { return n + 1; });
764
779
  };
765
780
  document.addEventListener(ACTIVATE_EVENT, onActivate);
766
781
  return function () { document.removeEventListener(ACTIVATE_EVENT, onActivate); };
767
- }, [loadWorkspaces]);
782
+ }, [loadWorkspaces, reload]);
768
783
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
769
784
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
770
785
  useEffect(function () {
@@ -804,13 +819,14 @@ window.__ModuleLoader__.load({
804
819
  });
805
820
  } else if (snap) {
806
821
  var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
807
- if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, onBack: function () { setReaderFile(null); } });
822
+ if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, refreshSeq: reopenSeq, onBack: function () { setReaderFile(null); } });
808
823
  else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
809
824
  else if (tab === "characters") body = h(CharactersTab, tabProps);
810
825
  else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
811
826
  else if (tab === "relations") body = h(RelationsTab, tabProps);
812
827
  else if (tab === "timeline") body = h(TimelineTab, tabProps);
813
828
  else if (tab === "world") body = h(WorldTab, tabProps);
829
+ else if (tab === "style") body = h(StyleTab, tabProps);
814
830
  else if (tab === "index") body = h(IndexTab, tabProps);
815
831
  else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
816
832
  }
@@ -902,6 +918,8 @@ window.__ModuleLoader__.load({
902
918
  var project = snap.project || {};
903
919
  // 编辑态:true 时 hero 卡切换为书稿元信息表单(ProjectMetaForm)。
904
920
  var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
921
+ // 分卷规划账源文件直达(0.1.20 写面):volumes.yml 整文件可编辑。
922
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
905
923
  var totals = snap.totals || {};
906
924
  var byStatus = totals.byStatus || {};
907
925
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
@@ -977,7 +995,8 @@ window.__ModuleLoader__.load({
977
995
  }))),
978
996
  volumes.length ? h("div", { className: "myt-card" },
979
997
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.volumes")),
980
- h("span", { className: "myt-boardCount" }, volumes.length)),
998
+ h("span", { className: "myt-boardCount" }, volumes.length),
999
+ srcEditBtn("volumes.yml", function () { setSrcRel("volumes.yml"); })),
981
1000
  h("div", { className: "myt-stack" }, volumes.map(function (v, i) {
982
1001
  return h("div", { key: v.id || i, className: "myt-worldGroup" },
983
1002
  h("div", { className: "myt-listHead" },
@@ -995,7 +1014,8 @@ window.__ModuleLoader__.load({
995
1014
  h("span", { className: "myt-chPath" }, chWhere(ch)),
996
1015
  h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
997
1016
  }))
998
- : h(Empty, { text: tt("chapters.empty") })));
1017
+ : h(Empty, { text: tt("chapters.empty") })),
1018
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
999
1019
  }
1000
1020
 
1001
1021
  // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
@@ -1112,7 +1132,7 @@ window.__ModuleLoader__.load({
1112
1132
  var UI_LANG = "zh";
1113
1133
  var I18N = {
1114
1134
  zh: {
1115
- "shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 只读正文,写状态",
1135
+ "shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 正文/章纲可编辑保存",
1116
1136
  "header.curVerTitle": "当前版本", "header.upgradeTip": "当前 {cur},点击打开新版发布页", "header.newVersion": "有新版 {ver} ↗",
1117
1137
  "header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
1118
1138
  "header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
@@ -1125,7 +1145,12 @@ window.__ModuleLoader__.load({
1125
1145
  "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
1126
1146
  "tabs.aria": "创作台分区",
1127
1147
  "tabs.overview": "概览", "tabs.chapters": "章节", "tabs.characters": "角色", "tabs.foreshadows": "伏笔", "tabs.relations": "关系图",
1128
- "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板",
1148
+ "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.style": "写作风格", "tabs.index": "索引板",
1149
+ "style.note": "写手 / 大纲师 / 审校以此为准;改完即生效",
1150
+ "style.editTip": "编辑写作风格基准(novel/style.md)",
1151
+ "style.empty": "novel/style.md 还没有创建。写手/大纲师/审校读取风格基准时将拿不到本文;可先用插件内置的通用网文默认基线创建,再按本书题材修改。",
1152
+ "style.createFromTpl": "用默认基线创建",
1153
+ "style.loadFailed": "写作风格读取失败:{msg}",
1129
1154
  "shell.wsListFailed": "工作区列表拉取失败:{msg}", "shell.stateFailed": "快照拉取失败:{msg}",
1130
1155
  "guide.noWsTitle": "还没有可用的小说工作区", "guide.noWsDesc": "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
1131
1156
  "guide.notInitTitle": "该工作区尚未初始化小说项目", "guide.notInitDesc": "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
@@ -1136,6 +1161,21 @@ window.__ModuleLoader__.load({
1136
1161
  "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
1137
1162
  "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
1138
1163
  "toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
1164
+ "toast.styleSaved": "写作风格已保存", "toast.chapterSaved": "章节正文已保存", "toast.outlineSaved": "章纲已保存",
1165
+ "src.modalAria": "源文件编辑", "src.closeAria": "关闭源文件编辑",
1166
+ "src.editSource": "编辑源文件", "src.editSourceTip": "编辑源文件:{rel}",
1167
+ "src.deleteFile": "删除文件", "src.deleteTip": "删除文件:{rel}",
1168
+ "src.loading": "源文件加载中…", "src.loadFailed": "源文件读取失败:{msg}",
1169
+ "src.editHint": "直接编辑文件全文;保存时做结构校验(账本 yml 须解析为条目数组、md 实体卡须带 frontmatter 围栏),语义问题交给保存后的静态体检。",
1170
+ "src.conflictReloaded": "文件已被外部修改,草稿已丢弃,已放回磁盘最新内容。",
1171
+ "src.slugLabel": "文件名 slug", "src.slugPh": "如 lin-waner(小写字母/数字与 -)", "src.slugInvalid": "slug 只接受小写字母/数字与 -,且以字母或数字开头",
1172
+ "src.createHint": "内容已按模板预填,可修改后再创建。", "src.create": "创建", "src.creating": "创建中…",
1173
+ "src.confirmTitle": "确认删除", "src.confirmText": "即将删除 {rel}。此操作不可逆——后悔靠 git 回滚;删除后静态体检会把指向它的悬空引用(伏笔/时间线/关系等)标为问题。",
1174
+ "src.confirmDelete": "确认删除", "src.deleting": "删除中…",
1175
+ "toast.fileSaved": "已保存 {rel}", "toast.fileCreated": "已创建 {rel}", "toast.fileDeleted": "已删除 {rel}",
1176
+ "reader.createOutline": "新建章纲", "reader.deleteChapter": "删除章节",
1177
+ "outline.deleteBtn": "删除章纲",
1178
+ "char.createBtn": "新建角色", "world.createBtn": "新建设定", "world.createScienceBtn": "新建科学设定",
1139
1179
  "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
1140
1180
  "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
1141
1181
  "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
@@ -1158,10 +1198,12 @@ window.__ModuleLoader__.load({
1158
1198
  "timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
1159
1199
  "world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
1160
1200
  "world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
1201
+ "world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区", "world.catTabsAria": "世界观分类",
1161
1202
  "relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
1162
- "relations.help": "拖节点固定位置 · 滚轮缩放 · 双击复位 · 悬停边看关系描述",
1203
+ "relations.help": "拖节点固定位置 · 滚轮或 +/- 按钮缩放 · 双击复位 · 悬停边看关系描述 · 右上角按钮全屏",
1163
1204
  "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
1164
1205
  "relations.edgeTipPrefix": "{type}:", "relations.noDesc": "(无描述)",
1206
+ "relations.zoomIn": "放大", "relations.zoomOut": "缩小", "relations.fs": "全屏", "relations.exitFs": "退出全屏",
1165
1207
  "char.empty": "characters/ 目录还没有角色卡",
1166
1208
  "role.protagonist": "主角", "role.antagonist": "反派", "role.supporting": "配角", "role.minor": "龙套",
1167
1209
  "char.field.age": "年龄", "char.field.gender": "性别", "char.field.appearance": "外貌", "char.field.personality": "性格",
@@ -1180,6 +1222,9 @@ window.__ModuleLoader__.load({
1180
1222
  "chapters.createTip": "在 chapters/ 下按章纲建一章(status=pending,正文待写)",
1181
1223
  "chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
1182
1224
  "reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
1225
+ "reader.edit": "编辑", "reader.editTip": "编辑章节正文(frontmatter 保留不动)",
1226
+ "reader.save": "保存", "reader.bodyEditHint": "直接编辑正文;frontmatter(--- 围栏)保留不动。编辑期间文件被作者或 agent 改过时保存会被拒绝,并放回磁盘最新内容。",
1227
+ "outline.editTip": "编辑章纲", "outline.editHint": "直接编辑章纲全文;编辑期间文件被外部改过时保存会被拒绝,并放回磁盘最新内容。",
1183
1228
  "reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
1184
1229
  "reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
1185
1230
  "reader.flowing": "流转中…", "reader.advanceTo": "推进到「{next}」",
@@ -1201,7 +1246,7 @@ window.__ModuleLoader__.load({
1201
1246
  "dim.suspense": "悬念", "dim.interpersonalTension": "人际张力",
1202
1247
  },
1203
1248
  en: {
1204
- "shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · read-only text, writable status",
1249
+ "shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · chapter text & outlines editable",
1205
1250
  "header.curVerTitle": "Current version", "header.upgradeTip": "Current {cur} — click to open the release page", "header.newVersion": "New version {ver} ↗",
1206
1251
  "header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
1207
1252
  "header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
@@ -1214,7 +1259,12 @@ window.__ModuleLoader__.load({
1214
1259
  "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
1215
1260
  "tabs.aria": "Studio sections",
1216
1261
  "tabs.overview": "Overview", "tabs.chapters": "Chapters", "tabs.characters": "Characters", "tabs.foreshadows": "Foreshadowing", "tabs.relations": "Relations",
1217
- "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.index": "Index Board",
1262
+ "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.style": "Style", "tabs.index": "Index Board",
1263
+ "style.note": "Writer / outliner / reviewer all follow this baseline; changes take effect immediately",
1264
+ "style.editTip": "Edit the style baseline (novel/style.md)",
1265
+ "style.empty": "novel/style.md does not exist yet — the writer / outliner / reviewer will find no style baseline. Create it from the plugin's default web-novel baseline, then adapt it to this book.",
1266
+ "style.createFromTpl": "Create from default baseline",
1267
+ "style.loadFailed": "Failed to load the style baseline: {msg}",
1218
1268
  "shell.wsListFailed": "Failed to load workspace list: {msg}", "shell.stateFailed": "Failed to load snapshot: {msg}",
1219
1269
  "guide.noWsTitle": "No novel workspace available yet", "guide.noWsDesc": "A workspace is picked up automatically once novel/project.yml exists at its root; initialize the directory scaffold with this command first:",
1220
1270
  "guide.notInitTitle": "No novel project in this workspace yet", "guide.notInitDesc": "novel/project.yml is missing at the workspace root. Run this at the workspace root:",
@@ -1225,6 +1275,21 @@ window.__ModuleLoader__.load({
1225
1275
  "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
1226
1276
  "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1227
1277
  "toast.chapterStatus": "Chapter status updated", "toast.chapterCreated": "Created chapter \"{title}\" from the outline (pending)", "toast.foreshadowStatus": "Foreshadowing status updated", "toast.phaseAdvanced": "Phase advanced to {phase}",
1278
+ "toast.styleSaved": "Style baseline saved", "toast.chapterSaved": "Chapter text saved", "toast.outlineSaved": "Outline saved",
1279
+ "src.modalAria": "Source file editor", "src.closeAria": "Close source editor",
1280
+ "src.editSource": "Edit source file", "src.editSourceTip": "Edit source file: {rel}",
1281
+ "src.deleteFile": "Delete file", "src.deleteTip": "Delete file: {rel}",
1282
+ "src.loading": "Loading source file…", "src.loadFailed": "Failed to load source file: {msg}",
1283
+ "src.editHint": "Edit the whole file; saving runs structural validation (ledger yml must parse as an entry array, md entity cards must carry frontmatter fences) — semantics are left to the static check after saving.",
1284
+ "src.conflictReloaded": "The file was changed externally; your draft was dropped and the latest on-disk content has been restored.",
1285
+ "src.slugLabel": "File slug", "src.slugPh": "e.g. lin-waner (lowercase letters/digits and -)", "src.slugInvalid": "Slug accepts lowercase letters/digits and - only, starting with a letter or digit",
1286
+ "src.createHint": "Pre-filled from the template — adjust it, then create.", "src.create": "Create", "src.creating": "Creating…",
1287
+ "src.confirmTitle": "Confirm delete", "src.confirmText": "About to delete {rel}. This is irreversible — recovery is via git rollback; afterwards the static check will flag dangling references to it (foreshadows / timeline / relations etc.).",
1288
+ "src.confirmDelete": "Delete", "src.deleting": "Deleting…",
1289
+ "toast.fileSaved": "Saved {rel}", "toast.fileCreated": "Created {rel}", "toast.fileDeleted": "Deleted {rel}",
1290
+ "reader.createOutline": "Create outline", "reader.deleteChapter": "Delete chapter",
1291
+ "outline.deleteBtn": "Delete outline",
1292
+ "char.createBtn": "New character", "world.createBtn": "New world entry", "world.createScienceBtn": "New science entry",
1228
1293
  "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
1229
1294
  "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
1230
1295
  "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
@@ -1247,10 +1312,12 @@ window.__ModuleLoader__.load({
1247
1312
  "timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
1248
1313
  "world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
1249
1314
  "world.scienceEmpty": "No science entries in science/ yet", "world.research": "Research", "world.ref": "Ref {n}: {r}",
1315
+ "world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections", "world.catTabsAria": "Worldbuilding categories",
1250
1316
  "relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
1251
- "relations.help": "Drag a node to pin it · scroll to zoom · double-click to reset · hover an edge for its description",
1317
+ "relations.help": "Drag a node to pin it · scroll or the +/- buttons to zoom · double-click to reset · hover an edge for its description · top-right button for full screen",
1252
1318
  "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
1253
1319
  "relations.edgeTipPrefix": "{type}: ", "relations.noDesc": "(no description)",
1320
+ "relations.zoomIn": "Zoom in", "relations.zoomOut": "Zoom out", "relations.fs": "Full screen", "relations.exitFs": "Exit full screen",
1254
1321
  "char.empty": "No character cards in characters/ yet",
1255
1322
  "role.protagonist": "Protagonist", "role.antagonist": "Antagonist", "role.supporting": "Supporting", "role.minor": "Minor",
1256
1323
  "char.field.age": "Age", "char.field.gender": "Gender", "char.field.appearance": "Appearance", "char.field.personality": "Personality",
@@ -1269,6 +1336,9 @@ window.__ModuleLoader__.load({
1269
1336
  "chapters.createTip": "Create a pending chapter in chapters/ from this outline (body to be written)",
1270
1337
  "chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
1271
1338
  "reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
1339
+ "reader.edit": "Edit", "reader.editTip": "Edit the chapter text (frontmatter is preserved)",
1340
+ "reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly; the frontmatter (--- fence) is preserved. If the file was changed externally while editing, saving is rejected and the latest content is restored.",
1341
+ "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.",
1272
1342
  "reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
1273
1343
  "reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
1274
1344
  "reader.flowing": "Transitioning…", "reader.advanceTo": "Advance to {next}",
@@ -1336,29 +1406,76 @@ window.__ModuleLoader__.load({
1336
1406
 
1337
1407
  // ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
1338
1408
  // 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
1339
- // 遮挡;整页视图天然在面板文档流内,无遮挡问题。 ──
1409
+ // 遮挡;整页视图天然在面板文档流内,无遮挡问题。0.1.19 起正文/章纲可在
1410
+ // 本页编辑保存(textarea + 手动保存,无自动保存——IME 组合词风险天然规
1411
+ // 避;409 人工改动保护链路照 StyleTab)。──
1340
1412
  function ChapterReader(props) {
1341
1413
  var file = props.file;
1342
1414
  var wsPath = props.wsPath;
1343
1415
  var snap = props.snap;
1344
1416
  var reload = props.reload;
1417
+ var reopenSeq = props.refreshSeq || 0; // 面板重开序号(PageShell 广播自增)
1345
1418
  var onBack = props.onBack;
1346
1419
  var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
1347
1420
  var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
1421
+ // 编辑态:draft 是受控草稿,只在进入编辑与切章时初始化——重渲染与
1422
+ // detail 重拉都不碰它,否则打字会被中途清掉。olDraft 同构管章纲对照栏。
1423
+ var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
1424
+ var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
1425
+ var _oe = useState(false), olEditing = _oe[0], setOlEditing = _oe[1];
1426
+ var _odr = useState(""), olDraft = _odr[0], setOlDraft = _odr[1];
1427
+ var _bb = useState(false), bodyBusy = _bb[0], setBodyBusy = _bb[1];
1428
+ var _ob = useState(false), olBusy = _ob[0], setOlBusy = _ob[1];
1429
+ var _be = useState(""), bodyErr = _be[0], setBodyErr = _be[1];
1430
+ var _oerr = useState(""), olErr = _oerr[0], setOlErr = _oerr[1];
1348
1431
  var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
1349
- var loadDetail = useCallback(function (f) {
1432
+ // 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
1433
+ // (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
1434
+ var bodyResetRef = useRef(false);
1435
+ var olResetRef = useRef(false);
1436
+ // 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
1437
+ var stem = file.replace(/\.md$/i, "");
1438
+ var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
1439
+ var _co = useState(false), coBusy = _co[0], setCoBusy = _co[1];
1440
+ var _dc = useState(false), delChapterOpen = _dc[0], setDelChapterOpen = _dc[1];
1441
+ var _do = useState(false), delOutlineOpen = _do[0], setDelOutlineOpen = _do[1];
1442
+ function createOutline() {
1443
+ if (coBusy || !stemOk) return;
1444
+ setCoBusy(true);
1445
+ // 骨架照 spec 六件章纲的其中五件预填(章尾钩子独立成节,留给写的人
1446
+ // 展开成 `## 钩子`);服务端只验形状,成败提示走 toast 通道。
1447
+ apiPost("file-create", { path: wsPath, rel: "outlines/" + stem + ".md", text: outlineTemplateOf(ch, stem) })
1448
+ .then(function () { return reload().then(function () { return loadDetail(file); }); })
1449
+ .catch(function () {}).finally(function () { setCoBusy(false); });
1450
+ }
1451
+ var loadDetail = useCallback(function (f, opts) {
1350
1452
  var seq = ++detailSeqRef.current;
1351
1453
  if (!f) { setDetail(null); return; }
1352
- setDetail({ loading: true, data: null, err: "" });
1454
+ // silent:不闪骨架的原地刷新(章纲保存后对齐字数 chip 用),草稿不受影响
1455
+ if (!(opts && opts.silent)) setDetail({ loading: true, data: null, err: "" });
1353
1456
  apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
1354
1457
  if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
1355
1458
  setDetail({ loading: false, data: d, err: "" });
1459
+ if (bodyResetRef.current) { bodyResetRef.current = false; setDraft(d && typeof d.body === "string" ? d.body : ""); }
1460
+ if (olResetRef.current) { olResetRef.current = false; setOlDraft(d && d.outline && typeof d.outline.body === "string" ? d.outline.body : ""); }
1356
1461
  }).catch(function (err) {
1357
1462
  if (seq !== detailSeqRef.current) return;
1358
1463
  setDetail({ loading: false, data: null, err: err.message });
1359
1464
  });
1360
1465
  }, [wsPath]);
1361
1466
  useEffect(function () { loadDetail(file); }, [file, loadDetail]);
1467
+ // 面板重开(reopenSeq 递增):重拉本章让磁盘最新内容即时可见。编辑中
1468
+ // 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
1469
+ // 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
1470
+ useEffect(function () {
1471
+ if (reopenSeq > 0) loadDetail(file, { silent: editing });
1472
+ }, [reopenSeq]);
1473
+ // 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
1474
+ // 去会把 A 章的文字存进 B 章文件。
1475
+ useEffect(function () {
1476
+ setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false;
1477
+ setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false;
1478
+ }, [file]);
1362
1479
  var ch = null;
1363
1480
  var chapters = (snap && snap.chapters) || [];
1364
1481
  for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
@@ -1379,6 +1496,70 @@ window.__ModuleLoader__.load({
1379
1496
  else toast.warn(tt("reader.copyFailed"));
1380
1497
  });
1381
1498
  }
1499
+ function startBodyEdit() {
1500
+ if (!detail.data || typeof detail.data.body !== "string") return;
1501
+ setBodyErr("");
1502
+ setDraft(detail.data.body);
1503
+ setEditing(true);
1504
+ }
1505
+ function cancelBodyEdit() {
1506
+ if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
1507
+ setEditing(false); setDraft(""); setBodyErr("");
1508
+ }
1509
+ function saveBody() {
1510
+ if (bodyBusy || !detail.data) return;
1511
+ setBodyBusy(true); setBodyErr("");
1512
+ // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast)。
1513
+ apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }).then(function (res) {
1514
+ // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
1515
+ // 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
1516
+ setDetail({ loading: false, data: Object.assign({}, detail.data, {
1517
+ body: draft,
1518
+ hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
1519
+ }), err: "" });
1520
+ setEditing(false); setDraft("");
1521
+ return reload(); // 字数/统计随正文变,快照由 reload 刷新
1522
+ }).catch(function (err) {
1523
+ setBodyErr(err.message);
1524
+ // 409 = 章节文件在进入编辑态后被外部改过:草稿丢弃、重拉磁盘真值放回
1525
+ // 编辑器(与 ProjectMetaForm 同纪律),别让用户在过期基线上盲改。
1526
+ if (err && err.status === 409) { bodyResetRef.current = true; loadDetail(file); }
1527
+ // 其他失败(网络抖动等)不动草稿,只报错。
1528
+ }).finally(function () { setBodyBusy(false); });
1529
+ }
1530
+ function startOutlineEdit() {
1531
+ var ol = detail.data && detail.data.outline;
1532
+ if (!ol || typeof ol.body !== "string") return;
1533
+ setOlErr("");
1534
+ setOlDraft(ol.body);
1535
+ setOlEditing(true);
1536
+ }
1537
+ function cancelOutlineEdit() {
1538
+ if (olBusy) return;
1539
+ setOlEditing(false); setOlDraft(""); setOlErr("");
1540
+ }
1541
+ function saveOutline() {
1542
+ var ol = detail.data && detail.data.outline;
1543
+ if (olBusy || !ol) return;
1544
+ setOlBusy(true); setOlErr("");
1545
+ apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }).then(function (res) {
1546
+ setDetail({ loading: false, data: Object.assign({}, detail.data, {
1547
+ outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
1548
+ }), err: "" });
1549
+ setOlEditing(false); setOlDraft("");
1550
+ // 章纲字数 chip 客户端不算 CJK——silent 重拉静默对齐,不闪骨架。
1551
+ return reload().then(function () { return loadDetail(file, { silent: true }); });
1552
+ }).catch(function (err) {
1553
+ setOlErr(err.message);
1554
+ if (err && err.status === 409) { olResetRef.current = true; loadDetail(file); }
1555
+ }).finally(function () { setOlBusy(false); });
1556
+ }
1557
+ // 章纲编辑钩子(传给 OutlinePanel;无章纲返回 null = 只读,不给编辑入口——
1558
+ // v1 不做面板新建章纲文件)。
1559
+ function outlineEditor() {
1560
+ if (!detail.data || !detail.data.outline) return null;
1561
+ return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline };
1562
+ }
1382
1563
  return h("div", { className: "myt-reader" },
1383
1564
  h("div", { className: "myt-readerBar" },
1384
1565
  h("button", { type: "button", className: "myt-btn", onClick: onBack, title: tt("reader.backTip") },
@@ -1386,21 +1567,53 @@ window.__ModuleLoader__.load({
1386
1567
  h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
1387
1568
  ch ? statusBadge(ch.status) : null,
1388
1569
  h("div", { className: "myt-readerBarActions" },
1570
+ !editing && detail !== null && detail.data !== null && !detail.err ? h("button", {
1571
+ type: "button", className: "myt-btn myt-copyBtn",
1572
+ title: tt("reader.editTip"), "aria-label": tt("reader.editTip"),
1573
+ onClick: startBodyEdit,
1574
+ }, ic("IconEditOutlineRegular", 13), tt("reader.edit")) : null,
1389
1575
  h("button", {
1390
1576
  type: "button", className: "myt-btn myt-copyBtn",
1391
1577
  title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
1392
1578
  onClick: copyChapterPath,
1393
- }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")))),
1579
+ }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")),
1580
+ // 删除章节(0.1.20 写面):服务端不做二次确认,弹层在下方强制;
1581
+ // 删除成功 → 重拉快照 + 退出阅读页回列表(章节已不存在)。
1582
+ h("button", {
1583
+ type: "button", className: "myt-btn myt-btnDanger",
1584
+ title: tt("reader.deleteChapter"), "aria-label": tt("reader.deleteChapter"),
1585
+ onClick: function () { setDelChapterOpen(true); },
1586
+ }, tt("reader.deleteChapter")))),
1394
1587
  h("div", { className: "myt-readerBody" },
1395
1588
  !detail || detail.loading ? h("div", { className: "myt-stack" }, h("div", { className: "myt-skel myt-skelRow" }), h("div", { className: "myt-skel myt-skelRow", style: { width: "80%" } }), h("div", { className: "myt-skel myt-skelRow", style: { width: "64%" } }))
1396
1589
  : detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
1397
1590
  : [
1398
1591
  (function () {
1399
1592
  var ol = detail.data && detail.data.outline;
1400
- return ol ? h(OutlinePanel, { key: "outline", outline: ol })
1401
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
1593
+ return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor(), onDelete: stemOk ? function () { setDelOutlineOpen(true); } : null })
1594
+ : [
1595
+ h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline")),
1596
+ // 新建章纲(0.1.20 写面):outlines/<stem>.md 原不存在才走到
1597
+ // 这里;预填五件内容骨架,建好即重拉详情点亮章纲对照栏。
1598
+ stemOk ? h("button", {
1599
+ key: "outline-create", type: "button", className: "myt-btn",
1600
+ disabled: coBusy, title: tt("reader.createOutline"), "aria-label": tt("reader.createOutline"),
1601
+ onClick: createOutline,
1602
+ }, coBusy ? tt("src.creating") : tt("reader.createOutline")) : null,
1603
+ ];
1402
1604
  })(),
1403
- h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || tt("reader.emptyBody")),
1605
+ editing
1606
+ ? h("div", { key: "body", className: "myt-bodyEdit" },
1607
+ h("textarea", {
1608
+ className: "myt-chapterEditArea", rows: 20, spellCheck: false,
1609
+ value: draft, onChange: function (e) { setDraft(e.target.value); },
1610
+ }),
1611
+ h("div", { className: "myt-editHint" }, tt("reader.bodyEditHint")),
1612
+ bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
1613
+ h("div", { className: "myt-setActions" },
1614
+ h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: cancelBodyEdit }, tt("novel.cancel")),
1615
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: saveBody }, bodyBusy ? tt("novel.saving") : tt("reader.save"))))
1616
+ : h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || tt("reader.emptyBody")),
1404
1617
  ],
1405
1618
  ch ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
1406
1619
  return h("div", { key: dim[0], className: "myt-dim" },
@@ -1415,7 +1628,29 @@ window.__ModuleLoader__.load({
1415
1628
  return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
1416
1629
  h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
1417
1630
  flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
1418
- })()) : null);
1631
+ })()) : null,
1632
+ delChapterOpen ? h(ConfirmModal, {
1633
+ title: tt("reader.deleteChapter"),
1634
+ text: tf("src.confirmText", { rel: "chapters/" + file }),
1635
+ onCancel: function () { setDelChapterOpen(false); },
1636
+ onConfirm: function () {
1637
+ return apiPost("file-delete", { path: wsPath, rel: "chapters/" + file }).then(function () {
1638
+ setDelChapterOpen(false);
1639
+ return reload().then(onBack); // 章已删:重拉快照后退出阅读页回列表
1640
+ });
1641
+ },
1642
+ }) : null,
1643
+ delOutlineOpen ? h(ConfirmModal, {
1644
+ title: tt("outline.deleteBtn"),
1645
+ text: tf("src.confirmText", { rel: "outlines/" + stem + ".md" }),
1646
+ onCancel: function () { setDelOutlineOpen(false); },
1647
+ onConfirm: function () {
1648
+ return apiPost("file-delete", { path: wsPath, rel: "outlines/" + stem + ".md" }).then(function () {
1649
+ setDelOutlineOpen(false);
1650
+ return reload().then(function () { return loadDetail(file); }); // 章纲栏熄灭、空态亮出
1651
+ });
1652
+ },
1653
+ }) : null);
1419
1654
  }
1420
1655
 
1421
1656
  // ── 书稿元信息编辑表单(概览「编辑」按钮展开):可视化 project.yml 平
@@ -1535,30 +1770,59 @@ window.__ModuleLoader__.load({
1535
1770
  busy ? tt("novel.saving") : tt("novel.save"))));
1536
1771
  }
1537
1772
 
1538
- // ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(只读正文 + 五维 + 流转)────
1773
+ // ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(正文阅读/编辑 + 五维 + 流转)────
1539
1774
  var FIVE_DIMS = [
1540
1775
  ["cognitive_frame", tt("dim.cognitiveFrame")], ["emotional_anchor", tt("dim.emotionalAnchor")], ["world_texture", tt("dim.worldTexture")],
1541
1776
  ["suspense", tt("dim.suspense")], ["interpersonal_tension", tt("dim.interpersonalTension")],
1542
1777
  ];
1543
- // 章纲对照栏(五期):outline 非空时渲染可折叠原文(默认展开,只读,样式
1544
- // 与正文区分);共享常量避免重渲染时折叠态意外重置——组件随抽屉挂载,
1545
- // 切章即重挂回默认展开。
1778
+ // 章纲对照栏(五期):outline 非空时渲染可折叠原文(默认展开,样式与正文
1779
+ // 区分);共享常量避免重渲染时折叠态意外重置——组件随抽屉挂载,切章即重
1780
+ // 挂回默认展开。0.1.19 起带编辑钩子(editor 由 ChapterReader 注入,null =
1781
+ // 只读):编辑态整段 textarea 替换 MdView,保存走 outline-save(hash 旧版
1782
+ // 检测);无章纲不给编辑入口(v1 不做面板新建章纲文件)。
1546
1783
  function OutlinePanel(props) {
1547
1784
  var outline = props.outline;
1548
- var _op = useState(true), open = _op[0], setOpen = _op[1];
1785
+ var ed = props.editor || null; // { active, draft, busy, err, start, change, cancel, save }
1786
+ var _op = useState(true), openState = _op[0], setOpen = _op[1];
1787
+ var open = ed !== null && ed.active === true ? true : openState; // 编辑态强制展开
1549
1788
  var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : tt("outline.empty");
1550
1789
  return h("div", { className: "myt-outlinePanel" },
1551
1790
  h("button", {
1552
1791
  type: "button", className: "myt-outlineHead",
1553
1792
  "aria-expanded": open ? "true" : "false",
1554
1793
  title: open ? tt("outline.collapseTip") : tt("outline.expandTip"),
1555
- onClick: function () { setOpen(!open); },
1794
+ onClick: function () { setOpen(!openState); },
1556
1795
  },
1557
1796
  h("span", { className: "myt-outlineTitle" }, tt("outline.title")),
1558
1797
  typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, tf("common.charCount", { n: fmtNum(outline.words) })) : null,
1798
+ // 编辑入口:head 是 <button>,里面不能再套 button——span + role 补键盘语义
1799
+ ed !== null && !ed.active ? h("span", {
1800
+ className: "myt-outlineEditBtn", role: "button", tabIndex: 0,
1801
+ title: tt("outline.editTip"), "aria-label": tt("outline.editTip"),
1802
+ onClick: function (e) { e.stopPropagation(); ed.start(); },
1803
+ onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); ed.start(); } },
1804
+ }, ic("IconEditOutlineRegular", 12), tt("reader.edit")) : null,
1805
+ // 删除章纲(0.1.20 写面):同 head-span 形态;确认弹层由 ChapterReader 渲染。
1806
+ props.onDelete && ed !== null && !ed.active ? h("span", {
1807
+ className: "myt-outlineEditBtn myt-outlineDelBtn", role: "button", tabIndex: 0,
1808
+ title: tt("outline.deleteBtn"), "aria-label": tt("outline.deleteBtn"),
1809
+ onClick: function (e) { e.stopPropagation(); props.onDelete(); },
1810
+ onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); props.onDelete(); } },
1811
+ }, tt("outline.deleteBtn")) : null,
1559
1812
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
1560
1813
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
1561
- open ? h(MdView, { text: body, className: "myt-outlineBody" }) : null);
1814
+ open ? (ed !== null && ed.active
1815
+ ? h("div", { className: "myt-outlineEdit" },
1816
+ h("textarea", {
1817
+ className: "myt-setInput myt-setArea myt-outlineArea", rows: 10, spellCheck: false,
1818
+ value: ed.draft, onChange: function (e) { ed.change(e.target.value); },
1819
+ }),
1820
+ h("div", { className: "myt-editHint" }, tt("outline.editHint")),
1821
+ ed.err ? h(ErrorStrip, { text: ed.err }) : null,
1822
+ h("div", { className: "myt-setActions" },
1823
+ h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.cancel }, tt("novel.cancel")),
1824
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.save }, ed.busy ? tt("novel.saving") : tt("reader.save"))))
1825
+ : h(MdView, { text: body, className: "myt-outlineBody" })) : null);
1562
1826
  }
1563
1827
  // 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
1564
1828
  // overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
@@ -1611,6 +1875,171 @@ window.__ModuleLoader__.load({
1611
1875
  : h(MdView, { text: (detail.data && detail.data.body) || tt("outline.empty"), className: "myt-outlineBody" }))));
1612
1876
  }
1613
1877
 
1878
+ // ── 受管文件通用弹层三件套(0.1.20「全量可编辑写面」)────────────────────
1879
+ // 新建实体卡的预填模板(服务端只验形状,内容由客户端模板兜底——spec 各
1880
+ // 文件的最小骨架;数据文件按约定中文书写,不随界面语言切换)。
1881
+ var MANAGED_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; // 与服务端 managedRelCheck 同一条正则
1882
+ var TPL_CHARACTER = "---\nname: \nrole: supporting\n---\n\n";
1883
+ var TPL_WORLD = "---\ncategory: 地点\n---\n\n";
1884
+ var TPL_SCIENCE = "---\nlabel: known\n---\n\n";
1885
+ function outlineTemplateOf(ch, stem) {
1886
+ var num = ch && ch.num ? ch.num : ((stem.match(/c(\d+)/i) || [])[1] || "?");
1887
+ var title = ch && ch.title ? " " + ch.title : "";
1888
+ return "# 第" + num + "章" + title + " 章纲\n\n- 本章目标:\n- 节拍:\n- 出场角色:\n- 需埋/需收伏笔:\n- 与前章接口:\n";
1889
+ }
1890
+ // 各 tab 复用的「编辑源文件 / 删除文件」小按钮:title 点名文件路径,
1891
+ // 删除走红色按钮(确认弹层由调用方持 state 渲染)。
1892
+ function srcEditBtn(rel, onOpen) {
1893
+ return h("button", {
1894
+ type: "button", className: "myt-btn",
1895
+ title: tf("src.editSourceTip", { rel: rel }), "aria-label": tf("src.editSourceTip", { rel: rel }),
1896
+ onClick: onOpen,
1897
+ }, ic("IconEditOutlineRegular", 13), tt("src.editSource"));
1898
+ }
1899
+ function srcDeleteBtn(rel, onAsk) {
1900
+ return h("button", {
1901
+ type: "button", className: "myt-btn myt-btnDanger",
1902
+ title: tf("src.deleteTip", { rel: rel }), "aria-label": tf("src.deleteTip", { rel: rel }),
1903
+ onClick: onAsk,
1904
+ }, tt("src.deleteFile"));
1905
+ }
1906
+ // 编辑既有受管文件全文。打开即 GET file-source(请求序号防快速开关时的
1907
+ // 过期响应,对照 ChapterReader);保存 POST file-save 带 base_hash 基线,
1908
+ // 409 = 编辑期间文件被外部改过——照 ChapterReader 纪律:草稿丢弃、重拉
1909
+ // 磁盘真值放回编辑器,别让用户在过期基线上盲改。成功后关弹层并触发调用
1910
+ // 方 reload(快照重算),成败提示走 apiPost 的 toast 通道。
1911
+ function SourceEditorModal(props) {
1912
+ var title = props.title;
1913
+ var rel = props.rel;
1914
+ var wsPath = props.wsPath;
1915
+ var onClose = props.onClose;
1916
+ var onSaved = props.onSaved;
1917
+ var _d = useState(null), detail = _d[0], setDetail = _d[1]; // { data, err }
1918
+ var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
1919
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
1920
+ var _e = useState(""), err = _e[0], setErr = _e[1];
1921
+ var seqRef = useRef(0);
1922
+ var load = useCallback(function () {
1923
+ var seq = ++seqRef.current;
1924
+ setDetail(null); setErr("");
1925
+ apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
1926
+ if (seq !== seqRef.current) return;
1927
+ setDetail({ data: d, err: "" });
1928
+ setDraft(d && typeof d.text === "string" ? d.text : "");
1929
+ }).catch(function (e2) {
1930
+ if (seq !== seqRef.current) return;
1931
+ setDetail({ data: null, err: e2 && e2.message ? e2.message : tt("common.unknownErr") });
1932
+ });
1933
+ }, [wsPath, rel]);
1934
+ useEffect(function () { load(); }, [load]);
1935
+ function save() {
1936
+ if (busy || !detail || !detail.data) return;
1937
+ setBusy(true); setErr("");
1938
+ apiPost("file-save", { path: wsPath, rel: rel, text: draft, base_hash: detail.data.hash }).then(function () {
1939
+ onClose();
1940
+ if (onSaved) onSaved();
1941
+ }).catch(function (e2) {
1942
+ if (e2 && e2.status === 409) { setErr(tt("src.conflictReloaded")); load(); }
1943
+ else setErr(e2 && e2.message ? e2.message : tt("common.unknownErr"));
1944
+ }).finally(function () { setBusy(false); });
1945
+ }
1946
+ return h(MaskPortal, {
1947
+ className: "myt-modalMask",
1948
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
1949
+ },
1950
+ h("div", { className: "myt-modalCard myt-srcCard", role: "dialog", "aria-modal": "true", "aria-label": tt("src.modalAria") },
1951
+ h("div", { className: "myt-modalHead" },
1952
+ h("h2", { className: "myt-modalTitle" }, title),
1953
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
1954
+ h("div", { className: "myt-modalBody" },
1955
+ h("div", { className: "myt-srcRel" }, rel),
1956
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.editHint")),
1957
+ !detail ? h("div", { className: "myt-muted" }, tt("src.loading"))
1958
+ : detail.err ? h(ErrorStrip, { text: tf("src.loadFailed", { msg: detail.err }), onRetry: load })
1959
+ : [
1960
+ h("textarea", { key: "a", className: "myt-setInput myt-setArea myt-srcArea", rows: 16, spellCheck: false,
1961
+ value: draft, onChange: function (e) { setDraft(e.target.value); } }),
1962
+ err ? h(ErrorStrip, { key: "e", text: err }) : null,
1963
+ h("div", { key: "s", className: "myt-setActions" },
1964
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
1965
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save }, busy ? tt("novel.saving") : tt("novel.save"))),
1966
+ ])));
1967
+ }
1968
+ // 新建受管 md 实体卡(角色/设定/科学设定共用):slug 客户端预校(与服务
1969
+ // 端同一条正则,错在敲键时就报),内容按模板预填可改;失败(409 已存在等)
1970
+ // 留在弹层内报错,草稿不丢——建卡是低频操作,丢一遍模板重填太伤。
1971
+ function CreateFileModal(props) {
1972
+ var title = props.title;
1973
+ var dir = props.dir;
1974
+ var template = props.template;
1975
+ var wsPath = props.wsPath;
1976
+ var onClose = props.onClose;
1977
+ var onSaved = props.onSaved;
1978
+ var _s = useState(""), slug = _s[0], setSlug = _s[1];
1979
+ var _t = useState(template), text = _t[0], setText = _t[1];
1980
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
1981
+ var _e = useState(""), err = _e[0], setErr = _e[1];
1982
+ var slugOk = MANAGED_SLUG_RE.test(slug);
1983
+ function create() {
1984
+ if (busy) return;
1985
+ if (!slugOk) { setErr(tt("src.slugInvalid")); return; }
1986
+ setBusy(true); setErr("");
1987
+ apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }).then(function () {
1988
+ onClose();
1989
+ if (onSaved) onSaved();
1990
+ }).catch(function (e2) { setErr(e2 && e2.message ? e2.message : tt("common.unknownErr")); })
1991
+ .finally(function () { setBusy(false); });
1992
+ }
1993
+ return h(MaskPortal, {
1994
+ className: "myt-modalMask",
1995
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
1996
+ },
1997
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": title },
1998
+ h("div", { className: "myt-modalHead" },
1999
+ h("h2", { className: "myt-modalTitle" }, title),
2000
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2001
+ h("div", { className: "myt-modalBody" },
2002
+ h("label", { className: "myt-setField" },
2003
+ h("span", { className: "myt-setFieldLabel" }, tt("src.slugLabel")),
2004
+ h("input", { type: "text", className: "myt-setInput", placeholder: tt("src.slugPh"), value: slug, spellCheck: false,
2005
+ onChange: function (e) { setSlug(e.target.value); setErr(""); } })),
2006
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.createHint")),
2007
+ h("textarea", { className: "myt-setInput myt-setArea myt-srcArea", rows: 10, spellCheck: false,
2008
+ value: text, onChange: function (e) { setText(e.target.value); } }),
2009
+ err ? h(ErrorStrip, { text: err }) : null,
2010
+ h("div", { className: "myt-setActions" },
2011
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
2012
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy || slug === "", onClick: create }, busy ? tt("src.creating") : tt("src.create"))))));
2013
+ }
2014
+ // 删除二次确认(服务端不做二次确认,UI 层强制——删除不可逆,文案点名后
2015
+ // 悔靠 git、check 会标孤儿引用)。onConfirm 返回 promise,在途禁双击;
2016
+ // 成败提示走 apiPost 的 toast 通道。
2017
+ function ConfirmModal(props) {
2018
+ var title = props.title;
2019
+ var text = props.text;
2020
+ var onCancel = props.onCancel;
2021
+ var onConfirm = props.onConfirm;
2022
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2023
+ function confirm() {
2024
+ if (busy) return;
2025
+ setBusy(true);
2026
+ Promise.resolve(onConfirm()).catch(function () {}).finally(function () { setBusy(false); });
2027
+ }
2028
+ return h(MaskPortal, {
2029
+ className: "myt-modalMask",
2030
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onCancel(); },
2031
+ },
2032
+ h("div", { className: "myt-modalCard myt-confirmCard", role: "alertdialog", "aria-modal": "true", "aria-label": title },
2033
+ h("div", { className: "myt-modalHead" },
2034
+ h("h2", { className: "myt-modalTitle" }, title),
2035
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), disabled: busy, onClick: onCancel }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2036
+ h("div", { className: "myt-modalBody" },
2037
+ h("div", { className: "myt-confirmText" }, text),
2038
+ h("div", { className: "myt-setActions" },
2039
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onCancel }, tt("novel.cancel")),
2040
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy, onClick: confirm }, busy ? tt("src.deleting") : tt("src.confirmDelete"))))));
2041
+ }
2042
+
1614
2043
  function ChaptersTab(props) {
1615
2044
  var snap = props.snap;
1616
2045
  var wsPath = props.wsPath;
@@ -1732,10 +2161,20 @@ window.__ModuleLoader__.load({
1732
2161
  ];
1733
2162
  function CharactersTab(props) {
1734
2163
  var characters = props.snap.characters || [];
2164
+ var wsPath = props.wsPath;
2165
+ var reload = props.reload;
1735
2166
  var _m = useState(null), modalFile = _m[0], setModalFile = _m[1];
2167
+ // 受管文件三态:编辑 rel / 新建弹层开关 / 删除确认 rel(0.1.20 写面)。
2168
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2169
+ var _new = useState(false), createOpen = _new[0], setCreateOpen = _new[1];
2170
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
1736
2171
  var modal = null;
1737
2172
  for (var i = 0; i < characters.length; i++) { if (characters[i].file === modalFile) { modal = characters[i]; break; } }
1738
2173
  return h("div", { className: "myt-stack" },
2174
+ h("div", { className: "myt-cardHead" },
2175
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.characters")),
2176
+ h("span", { className: "myt-boardCount" }, characters.length),
2177
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateOpen(true); } }, tt("char.createBtn"))),
1739
2178
  characters.length ? h("div", { className: "myt-charWall" }, characters.map(function (c) {
1740
2179
  return h("button", { key: c.file, type: "button", className: "myt-card myt-charCard", onClick: function () { setModalFile(c.file); } },
1741
2180
  h("div", { className: "myt-charHead" },
@@ -1770,7 +2209,27 @@ window.__ModuleLoader__.load({
1770
2209
  : "—")),
1771
2210
  h("div", { className: "myt-kvRow" },
1772
2211
  h("span", { className: "myt-kvKey" }, tt("char.file")),
1773
- h("span", { className: "myt-kvVal" }, modal.file || "—"))))) : null);
2212
+ h("span", { className: "myt-kvVal" }, modal.file || "—")),
2213
+ // 源文件直达(0.1.20 写面):rel 由快照 basename 拼目录前缀。
2214
+ h("div", { className: "myt-setActions" },
2215
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setSrcRel("characters/" + modal.file); } },
2216
+ ic("IconEditOutlineRegular", 13), tt("src.editSource")),
2217
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", onClick: function () { setDelRel("characters/" + modal.file); } },
2218
+ tt("src.deleteFile")))))) : null,
2219
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2220
+ createOpen ? h(CreateFileModal, { title: tt("char.createBtn"), dir: "characters", template: TPL_CHARACTER, wsPath: wsPath, onClose: function () { setCreateOpen(false); }, onSaved: reload }) : null,
2221
+ delRel ? h(ConfirmModal, {
2222
+ title: tf("src.deleteTip", { rel: delRel }),
2223
+ text: tf("src.confirmText", { rel: delRel }),
2224
+ onCancel: function () { setDelRel(null); },
2225
+ onConfirm: function () {
2226
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2227
+ setDelRel(null);
2228
+ if (modalFile && "characters/" + modalFile === delRel) setModalFile(null); // 删的是打开中的卡:详情弹层一并收
2229
+ return reload();
2230
+ });
2231
+ },
2232
+ }) : null);
1774
2233
  }
1775
2234
 
1776
2235
  // ── 伏笔 tab:四列看板(列头计数 + 推进按钮 + priority 色点)────────────
@@ -1786,7 +2245,12 @@ window.__ModuleLoader__.load({
1786
2245
  .then(reload).catch(function () {}).finally(function () { setBusyId(null); });
1787
2246
  }
1788
2247
  var list = snap.foreshadows || [];
2248
+ // 伏笔台账源文件直达(0.1.20 写面):foreshadows.yml 整文件可编辑。
2249
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
1789
2250
  return h("div", { className: "myt-stack" },
2251
+ h("div", { className: "myt-cardHead" },
2252
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.foreshadows")),
2253
+ srcEditBtn("foreshadows.yml", function () { setSrcRel("foreshadows.yml"); })),
1790
2254
  list.length ? h("div", { className: "myt-board" }, FS_ORDER.map(function (col) {
1791
2255
  var items = list.filter(function (f) { return (f.status || "planted") === col; });
1792
2256
  return h("div", { key: col, className: "myt-boardCol" },
@@ -1820,17 +2284,33 @@ window.__ModuleLoader__.load({
1820
2284
  ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
1821
2285
  }),
1822
2286
  items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
1823
- })) : h(Empty, { text: tt("foreshadow.empty") }));
2287
+ })) : h(Empty, { text: tt("foreshadow.empty") }),
2288
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1824
2289
  }
1825
2290
 
1826
2291
  // ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
1827
2292
  function TimelineTab(props) {
1828
2293
  var items = (props.snap.timeline || []).slice();
2294
+ var wsPath = props.wsPath;
2295
+ var reload = props.reload;
2296
+ // 时间线账本源文件直达(0.1.20 写面):空态也保留入口——账本空不等于
2297
+ // 不可编辑,恰恰是第一条要从这里写进去。
2298
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2299
+ var head = h("div", { className: "myt-cardHead" },
2300
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.timeline")),
2301
+ srcEditBtn("timeline.yml", function () { setSrcRel("timeline.yml"); }));
1829
2302
  if (items.length) {
1830
2303
  items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
1831
2304
  }
1832
- if (!items.length) return h(Empty, { text: tt("timeline.empty") });
1833
- return h("div", { className: "myt-card" },
2305
+ if (!items.length) {
2306
+ return h("div", { className: "myt-stack" },
2307
+ head,
2308
+ h(Empty, { text: tt("timeline.empty") }),
2309
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2310
+ }
2311
+ return h("div", { className: "myt-stack" },
2312
+ head,
2313
+ h("div", { className: "myt-card" },
1834
2314
  h("div", { className: "myt-timeline" }, items.map(function (t, i) {
1835
2315
  var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
1836
2316
  return h("div", { key: i, className: "myt-tlRow" },
@@ -1840,15 +2320,165 @@ window.__ModuleLoader__.load({
1840
2320
  h("div", { className: "myt-tlTitle" }, orDash(t.title)),
1841
2321
  t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
1842
2322
  t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
1843
- })));
2323
+ }))),
2324
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1844
2325
  }
1845
2326
 
1846
2327
  // ── 设定 tab:world 按 category 分组 + science 三色标签 ─────────────────
2328
+ // ── 写作风格 tab:novel/style.md 基准的查看与编辑(写手/大纲师/审校共同
2329
+ // 遵守)。挂载/换工作区时 GET style 按需拉取(请求序号防过期响应,对照
2330
+ // OverviewTab 的 check effect);视图态 MdView 渲染全文,缺失时空态卡用
2331
+ // 插件模板预填创建;编辑态整文件保存(style-save 携 base_hash,409 人工
2332
+ // 改动保护链路照 ProjectMetaForm:成功接写后新指纹防连续保存假冲突,
2333
+ // 409 重拉磁盘真值后编辑器原地重置、停留编辑态让用户自行取舍)。──
2334
+ function StyleTab(props) {
2335
+ var wsPath = props.wsPath;
2336
+ var _st = useState(null), st = _st[0], setSt = _st[1]; // {loading, data, err}
2337
+ var seqRef = useRef(0);
2338
+ // 编辑态:draft 为 null = 视图态;字符串 = 编辑中的全文。draftHash 是
2339
+ // 进入编辑态(或上次保存成功)时的磁盘指纹,保存时作 base_hash 回传。
2340
+ var _dr = useState(null), draft = _dr[0], setDraft = _dr[1];
2341
+ var _dh = useState(null), draftHash = _dh[0], setDraftHash = _dh[1];
2342
+ var _er = useState(""), editErr = _er[0], setEditErr = _er[1];
2343
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2344
+ // 409 后的一次性重置标记:reload 带回磁盘真值时把编辑器与基线一并重置
2345
+ // (只置位于保存冲突路径;顶栏刷新等其他拉取不碰正在编辑的内容)。
2346
+ var pendingResetRef = useRef(false);
2347
+ // 换工作区:丢弃旧工作区的编辑态与在途编辑结果,再按新工作区重拉。
2348
+ var wsRef = useRef(wsPath);
2349
+ var load = useCallback(function () {
2350
+ var seq = ++seqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
2351
+ setSt({ loading: true, data: null, err: "" });
2352
+ apiGet("style", { path: wsPath }).then(function (d) {
2353
+ if (seq !== seqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
2354
+ setSt({ loading: false, data: d, err: "" });
2355
+ if (pendingResetRef.current) {
2356
+ // 409 冲突后的重拉:编辑器放回磁盘真值、基线对齐最新指纹,停留
2357
+ // 编辑态让用户基于最新内容自行取舍。
2358
+ pendingResetRef.current = false;
2359
+ setDraft(d && typeof d.text === "string" ? d.text : "");
2360
+ setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
2361
+ }
2362
+ }).catch(function (err) {
2363
+ if (seq !== seqRef.current) return;
2364
+ setSt({ loading: false, data: null, err: err.message });
2365
+ });
2366
+ }, [wsPath]);
2367
+ useEffect(function () {
2368
+ if (wsRef.current !== wsPath) {
2369
+ wsRef.current = wsPath;
2370
+ setDraft(null); setDraftHash(null); setEditErr(""); pendingResetRef.current = false;
2371
+ }
2372
+ load();
2373
+ }, [wsPath, load]);
2374
+ function startEdit() {
2375
+ if (!st.data || typeof st.data.text !== "string") return;
2376
+ setEditErr("");
2377
+ setDraftHash(typeof st.data.hash === "string" ? st.data.hash : null);
2378
+ setDraft(st.data.text);
2379
+ }
2380
+ function startFromTemplate() {
2381
+ if (!st.data || typeof st.data.template !== "string") return;
2382
+ setEditErr("");
2383
+ setDraftHash(null); // 文件尚不存在:无基线可护,保存即创建
2384
+ setDraft(st.data.template);
2385
+ }
2386
+ function cancelEdit() {
2387
+ if (busy) return; // 保存在途不许退出:避免卸载后 setState
2388
+ setDraft(null); setDraftHash(null); setEditErr("");
2389
+ }
2390
+ function save() {
2391
+ if (busy) return;
2392
+ setBusy(true); setEditErr("");
2393
+ // 成败提示统一走 apiPost 的 toast 通道;空文本允许保存(空文件 = 无
2394
+ // 基准,agent 侧优雅降级),不做非空校验。
2395
+ apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }).then(function (res) {
2396
+ // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地数据
2397
+ // 同步为新文本,取消编辑退回视图态时看到的已是刚落盘的内容。
2398
+ var hash = res && typeof res.hash === "string" ? res.hash : null;
2399
+ setDraftHash(hash);
2400
+ setSt({ loading: false, data: { found: true, text: draft, hash: hash, template: st.data ? st.data.template : null }, err: "" });
2401
+ }).catch(function (err) {
2402
+ setEditErr(err.message);
2403
+ // 409 = style.md 在进入编辑态后被外部改过:重拉磁盘真值并一次性
2404
+ // 把编辑器放回最新内容(停留编辑态),别让用户在过期基线上盲改。
2405
+ if (err && err.status === 409) {
2406
+ pendingResetRef.current = true;
2407
+ load();
2408
+ }
2409
+ }).finally(function () { setBusy(false); });
2410
+ }
2411
+ var data = st ? st.data : null;
2412
+ var head = h("div", { className: "myt-cardHead" },
2413
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.style")),
2414
+ h("span", { className: "myt-muted" }, tt("style.note")),
2415
+ draft === null
2416
+ ? h("button", {
2417
+ type: "button", className: "myt-btn",
2418
+ title: tt("style.editTip"), "aria-label": tt("style.editTip"),
2419
+ disabled: !data || (st && st.err) !== "" || busy,
2420
+ onClick: data && data.found ? startEdit : startFromTemplate,
2421
+ }, ic("IconEditOutlineRegular", 13), tt("overview.edit"))
2422
+ : null);
2423
+ // 拉取中:轻量骨架(对照 ChapterReader 的加载态);失败:错误条可重试。
2424
+ if (st === null || st.loading) {
2425
+ return h("div", { className: "myt-stack" },
2426
+ h("div", { className: "myt-card" }, head,
2427
+ h("div", { className: "myt-skel myt-skelRow", style: { width: "38%" } }),
2428
+ h("div", { className: "myt-skel myt-skelRow", style: { width: "82%" } }),
2429
+ h("div", { className: "myt-skel myt-skelRow", style: { width: "64%" } }),
2430
+ h("div", { className: "myt-skel myt-skelRow", style: { width: "74%" } })));
2431
+ }
2432
+ if (st.err !== "") {
2433
+ return h("div", { className: "myt-stack" },
2434
+ h("div", { className: "myt-card" }, head),
2435
+ h(ErrorStrip, { text: tf("style.loadFailed", { msg: st.err }), onRetry: load }));
2436
+ }
2437
+ if (draft !== null) {
2438
+ // 编辑态:整文件 textarea + 取消/保存(动作区与表单卡同构)。
2439
+ return h("div", { className: "myt-stack" },
2440
+ h("div", { className: "myt-card" }, head,
2441
+ h("textarea", {
2442
+ className: "myt-setInput myt-setArea myt-styleArea", rows: 18, spellCheck: false,
2443
+ value: draft, onChange: function (e) { setDraft(e.target.value); },
2444
+ })),
2445
+ editErr ? h(ErrorStrip, { text: editErr }) : null,
2446
+ h("div", { className: "myt-setActions" },
2447
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancelEdit }, tt("novel.cancel")),
2448
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
2449
+ busy ? tt("novel.saving") : tt("novel.save"))));
2450
+ }
2451
+ if (!data || data.found !== true) {
2452
+ // 文件未创建:空态卡 + 模板创建引导(模板读不到时按钮禁用)。
2453
+ var tplReady = !!(data && typeof data.template === "string");
2454
+ return h("div", { className: "myt-stack" },
2455
+ h("div", { className: "myt-card" }, head,
2456
+ h("div", { className: "myt-styleEmpty" },
2457
+ h("p", { className: "myt-muted" }, tt("style.empty")),
2458
+ h("button", {
2459
+ type: "button", className: "myt-btn myt-btnPrimary", disabled: !tplReady || busy,
2460
+ title: tt("style.editTip"), "aria-label": tt("style.editTip"),
2461
+ onClick: startFromTemplate,
2462
+ }, tt("style.createFromTpl")))));
2463
+ }
2464
+ // 视图态:MdView 渲染全文(空文件渲出空容器——空文件 = 无基准,是
2465
+ // 保存过的合法状态,不当缺失处理)。
2466
+ return h("div", { className: "myt-stack" },
2467
+ h("div", { className: "myt-card" }, head, h(MdView, { text: data.text || "" })));
2468
+ }
2469
+
1847
2470
  function WorldTab(props) {
1848
2471
  var snap = props.snap;
2472
+ var wsPath = props.wsPath;
2473
+ var reload = props.reload;
1849
2474
  var world = snap.world || [];
1850
2475
  var science = snap.science || [];
1851
2476
  var research = snap.research || [];
2477
+ // 受管文件三态:编辑 rel / 新建分区(world|science)/ 删除确认 rel。
2478
+ // research 是调研笔记目录,v1 无面板入口(快照有卡,写面暂不开放)。
2479
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2480
+ var _new = useState(null), createDir = _new[0], setCreateDir = _new[1];
2481
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
1852
2482
  // 无原型容器:key 来自数据文件的自由文本(category),值为
1853
2483
  // constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
1854
2484
  // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
@@ -1859,23 +2489,55 @@ window.__ModuleLoader__.load({
1859
2489
  groups[key].push(w);
1860
2490
  });
1861
2491
  var groupKeys = Object.keys(groups).sort(function (a, b) { return a.localeCompare(b, "zh-CN"); });
2492
+ // 三分区改子 tab 切换:纵向三卡堆太高,改子 tab 条 + 仅渲染激活分区。
2493
+ // state 存在本组件(切走再切回不丢;卡头 h3 随卡一起只画激活分区)。
2494
+ // research 空时原实现整卡隐藏,现 tab 恒在、空态走 world.researchEmpty。
2495
+ var _st = useState("world"), subTab = _st[0], setSubTab = _st[1];
2496
+ // world 分区内的二级分类 tab:state 只存用户上次的偏好,快照重拉后该
2497
+ // 分类可能已不存在(条目被删/改名),此时回落第一个分类;null = 首选
2498
+ // 第一个。单分类时不出 tab 条、直接平铺。
2499
+ var _cat = useState(null), catPref = _cat[0], setCatPref = _cat[1];
2500
+ var subDefs = [
2501
+ ["world", tt("world.title"), world.length],
2502
+ ["science", tt("world.science"), science.length],
2503
+ ["research", tt("world.research"), research.length],
2504
+ ];
2505
+ var activeCat = catPref !== null && groupKeys.indexOf(catPref) !== -1 ? catPref : groupKeys[0];
1862
2506
  return h("div", { className: "myt-stack" },
1863
- h("div", { className: "myt-card" },
1864
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
1865
- groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
1866
- return h("div", { key: key, className: "myt-worldGroup" },
1867
- h("div", { className: "myt-listHead" }, key, h("span", { className: "myt-boardCount" }, groups[key].length)),
1868
- groups[key].map(function (w, i) {
1869
- return h("div", { key: w.file || i, className: "myt-worldItem" },
1870
- h("div", { className: "myt-worldHead" },
1871
- h("span", { className: "myt-worldName" }, orDash(w.name)),
1872
- (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
1873
- w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
1874
- }));
1875
- })) : h(Empty, { text: tt("world.empty") })),
1876
- h("div", { className: "myt-card" },
2507
+ h("nav", { className: "myt-subTabs", "aria-label": tt("world.subTabsAria") },
2508
+ subDefs.map(function (s) {
2509
+ return h("button", {
2510
+ key: s[0], type: "button", className: "myt-subTab", "data-active": subTab === s[0] ? "true" : undefined,
2511
+ onClick: function () { setSubTab(s[0]); },
2512
+ }, s[1], h("span", { className: "myt-boardCount" }, String(s[2])));
2513
+ })),
2514
+ subTab === "world" ? h("div", { className: "myt-card" },
2515
+ h("div", { className: "myt-cardHead" },
2516
+ h("h3", { className: "myt-cardTitle" }, tt("world.title")),
2517
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("world"); } }, tt("world.createBtn"))),
2518
+ // 多分类:二级子 tab 条(分类名 + 条目计数),分组头不再需要——tab
2519
+ // 已承载分类名与计数;单分类/无分类不出 tab 条直接平铺。
2520
+ groupKeys.length > 1 ? h("nav", { className: "myt-subTabs myt-subTabsInner", "aria-label": tt("world.catTabsAria") },
2521
+ groupKeys.map(function (key) {
2522
+ return h("button", {
2523
+ key: key, type: "button", className: "myt-subTab", "data-active": activeCat === key ? "true" : undefined,
2524
+ onClick: function () { setCatPref(key); },
2525
+ }, key, h("span", { className: "myt-boardCount" }, String(groups[key].length)));
2526
+ })) : null,
2527
+ groupKeys.length ? h("div", { className: "myt-stack" }, groups[activeCat].map(function (w, i) {
2528
+ return h("div", { key: w.file || i, className: "myt-worldItem" },
2529
+ h("div", { className: "myt-worldHead" },
2530
+ h("span", { className: "myt-worldName" }, orDash(w.name)),
2531
+ (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
2532
+ w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null,
2533
+ h("div", { className: "myt-itemActions" },
2534
+ srcEditBtn("world/" + w.file, function () { setSrcRel("world/" + w.file); }),
2535
+ srcDeleteBtn("world/" + w.file, function () { setDelRel("world/" + w.file); })));
2536
+ })) : h(Empty, { text: tt("world.empty") })) : null,
2537
+ subTab === "science" ? h("div", { className: "myt-card" },
1877
2538
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
1878
- h("span", { className: "myt-boardCount" }, science.length)),
2539
+ h("span", { className: "myt-boardCount" }, science.length),
2540
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("science"); } }, tt("world.createScienceBtn"))),
1879
2541
  science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
1880
2542
  var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
1881
2543
  return h("div", { key: s.file || i, className: "myt-worldItem" },
@@ -1885,19 +2547,40 @@ window.__ModuleLoader__.load({
1885
2547
  label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
1886
2548
  s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
1887
2549
  (s.references || []).length ? h("div", { className: "myt-refList" },
1888
- s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null);
1889
- })) : h(Empty, { text: tt("world.scienceEmpty") })),
1890
- research.length ? h("div", { className: "myt-card" },
2550
+ s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null,
2551
+ h("div", { className: "myt-itemActions" },
2552
+ srcEditBtn("science/" + s.file, function () { setSrcRel("science/" + s.file); }),
2553
+ srcDeleteBtn("science/" + s.file, function () { setDelRel("science/" + s.file); })));
2554
+ })) : h(Empty, { text: tt("world.scienceEmpty") })) : null,
2555
+ subTab === "research" ? h("div", { className: "myt-card" },
1891
2556
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
1892
2557
  h("span", { className: "myt-boardCount" }, research.length)),
1893
- h("div", { className: "myt-stack" }, research.map(function (r, i) {
2558
+ research.length ? h("div", { className: "myt-stack" }, research.map(function (r, i) {
1894
2559
  return h("div", { key: r.file || i, className: "myt-worldGroup" },
1895
2560
  h("div", { className: "myt-listHead" },
1896
2561
  h("span", { className: "myt-worldName", title: r.file || "" }, orDash(r.topic) || r.file)),
1897
2562
  r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
1898
2563
  (r.sources || []).length ? h("div", { className: "myt-refList" },
1899
2564
  r.sources.map(function (s, j) { return h("span", { key: j, className: "myt-refItem", title: s }, tf("world.ref", { n: j + 1, r: s })); })) : null);
1900
- }))) : null);
2565
+ })) : h(Empty, { text: tt("world.researchEmpty") })) : null,
2566
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2567
+ createDir ? h(CreateFileModal, {
2568
+ title: createDir === "science" ? tt("world.createScienceBtn") : tt("world.createBtn"),
2569
+ dir: createDir,
2570
+ template: createDir === "science" ? TPL_SCIENCE : TPL_WORLD,
2571
+ wsPath: wsPath, onClose: function () { setCreateDir(null); }, onSaved: reload,
2572
+ }) : null,
2573
+ delRel ? h(ConfirmModal, {
2574
+ title: tf("src.deleteTip", { rel: delRel }),
2575
+ text: tf("src.confirmText", { rel: delRel }),
2576
+ onCancel: function () { setDelRel(null); },
2577
+ onConfirm: function () {
2578
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2579
+ setDelRel(null);
2580
+ return reload();
2581
+ });
2582
+ },
2583
+ }) : null);
1901
2584
  }
1902
2585
 
1903
2586
  // ── 索引板 tab:clues 按 kind 分组(resolved 划线)+ memories 按 category
@@ -1928,6 +2611,8 @@ window.__ModuleLoader__.load({
1928
2611
  memGroups[key].push(m);
1929
2612
  });
1930
2613
  function groupKey(a, b) { return a.localeCompare(b, "zh-CN"); }
2614
+ // 两本索引账源文件直达(0.1.20 写面):clue-board.yml / memories.yml。
2615
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
1931
2616
  return h("div", { className: "myt-stack" },
1932
2617
  h("div", { className: "myt-cardHead" },
1933
2618
  h("h3", { className: "myt-cardTitle" }, tt("index.title")),
@@ -1935,7 +2620,8 @@ window.__ModuleLoader__.load({
1935
2620
  rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
1936
2621
  h("div", { className: "myt-indexCols" },
1937
2622
  h("div", { className: "myt-card" },
1938
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length)),
2623
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length),
2624
+ srcEditBtn("clue-board.yml", function () { setSrcRel("clue-board.yml"); })),
1939
2625
  clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
1940
2626
  var ck = CLUE_KIND_KEYS[kind];
1941
2627
  var label = ck ? tt("clue." + ck) : kind;
@@ -1948,7 +2634,8 @@ window.__ModuleLoader__.load({
1948
2634
  }));
1949
2635
  }) : h(Empty, { text: tt("index.clueEmpty") })),
1950
2636
  h("div", { className: "myt-card" },
1951
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length)),
2637
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length),
2638
+ srcEditBtn("memories.yml", function () { setSrcRel("memories.yml"); })),
1952
2639
  memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
1953
2640
  var isPromise = /promise|承诺/i.test(cat);
1954
2641
  return h("div", { key: cat, className: "myt-worldGroup" },
@@ -1960,13 +2647,18 @@ window.__ModuleLoader__.load({
1960
2647
  h("span", { className: "myt-clueText" }, orDash(m.content)),
1961
2648
  m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
1962
2649
  }));
1963
- }) : h(Empty, { text: tt("index.memEmpty") }))));
2650
+ }) : h(Empty, { text: tt("index.memEmpty") }))),
2651
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1964
2652
  }
1965
2653
 
1966
- // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力,rAF 循环)─────────
1967
- // 节点固定 18px、名字 label 下方;边宽 = intensity 1-5 → 1-4px(缺省 3),
1968
- // type 标在边中点、hover 显示 description;拖拽固定、滚轮缩放、双击复位;
1969
- // 角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
2654
+ // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力 + 硬碰撞分离,rAF 循环)─
2655
+ // 节点固定 18px、名字 label 下方,最小间距 62 位置级互推保证静止帧不压叠;
2656
+ // 边为二次曲线(绕开途中节点、重复边互相分开),type 标在曲线 t=0.5 点、
2657
+ // hover 显示 description(命中检测按曲线几何采样);拖拽固定、滚轮缩放、
2658
+ // 双击复位;角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
2659
+ // 关系图节点角色配色:主角红 / 配角蓝 / 反派黑 / 龙套黄(用户指定四色);
2660
+ // 越枚举/缺省 role 一律并入龙套黄(查表 miss 落 minor),全节点恒有色。
2661
+ var NODE_ROLE_COLORS = { protagonist: "#f53f3f", supporting: "#165dff", antagonist: "#1d2129", minor: "#fadc19" };
1970
2662
  function edgeWidth(intensity) {
1971
2663
  var n = typeof intensity === "number" && isFinite(intensity) ? Math.round(intensity) : 3;
1972
2664
  n = Math.min(5, Math.max(1, n));
@@ -1974,12 +2666,19 @@ window.__ModuleLoader__.load({
1974
2666
  }
1975
2667
  function RelationsTab(props) {
1976
2668
  var snap = props.snap;
2669
+ var wsPath = props.wsPath;
2670
+ var reload = props.reload;
2671
+ // 关系账本源文件直达(0.1.20 写面):relations.yml 整文件可编辑。
2672
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
1977
2673
  var chars = snap.characters || [];
1978
2674
  var rels = (snap.relations || []).filter(function (r) { return r && r.a && r.b && r.a !== r.b; });
1979
2675
  var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
1980
2676
  var wrapRef = useRef(null);
1981
2677
  var canvasRef = useRef(null);
1982
2678
  var tipRafRef = useRef(0); // hover 提示 rAF 句柄:卸载/换图时取消在途合帧
2679
+ var zoomApiRef = useRef(null); // 缩放按钮桥:渲染层 → 绘图 effect 闭包里的 sim.zoom
2680
+ var _fs2 = useState(false), isFs = _fs2[0], setFs = _fs2[1]; // 当前处于全屏
2681
+ var _fok = useState(false), canFs = _fok[0], setCanFs = _fok[1]; // 宿主支持 Fullscreen API
1983
2682
  // 数据准备:>30 角色只留 relations 相关节点;角色空时退化为关系端点名。
1984
2683
  // useMemo 缓存:hover 提示等重渲染不再重复建图(原渲染体内 IIFE 每帧重建)。
1985
2684
  // 容器用无原型对象:角色名/关系端点是数据文件自由文本,见 WorldTab 处说明。
@@ -1997,6 +2696,14 @@ window.__ModuleLoader__.load({
1997
2696
  nodes.forEach(function (nd, i) { idx[nd.id] = i; });
1998
2697
  var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
1999
2698
  .map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
2699
+ // 同一对节点的重复边编号:dup = 按 (a,b) 无序对分组后的组内序号(0 起)。
2700
+ // draw 的 edgeCtrl 按 dup 放大曲线偏移并交替朝向,重复边互相分开不重合。
2701
+ var dupCount = Object.create(null);
2702
+ edges.forEach(function (e) {
2703
+ var key = Math.min(e.a, e.b) + "-" + Math.max(e.a, e.b);
2704
+ e.dup = dupCount[key] || 0;
2705
+ dupCount[key] = e.dup + 1;
2706
+ });
2000
2707
  return { nodes: nodes, edges: edges, unlinked: unlinked };
2001
2708
  }, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
2002
2709
  useEffect(function () {
@@ -2007,6 +2714,10 @@ window.__ModuleLoader__.load({
2007
2714
  if (!ctx) return undefined;
2008
2715
  var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
2009
2716
  var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
2717
+ // 缩放按钮桥:渲染层按钮 → effect 内 sim.zoom(钳制与滚轮同口径 0.4-2.5)
2718
+ zoomApiRef.current = {
2719
+ zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); },
2720
+ };
2010
2721
  // 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
2011
2722
  function readTokens() {
2012
2723
  var cs = getComputedStyle(document.body);
@@ -2034,7 +2745,50 @@ window.__ModuleLoader__.load({
2034
2745
  nd.vx = nd.vy = 0;
2035
2746
  });
2036
2747
  function toWorld(sx, sy) { return { x: (sx - sim.w / 2) / sim.zoom, y: (sy - sim.h / 2) / sim.zoom }; }
2037
- function toScreen(x, y) { return { x: sim.w / 2 + x * sim.zoom, y: sim.h / 2 + y * sim.zoom }; }
2748
+ // 每条边的二次贝塞尔控制点:bow 大小随边长缩放、钳 [12,28] 世界像素;
2749
+ // side ∈ {-1,+1} 曲线朝向(挂边对象上帧间保留,做迟滞防抖);同一对
2750
+ // 节点的重复边按 dup 序号放大偏移并交替朝向,互相分开不重合。
2751
+ function edgeCtrl(e) {
2752
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
2753
+ var dx = b.x - a.x, dy = b.y - a.y;
2754
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
2755
+ var ux = -dy / len, uy = dx / len; // 单位法向
2756
+ var mag = Math.min(28, Math.max(12, len * 0.1));
2757
+ var k = e.dup || 0;
2758
+ var m = mag * (1 + 0.6 * k);
2759
+ var side = (e.side || 1) * (k % 2 ? -1 : 1);
2760
+ return { cx: (a.x + b.x) / 2 + ux * m * side, cy: (a.y + b.y) / 2 + uy * m * side };
2761
+ }
2762
+ // 曲线(A、C、B 三点二次贝塞尔)采样 9 个点到各无关节点(跳过两端)
2763
+ // 的最小距离:值越大说明这一侧越空旷。
2764
+ function clearance(e, cx, cy) {
2765
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
2766
+ var best = 999;
2767
+ for (var t = 1; t <= 9; t++) {
2768
+ var tt = t / 10, it = 1 - tt;
2769
+ var px = it * it * a.x + 2 * it * tt * cx + tt * tt * b.x;
2770
+ var py = it * it * a.y + 2 * it * tt * cy + tt * tt * b.y;
2771
+ for (var ni = 0; ni < graph.nodes.length; ni++) {
2772
+ if (ni === e.a || ni === e.b) continue;
2773
+ var nd = graph.nodes[ni];
2774
+ var ddx = px - nd.x, ddy = py - nd.y;
2775
+ var d = Math.sqrt(ddx * ddx + ddy * ddy);
2776
+ if (d < best) best = d;
2777
+ }
2778
+ }
2779
+ return best;
2780
+ }
2781
+ // 朝向迟滞更新:算当前侧与取反侧的 clearance,只有另一侧明显更空
2782
+ // (+12 世界像素)才翻转——力导向抖动时曲线不左右闪。
2783
+ function updateSide(e) {
2784
+ var side = e.side || 1;
2785
+ var c1 = edgeCtrl(e);
2786
+ var cur = clearance(e, c1.cx, c1.cy);
2787
+ e.side = -side;
2788
+ var c2 = edgeCtrl(e);
2789
+ var alt = clearance(e, c2.cx, c2.cy);
2790
+ if (alt <= cur + 12) e.side = side; // 另一侧不明显更空:保持原朝向
2791
+ }
2038
2792
  function hitNode(sx, sy) {
2039
2793
  var p = toWorld(sx, sy);
2040
2794
  for (var i = graph.nodes.length - 1; i >= 0; i--) {
@@ -2048,13 +2802,23 @@ window.__ModuleLoader__.load({
2048
2802
  var p = toWorld(sx, sy);
2049
2803
  var best = null, bestD = 10;
2050
2804
  graph.edges.forEach(function (e) {
2805
+ // 命中跟曲线走:二次贝塞尔采样 9 段(10 点含两端),点到各线段
2806
+ // 距离取最小;edgeCtrl 与 draw 同源(side 存边对象上,天然一致)。
2051
2807
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
2052
- var ax = a.x, ay = a.y, bx = b.x - a.x, by = b.y - a.y;
2053
- var len2 = bx * bx + by * by || 1;
2054
- var t = Math.min(1, Math.max(0, ((p.x - ax) * bx + (p.y - ay) * by) / len2));
2055
- var dx = p.x - (ax + bx * t), dy = p.y - (ay + by * t);
2056
- var d = Math.sqrt(dx * dx + dy * dy);
2057
- if (d < bestD) { bestD = d; best = e; }
2808
+ var c = edgeCtrl(e);
2809
+ var qx = a.x, qy = a.y; // 上一采样点,首点 = 端点 A
2810
+ for (var k = 1; k <= 9; k++) {
2811
+ var tt = k / 9, it = 1 - tt;
2812
+ var nx = it * it * a.x + 2 * it * tt * c.cx + tt * tt * b.x;
2813
+ var ny = it * it * a.y + 2 * it * tt * c.cy + tt * tt * b.y;
2814
+ var segx = nx - qx, segy = ny - qy;
2815
+ var len2 = segx * segx + segy * segy || 1;
2816
+ var u = Math.min(1, Math.max(0, ((p.x - qx) * segx + (p.y - qy) * segy) / len2));
2817
+ var dx = p.x - (qx + segx * u), dy = p.y - (qy + segy * u);
2818
+ var d = Math.sqrt(dx * dx + dy * dy);
2819
+ if (d < bestD) { bestD = d; best = e; }
2820
+ qx = nx; qy = ny;
2821
+ }
2058
2822
  });
2059
2823
  return best;
2060
2824
  }
@@ -2076,7 +2840,7 @@ window.__ModuleLoader__.load({
2076
2840
  var a = nodes[e.a], b = nodes[e.b];
2077
2841
  var dx = b.x - a.x, dy = b.y - a.y;
2078
2842
  var d = Math.sqrt(dx * dx + dy * dy) || 1;
2079
- var f = (d - 120) * 0.02; // 弹簧,rest 120
2843
+ var f = (d - 130) * 0.02; // 弹簧,rest 130(原 120,多留缝隙配合硬碰撞分离)
2080
2844
  var fx = (dx / d) * f, fy = (dy / d) * f;
2081
2845
  a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
2082
2846
  });
@@ -2088,6 +2852,33 @@ window.__ModuleLoader__.load({
2088
2852
  nd.x += Math.max(-8, Math.min(8, nd.vx));
2089
2853
  nd.y += Math.max(-8, Math.min(8, nd.vy));
2090
2854
  });
2855
+ // 硬碰撞分离:力式斥力有上限(f 封顶 4),拥挤时节点仍会压叠;这里在
2856
+ // 速度积分之后按最小间距 62(两节点半径 18×2 + 名字 label 纵向净空)
2857
+ // 做位置级互推,保证静止帧节点(含下方 label)不重叠。固定节点
2858
+ // (fixed 或正在拖拽)不动、推对方,双固定跳过;位移不写回速度——
2859
+ // 纯位置修正,靠阻尼自然收敛,不与力导向抢劲。两轮 pass 处理推挤
2860
+ // 连锁(A 推开 B 后 B 又压到 C)。
2861
+ var MIN_DIST = 62;
2862
+ for (var pass = 0; pass < 2; pass++) {
2863
+ for (var ci = 0; ci < n; ci++) {
2864
+ for (var cj = ci + 1; cj < n; cj++) {
2865
+ var na = nodes[ci], nb = nodes[cj];
2866
+ var cdx = nb.x - na.x, cdy = nb.y - na.y;
2867
+ var cd2 = cdx * cdx + cdy * cdy;
2868
+ if (cd2 >= MIN_DIST * MIN_DIST) continue;
2869
+ var cd = Math.sqrt(cd2);
2870
+ if (cd < 0.01) { cdx = 1; cdy = 0; cd = 1; } // 完全重合:任意方向分开
2871
+ var push = (MIN_DIST - cd) / cd; // 归一化差向量:差多少补多少
2872
+ var aFixed = na.fixed || na === (sim.drag && sim.drag.node);
2873
+ var bFixed = nb.fixed || nb === (sim.drag && sim.drag.node);
2874
+ if (aFixed && bFixed) continue;
2875
+ var mA = aFixed ? 0 : (bFixed ? 1 : 0.5); // 固定方不动,另一方全担
2876
+ var mB = bFixed ? 0 : (aFixed ? 1 : 0.5);
2877
+ na.x -= cdx * push * mA; na.y -= cdy * push * mA;
2878
+ nb.x += cdx * push * mB; nb.y += cdy * push * mB;
2879
+ }
2880
+ }
2881
+ }
2091
2882
  }
2092
2883
  function draw() {
2093
2884
  if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
@@ -2099,29 +2890,30 @@ window.__ModuleLoader__.load({
2099
2890
  ctx.scale(sim.zoom, sim.zoom);
2100
2891
  graph.edges.forEach(function (e) {
2101
2892
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
2893
+ updateSide(e); // 朝向迟滞:另一侧明显更空才翻,防抖(详见 updateSide)
2894
+ var c = edgeCtrl(e);
2102
2895
  ctx.strokeStyle = tk.line;
2103
2896
  ctx.globalAlpha = 0.8;
2104
2897
  ctx.lineWidth = e.w;
2105
- ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
2898
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(c.cx, c.cy, b.x, b.y); ctx.stroke();
2106
2899
  if (e.type && sim.zoom >= 0.7) {
2107
2900
  ctx.globalAlpha = 1;
2108
2901
  ctx.fillStyle = tk.faint;
2109
2902
  ctx.font = "10px sans-serif";
2110
2903
  ctx.textAlign = "center";
2111
- ctx.fillText(e.type, (a.x + b.x) / 2, (a.y + b.y) / 2 - 3);
2904
+ // type 标在曲线 t=0.5 点(二次贝塞尔中点),与曲线本体一致
2905
+ ctx.fillText(e.type, 0.25 * a.x + 0.5 * c.cx + 0.25 * b.x, 0.25 * a.y + 0.5 * c.cy + 0.25 * b.y - 3);
2112
2906
  }
2113
2907
  });
2114
2908
  ctx.globalAlpha = 1;
2115
2909
  graph.nodes.forEach(function (nd) {
2116
- var s = toScreen(nd.x, nd.y);
2117
2910
  var r = 18; // 世界坐标画:半径随 zoom 缩放
2911
+ var rc = NODE_ROLE_COLORS[nd.role] || NODE_ROLE_COLORS.minor;
2118
2912
  ctx.beginPath();
2119
2913
  ctx.arc(nd.x, nd.y, r, 0, Math.PI * 2);
2120
- ctx.fillStyle = nd.fixed ? tk.accent : tk.card;
2121
- ctx.globalAlpha = nd.fixed ? 0.25 : 1;
2914
+ ctx.fillStyle = rc;
2122
2915
  ctx.fill();
2123
- ctx.globalAlpha = 1;
2124
- ctx.lineWidth = 1.5;
2916
+ ctx.lineWidth = nd.fixed ? 3 : 1.5; // 固定态=加粗 accent 描边(全节点恒有色后的唯一固定指示)
2125
2917
  ctx.strokeStyle = tk.accent;
2126
2918
  ctx.stroke();
2127
2919
  ctx.fillStyle = tk.label;
@@ -2192,6 +2984,7 @@ window.__ModuleLoader__.load({
2192
2984
  canvas.addEventListener("wheel", onWheel, { passive: false });
2193
2985
  canvas.addEventListener("dblclick", onDbl);
2194
2986
  return function () {
2987
+ zoomApiRef.current = null; // 桥随旧 sim 一起作废:换图间隙按钮点击不落到旧闭包
2195
2988
  cancelAnimationFrame(sim.raf);
2196
2989
  if (tipRafRef.current) { cancelAnimationFrame(tipRafRef.current); tipRafRef.current = 0; }
2197
2990
  if (ro) ro.disconnect();
@@ -2202,21 +2995,78 @@ window.__ModuleLoader__.load({
2202
2995
  canvas.removeEventListener("dblclick", onDbl);
2203
2996
  };
2204
2997
  }, [snap]);
2205
- if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
2998
+ // 全屏能力检测:Fullscreen API 可用才渲染全屏按钮(不支持的宿主不渲染,按钮不占位)
2999
+ useEffect(function () {
3000
+ var el = wrapRef.current;
3001
+ if (!el) return undefined;
3002
+ var ok = !!(el.requestFullscreen || el.webkitRequestFullscreen) &&
3003
+ document.fullscreenEnabled !== false && document.webkitFullscreenEnabled !== false;
3004
+ if (ok) setCanFs(true);
3005
+ return undefined;
3006
+ }, []);
3007
+ // 全屏状态跟随:浏览器 ESC 退出等非按钮路径也要同步按钮文案
3008
+ useEffect(function () {
3009
+ function sync() { setFs(!!(document.fullscreenElement || document.webkitFullscreenElement)); }
3010
+ document.addEventListener("fullscreenchange", sync);
3011
+ document.addEventListener("webkitfullscreenchange", sync);
3012
+ return function () {
3013
+ document.removeEventListener("fullscreenchange", sync);
3014
+ document.removeEventListener("webkitfullscreenchange", sync);
3015
+ };
3016
+ }, []);
3017
+ // 进/出全屏:webkit 前缀兜底;promise 拒绝吞掉(用户 ESC / 策略性拒绝不打断使用)
3018
+ function toggleFs() {
3019
+ var el = wrapRef.current;
3020
+ try {
3021
+ if (document.fullscreenElement || document.webkitFullscreenElement) {
3022
+ var exit = document.exitFullscreen || document.webkitExitFullscreen;
3023
+ if (exit) {
3024
+ var p = exit.call(document);
3025
+ if (p && typeof p.catch === "function") p.catch(function () {});
3026
+ }
3027
+ } else if (el) {
3028
+ var req = el.requestFullscreen || el.webkitRequestFullscreen;
3029
+ if (req) {
3030
+ var p2 = req.call(el);
3031
+ if (p2 && typeof p2.catch === "function") p2.catch(function () {});
3032
+ }
3033
+ }
3034
+ } catch (e) { /* 全屏 API 个别浏览器仍有策略性拒绝,静默 */ }
3035
+ }
3036
+ if (!graph.nodes.length) {
3037
+ return h("div", { className: "myt-stack" },
3038
+ h("div", { className: "myt-cardHead" },
3039
+ h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
3040
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
3041
+ h(Empty, { text: tt("relations.noData") }),
3042
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
3043
+ }
2206
3044
  return h("div", { className: "myt-stack" },
2207
3045
  h("div", { className: "myt-card" },
2208
3046
  h("div", { className: "myt-cardHead" },
2209
3047
  h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
2210
- h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length }))),
3048
+ h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length })),
3049
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
2211
3050
  h("div", { className: "myt-graphWrap", ref: wrapRef },
2212
3051
  h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
2213
- tip ? h("div", { className: "myt-graphTip", style: { left: Math.min(tip.x + 12, 320) + "px", top: Math.max(tip.y - 34, 4) + "px" } }, tip.text) : null),
2214
- h("div", { className: "myt-graphFoot" }, tt("relations.help"))),
3052
+ tip ? h("div", { className: "myt-graphTip", style: { left: Math.min(tip.x + 12, 320) + "px", top: Math.max(tip.y - 34, 4) + "px" } }, tip.text) : null,
3053
+ h("div", { className: "myt-graphTools" },
3054
+ h("button", { type: "button", className: "myt-graphTool", title: tt("relations.zoomOut"), "aria-label": tt("relations.zoomOut"), onClick: function () { if (zoomApiRef.current) zoomApiRef.current.zoomBy(1 / 1.25); } }, "-"),
3055
+ h("button", { type: "button", className: "myt-graphTool", title: tt("relations.zoomIn"), "aria-label": tt("relations.zoomIn"), onClick: function () { if (zoomApiRef.current) zoomApiRef.current.zoomBy(1.25); } }, "+"),
3056
+ canFs ? h("button", { type: "button", className: "myt-graphTool myt-graphToolText", title: isFs ? tt("relations.exitFs") : tt("relations.fs"), "aria-label": isFs ? tt("relations.exitFs") : tt("relations.fs"), onClick: toggleFs }, isFs ? tt("relations.exitFs") : tt("relations.fs")) : null)),
3057
+ h("div", { className: "myt-graphFoot" },
3058
+ tt("relations.help"),
3059
+ h("span", { className: "myt-graphLegend" },
3060
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "protagonist" }), tt("role.protagonist")),
3061
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "supporting" }), tt("role.supporting")),
3062
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "antagonist" }), tt("role.antagonist")),
3063
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "minor" }), tt("role.minor"))))),
2215
3064
  graph.unlinked.length ? h("div", { className: "myt-card" },
2216
3065
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
2217
3066
  h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
2218
3067
  return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
2219
- }))) : null);
3068
+ }))) : null,
3069
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2220
3070
  }
2221
3071
 
2222
3072
  // ── cordis client 插件入口 ──────────────────────────────────────────────