dsh-maoying 0.1.18 → 0.1.19
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 +3 -3
- package/README.md +3 -3
- package/docs/creator-panel.md +18 -9
- package/docs/design.md +1 -1
- package/index.mjs +49 -2
- package/lib/base.css +120 -2
- package/lib/client.js +388 -27
- package/lib/novel-parse.mjs +12 -1
- package/lib/panel-core.mjs +162 -15
- package/package.json +1 -1
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,9 @@ 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");
|
|
246
249
|
return tt("toast.success");
|
|
247
250
|
}
|
|
248
251
|
|
|
@@ -811,6 +814,7 @@ window.__ModuleLoader__.load({
|
|
|
811
814
|
else if (tab === "relations") body = h(RelationsTab, tabProps);
|
|
812
815
|
else if (tab === "timeline") body = h(TimelineTab, tabProps);
|
|
813
816
|
else if (tab === "world") body = h(WorldTab, tabProps);
|
|
817
|
+
else if (tab === "style") body = h(StyleTab, tabProps);
|
|
814
818
|
else if (tab === "index") body = h(IndexTab, tabProps);
|
|
815
819
|
else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
|
|
816
820
|
}
|
|
@@ -1112,7 +1116,7 @@ window.__ModuleLoader__.load({
|
|
|
1112
1116
|
var UI_LANG = "zh";
|
|
1113
1117
|
var I18N = {
|
|
1114
1118
|
zh: {
|
|
1115
|
-
"shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 ·
|
|
1119
|
+
"shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 正文/章纲可编辑保存",
|
|
1116
1120
|
"header.curVerTitle": "当前版本", "header.upgradeTip": "当前 {cur},点击打开新版发布页", "header.newVersion": "有新版 {ver} ↗",
|
|
1117
1121
|
"header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
|
|
1118
1122
|
"header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
|
|
@@ -1125,7 +1129,12 @@ window.__ModuleLoader__.load({
|
|
|
1125
1129
|
"header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
|
|
1126
1130
|
"tabs.aria": "创作台分区",
|
|
1127
1131
|
"tabs.overview": "概览", "tabs.chapters": "章节", "tabs.characters": "角色", "tabs.foreshadows": "伏笔", "tabs.relations": "关系图",
|
|
1128
|
-
"tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板",
|
|
1132
|
+
"tabs.timeline": "时间线", "tabs.world": "设定", "tabs.style": "写作风格", "tabs.index": "索引板",
|
|
1133
|
+
"style.note": "写手 / 大纲师 / 审校以此为准;改完即生效",
|
|
1134
|
+
"style.editTip": "编辑写作风格基准(novel/style.md)",
|
|
1135
|
+
"style.empty": "novel/style.md 还没有创建。写手/大纲师/审校读取风格基准时将拿不到本文;可先用插件内置的通用网文默认基线创建,再按本书题材修改。",
|
|
1136
|
+
"style.createFromTpl": "用默认基线创建",
|
|
1137
|
+
"style.loadFailed": "写作风格读取失败:{msg}",
|
|
1129
1138
|
"shell.wsListFailed": "工作区列表拉取失败:{msg}", "shell.stateFailed": "快照拉取失败:{msg}",
|
|
1130
1139
|
"guide.noWsTitle": "还没有可用的小说工作区", "guide.noWsDesc": "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
|
|
1131
1140
|
"guide.notInitTitle": "该工作区尚未初始化小说项目", "guide.notInitDesc": "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
|
|
@@ -1136,6 +1145,7 @@ window.__ModuleLoader__.load({
|
|
|
1136
1145
|
"common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
|
|
1137
1146
|
"toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
|
|
1138
1147
|
"toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
|
|
1148
|
+
"toast.styleSaved": "写作风格已保存", "toast.chapterSaved": "章节正文已保存", "toast.outlineSaved": "章纲已保存",
|
|
1139
1149
|
"api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
|
|
1140
1150
|
"status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
|
|
1141
1151
|
"phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
|
|
@@ -1158,10 +1168,12 @@ window.__ModuleLoader__.load({
|
|
|
1158
1168
|
"timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
|
|
1159
1169
|
"world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
|
|
1160
1170
|
"world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
|
|
1171
|
+
"world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区",
|
|
1161
1172
|
"relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
|
|
1162
|
-
"relations.help": "拖节点固定位置 ·
|
|
1173
|
+
"relations.help": "拖节点固定位置 · 滚轮或 +/- 按钮缩放 · 双击复位 · 悬停边看关系描述 · 右上角按钮全屏",
|
|
1163
1174
|
"relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
|
|
1164
1175
|
"relations.edgeTipPrefix": "{type}:", "relations.noDesc": "(无描述)",
|
|
1176
|
+
"relations.zoomIn": "放大", "relations.zoomOut": "缩小", "relations.fs": "全屏", "relations.exitFs": "退出全屏",
|
|
1165
1177
|
"char.empty": "characters/ 目录还没有角色卡",
|
|
1166
1178
|
"role.protagonist": "主角", "role.antagonist": "反派", "role.supporting": "配角", "role.minor": "龙套",
|
|
1167
1179
|
"char.field.age": "年龄", "char.field.gender": "性别", "char.field.appearance": "外貌", "char.field.personality": "性格",
|
|
@@ -1180,6 +1192,9 @@ window.__ModuleLoader__.load({
|
|
|
1180
1192
|
"chapters.createTip": "在 chapters/ 下按章纲建一章(status=pending,正文待写)",
|
|
1181
1193
|
"chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
|
|
1182
1194
|
"reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
|
|
1195
|
+
"reader.edit": "编辑", "reader.editTip": "编辑章节正文(frontmatter 保留不动)",
|
|
1196
|
+
"reader.save": "保存", "reader.bodyEditHint": "直接编辑正文;frontmatter(--- 围栏)保留不动。编辑期间文件被作者或 agent 改过时保存会被拒绝,并放回磁盘最新内容。",
|
|
1197
|
+
"outline.editTip": "编辑章纲", "outline.editHint": "直接编辑章纲全文;编辑期间文件被外部改过时保存会被拒绝,并放回磁盘最新内容。",
|
|
1183
1198
|
"reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
|
|
1184
1199
|
"reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
|
|
1185
1200
|
"reader.flowing": "流转中…", "reader.advanceTo": "推进到「{next}」",
|
|
@@ -1201,7 +1216,7 @@ window.__ModuleLoader__.load({
|
|
|
1201
1216
|
"dim.suspense": "悬念", "dim.interpersonalTension": "人际张力",
|
|
1202
1217
|
},
|
|
1203
1218
|
en: {
|
|
1204
|
-
"shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow ·
|
|
1219
|
+
"shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · chapter text & outlines editable",
|
|
1205
1220
|
"header.curVerTitle": "Current version", "header.upgradeTip": "Current {cur} — click to open the release page", "header.newVersion": "New version {ver} ↗",
|
|
1206
1221
|
"header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
|
|
1207
1222
|
"header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
|
|
@@ -1214,7 +1229,12 @@ window.__ModuleLoader__.load({
|
|
|
1214
1229
|
"header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
|
|
1215
1230
|
"tabs.aria": "Studio sections",
|
|
1216
1231
|
"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",
|
|
1232
|
+
"tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.style": "Style", "tabs.index": "Index Board",
|
|
1233
|
+
"style.note": "Writer / outliner / reviewer all follow this baseline; changes take effect immediately",
|
|
1234
|
+
"style.editTip": "Edit the style baseline (novel/style.md)",
|
|
1235
|
+
"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.",
|
|
1236
|
+
"style.createFromTpl": "Create from default baseline",
|
|
1237
|
+
"style.loadFailed": "Failed to load the style baseline: {msg}",
|
|
1218
1238
|
"shell.wsListFailed": "Failed to load workspace list: {msg}", "shell.stateFailed": "Failed to load snapshot: {msg}",
|
|
1219
1239
|
"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
1240
|
"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 +1245,7 @@ window.__ModuleLoader__.load({
|
|
|
1225
1245
|
"common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
|
|
1226
1246
|
"toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
|
|
1227
1247
|
"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}",
|
|
1248
|
+
"toast.styleSaved": "Style baseline saved", "toast.chapterSaved": "Chapter text saved", "toast.outlineSaved": "Outline saved",
|
|
1228
1249
|
"api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
|
|
1229
1250
|
"status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
|
|
1230
1251
|
"phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
|
|
@@ -1247,10 +1268,12 @@ window.__ModuleLoader__.load({
|
|
|
1247
1268
|
"timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
|
|
1248
1269
|
"world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
|
|
1249
1270
|
"world.scienceEmpty": "No science entries in science/ yet", "world.research": "Research", "world.ref": "Ref {n}: {r}",
|
|
1271
|
+
"world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections",
|
|
1250
1272
|
"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",
|
|
1273
|
+
"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
1274
|
"relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
|
|
1253
1275
|
"relations.edgeTipPrefix": "{type}: ", "relations.noDesc": "(no description)",
|
|
1276
|
+
"relations.zoomIn": "Zoom in", "relations.zoomOut": "Zoom out", "relations.fs": "Full screen", "relations.exitFs": "Exit full screen",
|
|
1254
1277
|
"char.empty": "No character cards in characters/ yet",
|
|
1255
1278
|
"role.protagonist": "Protagonist", "role.antagonist": "Antagonist", "role.supporting": "Supporting", "role.minor": "Minor",
|
|
1256
1279
|
"char.field.age": "Age", "char.field.gender": "Gender", "char.field.appearance": "Appearance", "char.field.personality": "Personality",
|
|
@@ -1269,6 +1292,9 @@ window.__ModuleLoader__.load({
|
|
|
1269
1292
|
"chapters.createTip": "Create a pending chapter in chapters/ from this outline (body to be written)",
|
|
1270
1293
|
"chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
|
|
1271
1294
|
"reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
|
|
1295
|
+
"reader.edit": "Edit", "reader.editTip": "Edit the chapter text (frontmatter is preserved)",
|
|
1296
|
+
"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.",
|
|
1297
|
+
"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
1298
|
"reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
|
|
1273
1299
|
"reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
|
|
1274
1300
|
"reader.flowing": "Transitioning…", "reader.advanceTo": "Advance to {next}",
|
|
@@ -1336,7 +1362,9 @@ window.__ModuleLoader__.load({
|
|
|
1336
1362
|
|
|
1337
1363
|
// ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
|
|
1338
1364
|
// 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
|
|
1339
|
-
// 遮挡;整页视图天然在面板文档流内,无遮挡问题。
|
|
1365
|
+
// 遮挡;整页视图天然在面板文档流内,无遮挡问题。0.1.19 起正文/章纲可在
|
|
1366
|
+
// 本页编辑保存(textarea + 手动保存,无自动保存——IME 组合词风险天然规
|
|
1367
|
+
// 避;409 人工改动保护链路照 StyleTab)。──
|
|
1340
1368
|
function ChapterReader(props) {
|
|
1341
1369
|
var file = props.file;
|
|
1342
1370
|
var wsPath = props.wsPath;
|
|
@@ -1345,20 +1373,43 @@ window.__ModuleLoader__.load({
|
|
|
1345
1373
|
var onBack = props.onBack;
|
|
1346
1374
|
var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
|
|
1347
1375
|
var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
|
|
1376
|
+
// 编辑态:draft 是受控草稿,只在进入编辑与切章时初始化——重渲染与
|
|
1377
|
+
// detail 重拉都不碰它,否则打字会被中途清掉。olDraft 同构管章纲对照栏。
|
|
1378
|
+
var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
|
|
1379
|
+
var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
|
|
1380
|
+
var _oe = useState(false), olEditing = _oe[0], setOlEditing = _oe[1];
|
|
1381
|
+
var _odr = useState(""), olDraft = _odr[0], setOlDraft = _odr[1];
|
|
1382
|
+
var _bb = useState(false), bodyBusy = _bb[0], setBodyBusy = _bb[1];
|
|
1383
|
+
var _ob = useState(false), olBusy = _ob[0], setOlBusy = _ob[1];
|
|
1384
|
+
var _be = useState(""), bodyErr = _be[0], setBodyErr = _be[1];
|
|
1385
|
+
var _oerr = useState(""), olErr = _oerr[0], setOlErr = _oerr[1];
|
|
1348
1386
|
var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
|
|
1349
|
-
|
|
1387
|
+
// 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
|
|
1388
|
+
// (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
|
|
1389
|
+
var bodyResetRef = useRef(false);
|
|
1390
|
+
var olResetRef = useRef(false);
|
|
1391
|
+
var loadDetail = useCallback(function (f, opts) {
|
|
1350
1392
|
var seq = ++detailSeqRef.current;
|
|
1351
1393
|
if (!f) { setDetail(null); return; }
|
|
1352
|
-
|
|
1394
|
+
// silent:不闪骨架的原地刷新(章纲保存后对齐字数 chip 用),草稿不受影响
|
|
1395
|
+
if (!(opts && opts.silent)) setDetail({ loading: true, data: null, err: "" });
|
|
1353
1396
|
apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
|
|
1354
1397
|
if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
|
|
1355
1398
|
setDetail({ loading: false, data: d, err: "" });
|
|
1399
|
+
if (bodyResetRef.current) { bodyResetRef.current = false; setDraft(d && typeof d.body === "string" ? d.body : ""); }
|
|
1400
|
+
if (olResetRef.current) { olResetRef.current = false; setOlDraft(d && d.outline && typeof d.outline.body === "string" ? d.outline.body : ""); }
|
|
1356
1401
|
}).catch(function (err) {
|
|
1357
1402
|
if (seq !== detailSeqRef.current) return;
|
|
1358
1403
|
setDetail({ loading: false, data: null, err: err.message });
|
|
1359
1404
|
});
|
|
1360
1405
|
}, [wsPath]);
|
|
1361
1406
|
useEffect(function () { loadDetail(file); }, [file, loadDetail]);
|
|
1407
|
+
// 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
|
|
1408
|
+
// 去会把 A 章的文字存进 B 章文件。
|
|
1409
|
+
useEffect(function () {
|
|
1410
|
+
setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false;
|
|
1411
|
+
setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false;
|
|
1412
|
+
}, [file]);
|
|
1362
1413
|
var ch = null;
|
|
1363
1414
|
var chapters = (snap && snap.chapters) || [];
|
|
1364
1415
|
for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
|
|
@@ -1379,6 +1430,70 @@ window.__ModuleLoader__.load({
|
|
|
1379
1430
|
else toast.warn(tt("reader.copyFailed"));
|
|
1380
1431
|
});
|
|
1381
1432
|
}
|
|
1433
|
+
function startBodyEdit() {
|
|
1434
|
+
if (!detail.data || typeof detail.data.body !== "string") return;
|
|
1435
|
+
setBodyErr("");
|
|
1436
|
+
setDraft(detail.data.body);
|
|
1437
|
+
setEditing(true);
|
|
1438
|
+
}
|
|
1439
|
+
function cancelBodyEdit() {
|
|
1440
|
+
if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
|
|
1441
|
+
setEditing(false); setDraft(""); setBodyErr("");
|
|
1442
|
+
}
|
|
1443
|
+
function saveBody() {
|
|
1444
|
+
if (bodyBusy || !detail.data) return;
|
|
1445
|
+
setBodyBusy(true); setBodyErr("");
|
|
1446
|
+
// 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast)。
|
|
1447
|
+
apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }).then(function (res) {
|
|
1448
|
+
// 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
|
|
1449
|
+
// 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
|
|
1450
|
+
setDetail({ loading: false, data: Object.assign({}, detail.data, {
|
|
1451
|
+
body: draft,
|
|
1452
|
+
hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
|
|
1453
|
+
}), err: "" });
|
|
1454
|
+
setEditing(false); setDraft("");
|
|
1455
|
+
return reload(); // 字数/统计随正文变,快照由 reload 刷新
|
|
1456
|
+
}).catch(function (err) {
|
|
1457
|
+
setBodyErr(err.message);
|
|
1458
|
+
// 409 = 章节文件在进入编辑态后被外部改过:草稿丢弃、重拉磁盘真值放回
|
|
1459
|
+
// 编辑器(与 ProjectMetaForm 同纪律),别让用户在过期基线上盲改。
|
|
1460
|
+
if (err && err.status === 409) { bodyResetRef.current = true; loadDetail(file); }
|
|
1461
|
+
// 其他失败(网络抖动等)不动草稿,只报错。
|
|
1462
|
+
}).finally(function () { setBodyBusy(false); });
|
|
1463
|
+
}
|
|
1464
|
+
function startOutlineEdit() {
|
|
1465
|
+
var ol = detail.data && detail.data.outline;
|
|
1466
|
+
if (!ol || typeof ol.body !== "string") return;
|
|
1467
|
+
setOlErr("");
|
|
1468
|
+
setOlDraft(ol.body);
|
|
1469
|
+
setOlEditing(true);
|
|
1470
|
+
}
|
|
1471
|
+
function cancelOutlineEdit() {
|
|
1472
|
+
if (olBusy) return;
|
|
1473
|
+
setOlEditing(false); setOlDraft(""); setOlErr("");
|
|
1474
|
+
}
|
|
1475
|
+
function saveOutline() {
|
|
1476
|
+
var ol = detail.data && detail.data.outline;
|
|
1477
|
+
if (olBusy || !ol) return;
|
|
1478
|
+
setOlBusy(true); setOlErr("");
|
|
1479
|
+
apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }).then(function (res) {
|
|
1480
|
+
setDetail({ loading: false, data: Object.assign({}, detail.data, {
|
|
1481
|
+
outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
|
|
1482
|
+
}), err: "" });
|
|
1483
|
+
setOlEditing(false); setOlDraft("");
|
|
1484
|
+
// 章纲字数 chip 客户端不算 CJK——silent 重拉静默对齐,不闪骨架。
|
|
1485
|
+
return reload().then(function () { return loadDetail(file, { silent: true }); });
|
|
1486
|
+
}).catch(function (err) {
|
|
1487
|
+
setOlErr(err.message);
|
|
1488
|
+
if (err && err.status === 409) { olResetRef.current = true; loadDetail(file); }
|
|
1489
|
+
}).finally(function () { setOlBusy(false); });
|
|
1490
|
+
}
|
|
1491
|
+
// 章纲编辑钩子(传给 OutlinePanel;无章纲返回 null = 只读,不给编辑入口——
|
|
1492
|
+
// v1 不做面板新建章纲文件)。
|
|
1493
|
+
function outlineEditor() {
|
|
1494
|
+
if (!detail.data || !detail.data.outline) return null;
|
|
1495
|
+
return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline };
|
|
1496
|
+
}
|
|
1382
1497
|
return h("div", { className: "myt-reader" },
|
|
1383
1498
|
h("div", { className: "myt-readerBar" },
|
|
1384
1499
|
h("button", { type: "button", className: "myt-btn", onClick: onBack, title: tt("reader.backTip") },
|
|
@@ -1386,6 +1501,11 @@ window.__ModuleLoader__.load({
|
|
|
1386
1501
|
h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
|
|
1387
1502
|
ch ? statusBadge(ch.status) : null,
|
|
1388
1503
|
h("div", { className: "myt-readerBarActions" },
|
|
1504
|
+
!editing && detail !== null && detail.data !== null && !detail.err ? h("button", {
|
|
1505
|
+
type: "button", className: "myt-btn myt-copyBtn",
|
|
1506
|
+
title: tt("reader.editTip"), "aria-label": tt("reader.editTip"),
|
|
1507
|
+
onClick: startBodyEdit,
|
|
1508
|
+
}, ic("IconEditOutlineRegular", 13), tt("reader.edit")) : null,
|
|
1389
1509
|
h("button", {
|
|
1390
1510
|
type: "button", className: "myt-btn myt-copyBtn",
|
|
1391
1511
|
title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
|
|
@@ -1397,10 +1517,21 @@ window.__ModuleLoader__.load({
|
|
|
1397
1517
|
: [
|
|
1398
1518
|
(function () {
|
|
1399
1519
|
var ol = detail.data && detail.data.outline;
|
|
1400
|
-
return ol ? h(OutlinePanel, { key: "outline", outline: ol })
|
|
1520
|
+
return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor() })
|
|
1401
1521
|
: h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
|
|
1402
1522
|
})(),
|
|
1403
|
-
|
|
1523
|
+
editing
|
|
1524
|
+
? h("div", { key: "body", className: "myt-bodyEdit" },
|
|
1525
|
+
h("textarea", {
|
|
1526
|
+
className: "myt-chapterEditArea", rows: 20, spellCheck: false,
|
|
1527
|
+
value: draft, onChange: function (e) { setDraft(e.target.value); },
|
|
1528
|
+
}),
|
|
1529
|
+
h("div", { className: "myt-editHint" }, tt("reader.bodyEditHint")),
|
|
1530
|
+
bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
|
|
1531
|
+
h("div", { className: "myt-setActions" },
|
|
1532
|
+
h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: cancelBodyEdit }, tt("novel.cancel")),
|
|
1533
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: saveBody }, bodyBusy ? tt("novel.saving") : tt("reader.save"))))
|
|
1534
|
+
: h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || tt("reader.emptyBody")),
|
|
1404
1535
|
],
|
|
1405
1536
|
ch ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
|
|
1406
1537
|
return h("div", { key: dim[0], className: "myt-dim" },
|
|
@@ -1535,30 +1666,52 @@ window.__ModuleLoader__.load({
|
|
|
1535
1666
|
busy ? tt("novel.saving") : tt("novel.save"))));
|
|
1536
1667
|
}
|
|
1537
1668
|
|
|
1538
|
-
// ── 章节 tab:状态筛选 pills + 表格 +
|
|
1669
|
+
// ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(正文阅读/编辑 + 五维 + 流转)────
|
|
1539
1670
|
var FIVE_DIMS = [
|
|
1540
1671
|
["cognitive_frame", tt("dim.cognitiveFrame")], ["emotional_anchor", tt("dim.emotionalAnchor")], ["world_texture", tt("dim.worldTexture")],
|
|
1541
1672
|
["suspense", tt("dim.suspense")], ["interpersonal_tension", tt("dim.interpersonalTension")],
|
|
1542
1673
|
];
|
|
1543
|
-
// 章纲对照栏(五期):outline
|
|
1544
|
-
//
|
|
1545
|
-
//
|
|
1674
|
+
// 章纲对照栏(五期):outline 非空时渲染可折叠原文(默认展开,样式与正文
|
|
1675
|
+
// 区分);共享常量避免重渲染时折叠态意外重置——组件随抽屉挂载,切章即重
|
|
1676
|
+
// 挂回默认展开。0.1.19 起带编辑钩子(editor 由 ChapterReader 注入,null =
|
|
1677
|
+
// 只读):编辑态整段 textarea 替换 MdView,保存走 outline-save(hash 旧版
|
|
1678
|
+
// 检测);无章纲不给编辑入口(v1 不做面板新建章纲文件)。
|
|
1546
1679
|
function OutlinePanel(props) {
|
|
1547
1680
|
var outline = props.outline;
|
|
1548
|
-
var
|
|
1681
|
+
var ed = props.editor || null; // { active, draft, busy, err, start, change, cancel, save }
|
|
1682
|
+
var _op = useState(true), openState = _op[0], setOpen = _op[1];
|
|
1683
|
+
var open = ed !== null && ed.active === true ? true : openState; // 编辑态强制展开
|
|
1549
1684
|
var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : tt("outline.empty");
|
|
1550
1685
|
return h("div", { className: "myt-outlinePanel" },
|
|
1551
1686
|
h("button", {
|
|
1552
1687
|
type: "button", className: "myt-outlineHead",
|
|
1553
1688
|
"aria-expanded": open ? "true" : "false",
|
|
1554
1689
|
title: open ? tt("outline.collapseTip") : tt("outline.expandTip"),
|
|
1555
|
-
onClick: function () { setOpen(!
|
|
1690
|
+
onClick: function () { setOpen(!openState); },
|
|
1556
1691
|
},
|
|
1557
1692
|
h("span", { className: "myt-outlineTitle" }, tt("outline.title")),
|
|
1558
1693
|
typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, tf("common.charCount", { n: fmtNum(outline.words) })) : null,
|
|
1694
|
+
// 编辑入口:head 是 <button>,里面不能再套 button——span + role 补键盘语义
|
|
1695
|
+
ed !== null && !ed.active ? h("span", {
|
|
1696
|
+
className: "myt-outlineEditBtn", role: "button", tabIndex: 0,
|
|
1697
|
+
title: tt("outline.editTip"), "aria-label": tt("outline.editTip"),
|
|
1698
|
+
onClick: function (e) { e.stopPropagation(); ed.start(); },
|
|
1699
|
+
onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); ed.start(); } },
|
|
1700
|
+
}, ic("IconEditOutlineRegular", 12), tt("reader.edit")) : null,
|
|
1559
1701
|
h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
|
|
1560
1702
|
ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
|
|
1561
|
-
open ?
|
|
1703
|
+
open ? (ed !== null && ed.active
|
|
1704
|
+
? h("div", { className: "myt-outlineEdit" },
|
|
1705
|
+
h("textarea", {
|
|
1706
|
+
className: "myt-setInput myt-setArea myt-outlineArea", rows: 10, spellCheck: false,
|
|
1707
|
+
value: ed.draft, onChange: function (e) { ed.change(e.target.value); },
|
|
1708
|
+
}),
|
|
1709
|
+
h("div", { className: "myt-editHint" }, tt("outline.editHint")),
|
|
1710
|
+
ed.err ? h(ErrorStrip, { text: ed.err }) : null,
|
|
1711
|
+
h("div", { className: "myt-setActions" },
|
|
1712
|
+
h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.cancel }, tt("novel.cancel")),
|
|
1713
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.save }, ed.busy ? tt("novel.saving") : tt("reader.save"))))
|
|
1714
|
+
: h(MdView, { text: body, className: "myt-outlineBody" })) : null);
|
|
1562
1715
|
}
|
|
1563
1716
|
// 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
|
|
1564
1717
|
// overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
|
|
@@ -1844,6 +1997,148 @@ window.__ModuleLoader__.load({
|
|
|
1844
1997
|
}
|
|
1845
1998
|
|
|
1846
1999
|
// ── 设定 tab:world 按 category 分组 + science 三色标签 ─────────────────
|
|
2000
|
+
// ── 写作风格 tab:novel/style.md 基准的查看与编辑(写手/大纲师/审校共同
|
|
2001
|
+
// 遵守)。挂载/换工作区时 GET style 按需拉取(请求序号防过期响应,对照
|
|
2002
|
+
// OverviewTab 的 check effect);视图态 MdView 渲染全文,缺失时空态卡用
|
|
2003
|
+
// 插件模板预填创建;编辑态整文件保存(style-save 携 base_hash,409 人工
|
|
2004
|
+
// 改动保护链路照 ProjectMetaForm:成功接写后新指纹防连续保存假冲突,
|
|
2005
|
+
// 409 重拉磁盘真值后编辑器原地重置、停留编辑态让用户自行取舍)。──
|
|
2006
|
+
function StyleTab(props) {
|
|
2007
|
+
var wsPath = props.wsPath;
|
|
2008
|
+
var _st = useState(null), st = _st[0], setSt = _st[1]; // {loading, data, err}
|
|
2009
|
+
var seqRef = useRef(0);
|
|
2010
|
+
// 编辑态:draft 为 null = 视图态;字符串 = 编辑中的全文。draftHash 是
|
|
2011
|
+
// 进入编辑态(或上次保存成功)时的磁盘指纹,保存时作 base_hash 回传。
|
|
2012
|
+
var _dr = useState(null), draft = _dr[0], setDraft = _dr[1];
|
|
2013
|
+
var _dh = useState(null), draftHash = _dh[0], setDraftHash = _dh[1];
|
|
2014
|
+
var _er = useState(""), editErr = _er[0], setEditErr = _er[1];
|
|
2015
|
+
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
2016
|
+
// 409 后的一次性重置标记:reload 带回磁盘真值时把编辑器与基线一并重置
|
|
2017
|
+
// (只置位于保存冲突路径;顶栏刷新等其他拉取不碰正在编辑的内容)。
|
|
2018
|
+
var pendingResetRef = useRef(false);
|
|
2019
|
+
// 换工作区:丢弃旧工作区的编辑态与在途编辑结果,再按新工作区重拉。
|
|
2020
|
+
var wsRef = useRef(wsPath);
|
|
2021
|
+
var load = useCallback(function () {
|
|
2022
|
+
var seq = ++seqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
|
|
2023
|
+
setSt({ loading: true, data: null, err: "" });
|
|
2024
|
+
apiGet("style", { path: wsPath }).then(function (d) {
|
|
2025
|
+
if (seq !== seqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
|
|
2026
|
+
setSt({ loading: false, data: d, err: "" });
|
|
2027
|
+
if (pendingResetRef.current) {
|
|
2028
|
+
// 409 冲突后的重拉:编辑器放回磁盘真值、基线对齐最新指纹,停留
|
|
2029
|
+
// 编辑态让用户基于最新内容自行取舍。
|
|
2030
|
+
pendingResetRef.current = false;
|
|
2031
|
+
setDraft(d && typeof d.text === "string" ? d.text : "");
|
|
2032
|
+
setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
|
|
2033
|
+
}
|
|
2034
|
+
}).catch(function (err) {
|
|
2035
|
+
if (seq !== seqRef.current) return;
|
|
2036
|
+
setSt({ loading: false, data: null, err: err.message });
|
|
2037
|
+
});
|
|
2038
|
+
}, [wsPath]);
|
|
2039
|
+
useEffect(function () {
|
|
2040
|
+
if (wsRef.current !== wsPath) {
|
|
2041
|
+
wsRef.current = wsPath;
|
|
2042
|
+
setDraft(null); setDraftHash(null); setEditErr(""); pendingResetRef.current = false;
|
|
2043
|
+
}
|
|
2044
|
+
load();
|
|
2045
|
+
}, [wsPath, load]);
|
|
2046
|
+
function startEdit() {
|
|
2047
|
+
if (!st.data || typeof st.data.text !== "string") return;
|
|
2048
|
+
setEditErr("");
|
|
2049
|
+
setDraftHash(typeof st.data.hash === "string" ? st.data.hash : null);
|
|
2050
|
+
setDraft(st.data.text);
|
|
2051
|
+
}
|
|
2052
|
+
function startFromTemplate() {
|
|
2053
|
+
if (!st.data || typeof st.data.template !== "string") return;
|
|
2054
|
+
setEditErr("");
|
|
2055
|
+
setDraftHash(null); // 文件尚不存在:无基线可护,保存即创建
|
|
2056
|
+
setDraft(st.data.template);
|
|
2057
|
+
}
|
|
2058
|
+
function cancelEdit() {
|
|
2059
|
+
if (busy) return; // 保存在途不许退出:避免卸载后 setState
|
|
2060
|
+
setDraft(null); setDraftHash(null); setEditErr("");
|
|
2061
|
+
}
|
|
2062
|
+
function save() {
|
|
2063
|
+
if (busy) return;
|
|
2064
|
+
setBusy(true); setEditErr("");
|
|
2065
|
+
// 成败提示统一走 apiPost 的 toast 通道;空文本允许保存(空文件 = 无
|
|
2066
|
+
// 基准,agent 侧优雅降级),不做非空校验。
|
|
2067
|
+
apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }).then(function (res) {
|
|
2068
|
+
// 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地数据
|
|
2069
|
+
// 同步为新文本,取消编辑退回视图态时看到的已是刚落盘的内容。
|
|
2070
|
+
var hash = res && typeof res.hash === "string" ? res.hash : null;
|
|
2071
|
+
setDraftHash(hash);
|
|
2072
|
+
setSt({ loading: false, data: { found: true, text: draft, hash: hash, template: st.data ? st.data.template : null }, err: "" });
|
|
2073
|
+
}).catch(function (err) {
|
|
2074
|
+
setEditErr(err.message);
|
|
2075
|
+
// 409 = style.md 在进入编辑态后被外部改过:重拉磁盘真值并一次性
|
|
2076
|
+
// 把编辑器放回最新内容(停留编辑态),别让用户在过期基线上盲改。
|
|
2077
|
+
if (err && err.status === 409) {
|
|
2078
|
+
pendingResetRef.current = true;
|
|
2079
|
+
load();
|
|
2080
|
+
}
|
|
2081
|
+
}).finally(function () { setBusy(false); });
|
|
2082
|
+
}
|
|
2083
|
+
var data = st ? st.data : null;
|
|
2084
|
+
var head = h("div", { className: "myt-cardHead" },
|
|
2085
|
+
h("h3", { className: "myt-cardTitle" }, tt("tabs.style")),
|
|
2086
|
+
h("span", { className: "myt-muted" }, tt("style.note")),
|
|
2087
|
+
draft === null
|
|
2088
|
+
? h("button", {
|
|
2089
|
+
type: "button", className: "myt-btn",
|
|
2090
|
+
title: tt("style.editTip"), "aria-label": tt("style.editTip"),
|
|
2091
|
+
disabled: !data || (st && st.err) !== "" || busy,
|
|
2092
|
+
onClick: data && data.found ? startEdit : startFromTemplate,
|
|
2093
|
+
}, ic("IconEditOutlineRegular", 13), tt("overview.edit"))
|
|
2094
|
+
: null);
|
|
2095
|
+
// 拉取中:轻量骨架(对照 ChapterReader 的加载态);失败:错误条可重试。
|
|
2096
|
+
if (st === null || st.loading) {
|
|
2097
|
+
return h("div", { className: "myt-stack" },
|
|
2098
|
+
h("div", { className: "myt-card" }, head,
|
|
2099
|
+
h("div", { className: "myt-skel myt-skelRow", style: { width: "38%" } }),
|
|
2100
|
+
h("div", { className: "myt-skel myt-skelRow", style: { width: "82%" } }),
|
|
2101
|
+
h("div", { className: "myt-skel myt-skelRow", style: { width: "64%" } }),
|
|
2102
|
+
h("div", { className: "myt-skel myt-skelRow", style: { width: "74%" } })));
|
|
2103
|
+
}
|
|
2104
|
+
if (st.err !== "") {
|
|
2105
|
+
return h("div", { className: "myt-stack" },
|
|
2106
|
+
h("div", { className: "myt-card" }, head),
|
|
2107
|
+
h(ErrorStrip, { text: tf("style.loadFailed", { msg: st.err }), onRetry: load }));
|
|
2108
|
+
}
|
|
2109
|
+
if (draft !== null) {
|
|
2110
|
+
// 编辑态:整文件 textarea + 取消/保存(动作区与表单卡同构)。
|
|
2111
|
+
return h("div", { className: "myt-stack" },
|
|
2112
|
+
h("div", { className: "myt-card" }, head,
|
|
2113
|
+
h("textarea", {
|
|
2114
|
+
className: "myt-setInput myt-setArea myt-styleArea", rows: 18, spellCheck: false,
|
|
2115
|
+
value: draft, onChange: function (e) { setDraft(e.target.value); },
|
|
2116
|
+
})),
|
|
2117
|
+
editErr ? h(ErrorStrip, { text: editErr }) : null,
|
|
2118
|
+
h("div", { className: "myt-setActions" },
|
|
2119
|
+
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancelEdit }, tt("novel.cancel")),
|
|
2120
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
|
|
2121
|
+
busy ? tt("novel.saving") : tt("novel.save"))));
|
|
2122
|
+
}
|
|
2123
|
+
if (!data || data.found !== true) {
|
|
2124
|
+
// 文件未创建:空态卡 + 模板创建引导(模板读不到时按钮禁用)。
|
|
2125
|
+
var tplReady = !!(data && typeof data.template === "string");
|
|
2126
|
+
return h("div", { className: "myt-stack" },
|
|
2127
|
+
h("div", { className: "myt-card" }, head,
|
|
2128
|
+
h("div", { className: "myt-styleEmpty" },
|
|
2129
|
+
h("p", { className: "myt-muted" }, tt("style.empty")),
|
|
2130
|
+
h("button", {
|
|
2131
|
+
type: "button", className: "myt-btn myt-btnPrimary", disabled: !tplReady || busy,
|
|
2132
|
+
title: tt("style.editTip"), "aria-label": tt("style.editTip"),
|
|
2133
|
+
onClick: startFromTemplate,
|
|
2134
|
+
}, tt("style.createFromTpl")))));
|
|
2135
|
+
}
|
|
2136
|
+
// 视图态:MdView 渲染全文(空文件渲出空容器——空文件 = 无基准,是
|
|
2137
|
+
// 保存过的合法状态,不当缺失处理)。
|
|
2138
|
+
return h("div", { className: "myt-stack" },
|
|
2139
|
+
h("div", { className: "myt-card" }, head, h(MdView, { text: data.text || "" })));
|
|
2140
|
+
}
|
|
2141
|
+
|
|
1847
2142
|
function WorldTab(props) {
|
|
1848
2143
|
var snap = props.snap;
|
|
1849
2144
|
var world = snap.world || [];
|
|
@@ -1859,8 +2154,24 @@ window.__ModuleLoader__.load({
|
|
|
1859
2154
|
groups[key].push(w);
|
|
1860
2155
|
});
|
|
1861
2156
|
var groupKeys = Object.keys(groups).sort(function (a, b) { return a.localeCompare(b, "zh-CN"); });
|
|
2157
|
+
// 三分区改子 tab 切换:纵向三卡堆太高,改子 tab 条 + 仅渲染激活分区。
|
|
2158
|
+
// state 存在本组件(切走再切回不丢;卡头 h3 随卡一起只画激活分区)。
|
|
2159
|
+
// research 空时原实现整卡隐藏,现 tab 恒在、空态走 world.researchEmpty。
|
|
2160
|
+
var _st = useState("world"), subTab = _st[0], setSubTab = _st[1];
|
|
2161
|
+
var subDefs = [
|
|
2162
|
+
["world", tt("world.title"), world.length],
|
|
2163
|
+
["science", tt("world.science"), science.length],
|
|
2164
|
+
["research", tt("world.research"), research.length],
|
|
2165
|
+
];
|
|
1862
2166
|
return h("div", { className: "myt-stack" },
|
|
1863
|
-
h("
|
|
2167
|
+
h("nav", { className: "myt-subTabs", "aria-label": tt("world.subTabsAria") },
|
|
2168
|
+
subDefs.map(function (s) {
|
|
2169
|
+
return h("button", {
|
|
2170
|
+
key: s[0], type: "button", className: "myt-subTab", "data-active": subTab === s[0] ? "true" : undefined,
|
|
2171
|
+
onClick: function () { setSubTab(s[0]); },
|
|
2172
|
+
}, s[1], h("span", { className: "myt-boardCount" }, String(s[2])));
|
|
2173
|
+
})),
|
|
2174
|
+
subTab === "world" ? h("div", { className: "myt-card" },
|
|
1864
2175
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
|
|
1865
2176
|
groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
|
|
1866
2177
|
return h("div", { key: key, className: "myt-worldGroup" },
|
|
@@ -1872,8 +2183,8 @@ window.__ModuleLoader__.load({
|
|
|
1872
2183
|
(w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
|
|
1873
2184
|
w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
|
|
1874
2185
|
}));
|
|
1875
|
-
})) : h(Empty, { text: tt("world.empty") })),
|
|
1876
|
-
h("div", { className: "myt-card" },
|
|
2186
|
+
})) : h(Empty, { text: tt("world.empty") })) : null,
|
|
2187
|
+
subTab === "science" ? h("div", { className: "myt-card" },
|
|
1877
2188
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
|
|
1878
2189
|
h("span", { className: "myt-boardCount" }, science.length)),
|
|
1879
2190
|
science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
|
|
@@ -1886,18 +2197,18 @@ window.__ModuleLoader__.load({
|
|
|
1886
2197
|
s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
|
|
1887
2198
|
(s.references || []).length ? h("div", { className: "myt-refList" },
|
|
1888
2199
|
s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null);
|
|
1889
|
-
})) : h(Empty, { text: tt("world.scienceEmpty") })),
|
|
1890
|
-
research
|
|
2200
|
+
})) : h(Empty, { text: tt("world.scienceEmpty") })) : null,
|
|
2201
|
+
subTab === "research" ? h("div", { className: "myt-card" },
|
|
1891
2202
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
|
|
1892
2203
|
h("span", { className: "myt-boardCount" }, research.length)),
|
|
1893
|
-
h("div", { className: "myt-stack" }, research.map(function (r, i) {
|
|
2204
|
+
research.length ? h("div", { className: "myt-stack" }, research.map(function (r, i) {
|
|
1894
2205
|
return h("div", { key: r.file || i, className: "myt-worldGroup" },
|
|
1895
2206
|
h("div", { className: "myt-listHead" },
|
|
1896
2207
|
h("span", { className: "myt-worldName", title: r.file || "" }, orDash(r.topic) || r.file)),
|
|
1897
2208
|
r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
|
|
1898
2209
|
(r.sources || []).length ? h("div", { className: "myt-refList" },
|
|
1899
2210
|
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);
|
|
2211
|
+
})) : h(Empty, { text: tt("world.researchEmpty") })) : null);
|
|
1901
2212
|
}
|
|
1902
2213
|
|
|
1903
2214
|
// ── 索引板 tab:clues 按 kind 分组(resolved 划线)+ memories 按 category
|
|
@@ -1980,6 +2291,9 @@ window.__ModuleLoader__.load({
|
|
|
1980
2291
|
var wrapRef = useRef(null);
|
|
1981
2292
|
var canvasRef = useRef(null);
|
|
1982
2293
|
var tipRafRef = useRef(0); // hover 提示 rAF 句柄:卸载/换图时取消在途合帧
|
|
2294
|
+
var zoomApiRef = useRef(null); // 缩放按钮桥:渲染层 → 绘图 effect 闭包里的 sim.zoom
|
|
2295
|
+
var _fs2 = useState(false), isFs = _fs2[0], setFs = _fs2[1]; // 当前处于全屏
|
|
2296
|
+
var _fok = useState(false), canFs = _fok[0], setCanFs = _fok[1]; // 宿主支持 Fullscreen API
|
|
1983
2297
|
// 数据准备:>30 角色只留 relations 相关节点;角色空时退化为关系端点名。
|
|
1984
2298
|
// useMemo 缓存:hover 提示等重渲染不再重复建图(原渲染体内 IIFE 每帧重建)。
|
|
1985
2299
|
// 容器用无原型对象:角色名/关系端点是数据文件自由文本,见 WorldTab 处说明。
|
|
@@ -2007,6 +2321,10 @@ window.__ModuleLoader__.load({
|
|
|
2007
2321
|
if (!ctx) return undefined;
|
|
2008
2322
|
var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
|
|
2009
2323
|
var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
|
|
2324
|
+
// 缩放按钮桥:渲染层按钮 → effect 内 sim.zoom(钳制与滚轮同口径 0.4-2.5)
|
|
2325
|
+
zoomApiRef.current = {
|
|
2326
|
+
zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); },
|
|
2327
|
+
};
|
|
2010
2328
|
// 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
|
|
2011
2329
|
function readTokens() {
|
|
2012
2330
|
var cs = getComputedStyle(document.body);
|
|
@@ -2192,6 +2510,7 @@ window.__ModuleLoader__.load({
|
|
|
2192
2510
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
2193
2511
|
canvas.addEventListener("dblclick", onDbl);
|
|
2194
2512
|
return function () {
|
|
2513
|
+
zoomApiRef.current = null; // 桥随旧 sim 一起作废:换图间隙按钮点击不落到旧闭包
|
|
2195
2514
|
cancelAnimationFrame(sim.raf);
|
|
2196
2515
|
if (tipRafRef.current) { cancelAnimationFrame(tipRafRef.current); tipRafRef.current = 0; }
|
|
2197
2516
|
if (ro) ro.disconnect();
|
|
@@ -2202,6 +2521,44 @@ window.__ModuleLoader__.load({
|
|
|
2202
2521
|
canvas.removeEventListener("dblclick", onDbl);
|
|
2203
2522
|
};
|
|
2204
2523
|
}, [snap]);
|
|
2524
|
+
// 全屏能力检测:Fullscreen API 可用才渲染全屏按钮(不支持的宿主不渲染,按钮不占位)
|
|
2525
|
+
useEffect(function () {
|
|
2526
|
+
var el = wrapRef.current;
|
|
2527
|
+
if (!el) return undefined;
|
|
2528
|
+
var ok = !!(el.requestFullscreen || el.webkitRequestFullscreen) &&
|
|
2529
|
+
document.fullscreenEnabled !== false && document.webkitFullscreenEnabled !== false;
|
|
2530
|
+
if (ok) setCanFs(true);
|
|
2531
|
+
return undefined;
|
|
2532
|
+
}, []);
|
|
2533
|
+
// 全屏状态跟随:浏览器 ESC 退出等非按钮路径也要同步按钮文案
|
|
2534
|
+
useEffect(function () {
|
|
2535
|
+
function sync() { setFs(!!(document.fullscreenElement || document.webkitFullscreenElement)); }
|
|
2536
|
+
document.addEventListener("fullscreenchange", sync);
|
|
2537
|
+
document.addEventListener("webkitfullscreenchange", sync);
|
|
2538
|
+
return function () {
|
|
2539
|
+
document.removeEventListener("fullscreenchange", sync);
|
|
2540
|
+
document.removeEventListener("webkitfullscreenchange", sync);
|
|
2541
|
+
};
|
|
2542
|
+
}, []);
|
|
2543
|
+
// 进/出全屏:webkit 前缀兜底;promise 拒绝吞掉(用户 ESC / 策略性拒绝不打断使用)
|
|
2544
|
+
function toggleFs() {
|
|
2545
|
+
var el = wrapRef.current;
|
|
2546
|
+
try {
|
|
2547
|
+
if (document.fullscreenElement || document.webkitFullscreenElement) {
|
|
2548
|
+
var exit = document.exitFullscreen || document.webkitExitFullscreen;
|
|
2549
|
+
if (exit) {
|
|
2550
|
+
var p = exit.call(document);
|
|
2551
|
+
if (p && typeof p.catch === "function") p.catch(function () {});
|
|
2552
|
+
}
|
|
2553
|
+
} else if (el) {
|
|
2554
|
+
var req = el.requestFullscreen || el.webkitRequestFullscreen;
|
|
2555
|
+
if (req) {
|
|
2556
|
+
var p2 = req.call(el);
|
|
2557
|
+
if (p2 && typeof p2.catch === "function") p2.catch(function () {});
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
} catch (e) { /* 全屏 API 个别浏览器仍有策略性拒绝,静默 */ }
|
|
2561
|
+
}
|
|
2205
2562
|
if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
|
|
2206
2563
|
return h("div", { className: "myt-stack" },
|
|
2207
2564
|
h("div", { className: "myt-card" },
|
|
@@ -2210,7 +2567,11 @@ window.__ModuleLoader__.load({
|
|
|
2210
2567
|
h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length }))),
|
|
2211
2568
|
h("div", { className: "myt-graphWrap", ref: wrapRef },
|
|
2212
2569
|
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
|
|
2570
|
+
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,
|
|
2571
|
+
h("div", { className: "myt-graphTools" },
|
|
2572
|
+
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); } }, "-"),
|
|
2573
|
+
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); } }, "+"),
|
|
2574
|
+
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)),
|
|
2214
2575
|
h("div", { className: "myt-graphFoot" }, tt("relations.help"))),
|
|
2215
2576
|
graph.unlinked.length ? h("div", { className: "myt-card" },
|
|
2216
2577
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
|