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/README.en.md +9 -8
- package/README.md +10 -9
- package/bin/maoying.mjs +2 -2
- package/docs/creator-panel.md +43 -14
- package/docs/design.md +10 -10
- package/docs/spec.md +10 -5
- package/index.mjs +90 -2
- package/lib/base.css +144 -2
- package/lib/client.js +937 -87
- package/lib/novel-parse.mjs +143 -1
- package/lib/panel-core.mjs +352 -15
- package/package.json +1 -1
- package/presets/maoying/agent.cordis.v2.yml +11 -11
- package/presets/maoying/agent.cordis.yml +11 -11
- package/skills/maoying-foreshadow-discipline/SKILL.md +1 -1
- package/skills/maoying-review-checklist/SKILL.md +9 -3
- package/templates/novel/README.md +13 -6
- package/templates/novel/memories.yml +3 -2
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
|
|
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
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
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
|
|
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
|
-
|
|
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 ·
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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(!
|
|
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 ?
|
|
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 || "—"))
|
|
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)
|
|
1833
|
-
|
|
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("
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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
|
-
|
|
1890
|
-
|
|
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 力导向(斥力 + 弹簧 +
|
|
1967
|
-
// 节点固定 18px、名字 label
|
|
1968
|
-
// type
|
|
1969
|
-
//
|
|
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
|
-
|
|
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
|
|
2053
|
-
var
|
|
2054
|
-
var
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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 -
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
2121
|
-
ctx.globalAlpha = nd.fixed ? 0.25 : 1;
|
|
2914
|
+
ctx.fillStyle = rc;
|
|
2122
2915
|
ctx.fill();
|
|
2123
|
-
ctx.
|
|
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
|
-
|
|
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
|
-
|
|
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 插件入口 ──────────────────────────────────────────────
|