dsh-maoying 0.1.19 → 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 +8 -7
- package/README.md +9 -8
- package/bin/maoying.mjs +2 -2
- package/docs/creator-panel.md +32 -12
- package/docs/design.md +9 -9
- package/docs/spec.md +10 -5
- package/index.mjs +42 -1
- package/lib/base.css +24 -0
- package/lib/client.js +553 -64
- package/lib/novel-parse.mjs +131 -0
- package/lib/panel-core.mjs +190 -0
- 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
|
@@ -246,6 +246,9 @@ window.__ModuleLoader__.load({
|
|
|
246
246
|
if (path === "style-save") return tt("toast.styleSaved");
|
|
247
247
|
if (path === "chapter-save") return tt("toast.chapterSaved");
|
|
248
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) || "" });
|
|
249
252
|
return tt("toast.success");
|
|
250
253
|
}
|
|
251
254
|
|
|
@@ -545,8 +548,8 @@ window.__ModuleLoader__.load({
|
|
|
545
548
|
}
|
|
546
549
|
|
|
547
550
|
// ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
|
|
548
|
-
// 数据流:workspaces
|
|
549
|
-
// activate 监听);state
|
|
551
|
+
// 数据流:workspaces 与 state 每次打开面板都重拉(懒挂载下关闭不卸载,
|
|
552
|
+
// 见下方 activate 监听);state 另跟随选中工作区;写操作 POST 成功后
|
|
550
553
|
// 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
|
|
551
554
|
function PageShell(props) {
|
|
552
555
|
var onClose = props.onClose;
|
|
@@ -559,6 +562,8 @@ window.__ModuleLoader__.load({
|
|
|
559
562
|
var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
|
|
560
563
|
// 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
|
|
561
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];
|
|
562
567
|
// 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
|
|
563
568
|
var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
|
|
564
569
|
var _fs = useState(function () {
|
|
@@ -754,20 +759,27 @@ window.__ModuleLoader__.load({
|
|
|
754
759
|
});
|
|
755
760
|
}
|
|
756
761
|
useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
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)。
|
|
763
772
|
useEffect(function () {
|
|
764
773
|
var onActivate = function (event) {
|
|
765
774
|
var source = event && event.detail && event.detail.source;
|
|
766
|
-
if (source
|
|
775
|
+
if (source !== "maoying") return;
|
|
776
|
+
loadWorkspaces();
|
|
777
|
+
reload(); // 重开即最新小说内容,不必手点右上角刷新
|
|
778
|
+
setReopenSeq(function (n) { return n + 1; });
|
|
767
779
|
};
|
|
768
780
|
document.addEventListener(ACTIVATE_EVENT, onActivate);
|
|
769
781
|
return function () { document.removeEventListener(ACTIVATE_EVENT, onActivate); };
|
|
770
|
-
}, [loadWorkspaces]);
|
|
782
|
+
}, [loadWorkspaces, reload]);
|
|
771
783
|
useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
|
|
772
784
|
// 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
|
|
773
785
|
useEffect(function () {
|
|
@@ -807,7 +819,7 @@ window.__ModuleLoader__.load({
|
|
|
807
819
|
});
|
|
808
820
|
} else if (snap) {
|
|
809
821
|
var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
|
|
810
|
-
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); } });
|
|
811
823
|
else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
|
|
812
824
|
else if (tab === "characters") body = h(CharactersTab, tabProps);
|
|
813
825
|
else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
|
|
@@ -906,6 +918,8 @@ window.__ModuleLoader__.load({
|
|
|
906
918
|
var project = snap.project || {};
|
|
907
919
|
// 编辑态:true 时 hero 卡切换为书稿元信息表单(ProjectMetaForm)。
|
|
908
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];
|
|
909
923
|
var totals = snap.totals || {};
|
|
910
924
|
var byStatus = totals.byStatus || {};
|
|
911
925
|
var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
|
|
@@ -981,7 +995,8 @@ window.__ModuleLoader__.load({
|
|
|
981
995
|
}))),
|
|
982
996
|
volumes.length ? h("div", { className: "myt-card" },
|
|
983
997
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.volumes")),
|
|
984
|
-
h("span", { className: "myt-boardCount" }, volumes.length)
|
|
998
|
+
h("span", { className: "myt-boardCount" }, volumes.length),
|
|
999
|
+
srcEditBtn("volumes.yml", function () { setSrcRel("volumes.yml"); })),
|
|
985
1000
|
h("div", { className: "myt-stack" }, volumes.map(function (v, i) {
|
|
986
1001
|
return h("div", { key: v.id || i, className: "myt-worldGroup" },
|
|
987
1002
|
h("div", { className: "myt-listHead" },
|
|
@@ -999,7 +1014,8 @@ window.__ModuleLoader__.load({
|
|
|
999
1014
|
h("span", { className: "myt-chPath" }, chWhere(ch)),
|
|
1000
1015
|
h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
|
|
1001
1016
|
}))
|
|
1002
|
-
|
|
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);
|
|
1003
1019
|
}
|
|
1004
1020
|
|
|
1005
1021
|
// ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
|
|
@@ -1146,6 +1162,20 @@ window.__ModuleLoader__.load({
|
|
|
1146
1162
|
"toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
|
|
1147
1163
|
"toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
|
|
1148
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": "新建科学设定",
|
|
1149
1179
|
"api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
|
|
1150
1180
|
"status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
|
|
1151
1181
|
"phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
|
|
@@ -1168,7 +1198,7 @@ window.__ModuleLoader__.load({
|
|
|
1168
1198
|
"timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
|
|
1169
1199
|
"world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
|
|
1170
1200
|
"world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
|
|
1171
|
-
"world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区",
|
|
1201
|
+
"world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区", "world.catTabsAria": "世界观分类",
|
|
1172
1202
|
"relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
|
|
1173
1203
|
"relations.help": "拖节点固定位置 · 滚轮或 +/- 按钮缩放 · 双击复位 · 悬停边看关系描述 · 右上角按钮全屏",
|
|
1174
1204
|
"relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
|
|
@@ -1246,6 +1276,20 @@ window.__ModuleLoader__.load({
|
|
|
1246
1276
|
"toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
|
|
1247
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}",
|
|
1248
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",
|
|
1249
1293
|
"api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
|
|
1250
1294
|
"status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
|
|
1251
1295
|
"phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
|
|
@@ -1268,7 +1312,7 @@ window.__ModuleLoader__.load({
|
|
|
1268
1312
|
"timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
|
|
1269
1313
|
"world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
|
|
1270
1314
|
"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",
|
|
1315
|
+
"world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections", "world.catTabsAria": "Worldbuilding categories",
|
|
1272
1316
|
"relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
|
|
1273
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",
|
|
1274
1318
|
"relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
|
|
@@ -1370,6 +1414,7 @@ window.__ModuleLoader__.load({
|
|
|
1370
1414
|
var wsPath = props.wsPath;
|
|
1371
1415
|
var snap = props.snap;
|
|
1372
1416
|
var reload = props.reload;
|
|
1417
|
+
var reopenSeq = props.refreshSeq || 0; // 面板重开序号(PageShell 广播自增)
|
|
1373
1418
|
var onBack = props.onBack;
|
|
1374
1419
|
var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
|
|
1375
1420
|
var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
|
|
@@ -1388,6 +1433,21 @@ window.__ModuleLoader__.load({
|
|
|
1388
1433
|
// (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
|
|
1389
1434
|
var bodyResetRef = useRef(false);
|
|
1390
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
|
+
}
|
|
1391
1451
|
var loadDetail = useCallback(function (f, opts) {
|
|
1392
1452
|
var seq = ++detailSeqRef.current;
|
|
1393
1453
|
if (!f) { setDetail(null); return; }
|
|
@@ -1404,6 +1464,12 @@ window.__ModuleLoader__.load({
|
|
|
1404
1464
|
});
|
|
1405
1465
|
}, [wsPath]);
|
|
1406
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]);
|
|
1407
1473
|
// 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
|
|
1408
1474
|
// 去会把 A 章的文字存进 B 章文件。
|
|
1409
1475
|
useEffect(function () {
|
|
@@ -1510,15 +1576,31 @@ window.__ModuleLoader__.load({
|
|
|
1510
1576
|
type: "button", className: "myt-btn myt-copyBtn",
|
|
1511
1577
|
title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
|
|
1512
1578
|
onClick: copyChapterPath,
|
|
1513
|
-
}, 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")))),
|
|
1514
1587
|
h("div", { className: "myt-readerBody" },
|
|
1515
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%" } }))
|
|
1516
1589
|
: detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
|
|
1517
1590
|
: [
|
|
1518
1591
|
(function () {
|
|
1519
1592
|
var ol = detail.data && detail.data.outline;
|
|
1520
|
-
return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor() })
|
|
1521
|
-
:
|
|
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
|
+
];
|
|
1522
1604
|
})(),
|
|
1523
1605
|
editing
|
|
1524
1606
|
? h("div", { key: "body", className: "myt-bodyEdit" },
|
|
@@ -1546,7 +1628,29 @@ window.__ModuleLoader__.load({
|
|
|
1546
1628
|
return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
|
|
1547
1629
|
h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
|
|
1548
1630
|
flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
|
|
1549
|
-
})()) : 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);
|
|
1550
1654
|
}
|
|
1551
1655
|
|
|
1552
1656
|
// ── 书稿元信息编辑表单(概览「编辑」按钮展开):可视化 project.yml 平
|
|
@@ -1698,6 +1802,13 @@ window.__ModuleLoader__.load({
|
|
|
1698
1802
|
onClick: function (e) { e.stopPropagation(); ed.start(); },
|
|
1699
1803
|
onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); ed.start(); } },
|
|
1700
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,
|
|
1701
1812
|
h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
|
|
1702
1813
|
ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
|
|
1703
1814
|
open ? (ed !== null && ed.active
|
|
@@ -1764,6 +1875,171 @@ window.__ModuleLoader__.load({
|
|
|
1764
1875
|
: h(MdView, { text: (detail.data && detail.data.body) || tt("outline.empty"), className: "myt-outlineBody" }))));
|
|
1765
1876
|
}
|
|
1766
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
|
+
|
|
1767
2043
|
function ChaptersTab(props) {
|
|
1768
2044
|
var snap = props.snap;
|
|
1769
2045
|
var wsPath = props.wsPath;
|
|
@@ -1885,10 +2161,20 @@ window.__ModuleLoader__.load({
|
|
|
1885
2161
|
];
|
|
1886
2162
|
function CharactersTab(props) {
|
|
1887
2163
|
var characters = props.snap.characters || [];
|
|
2164
|
+
var wsPath = props.wsPath;
|
|
2165
|
+
var reload = props.reload;
|
|
1888
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];
|
|
1889
2171
|
var modal = null;
|
|
1890
2172
|
for (var i = 0; i < characters.length; i++) { if (characters[i].file === modalFile) { modal = characters[i]; break; } }
|
|
1891
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"))),
|
|
1892
2178
|
characters.length ? h("div", { className: "myt-charWall" }, characters.map(function (c) {
|
|
1893
2179
|
return h("button", { key: c.file, type: "button", className: "myt-card myt-charCard", onClick: function () { setModalFile(c.file); } },
|
|
1894
2180
|
h("div", { className: "myt-charHead" },
|
|
@@ -1923,7 +2209,27 @@ window.__ModuleLoader__.load({
|
|
|
1923
2209
|
: "—")),
|
|
1924
2210
|
h("div", { className: "myt-kvRow" },
|
|
1925
2211
|
h("span", { className: "myt-kvKey" }, tt("char.file")),
|
|
1926
|
-
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);
|
|
1927
2233
|
}
|
|
1928
2234
|
|
|
1929
2235
|
// ── 伏笔 tab:四列看板(列头计数 + 推进按钮 + priority 色点)────────────
|
|
@@ -1939,7 +2245,12 @@ window.__ModuleLoader__.load({
|
|
|
1939
2245
|
.then(reload).catch(function () {}).finally(function () { setBusyId(null); });
|
|
1940
2246
|
}
|
|
1941
2247
|
var list = snap.foreshadows || [];
|
|
2248
|
+
// 伏笔台账源文件直达(0.1.20 写面):foreshadows.yml 整文件可编辑。
|
|
2249
|
+
var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
|
|
1942
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"); })),
|
|
1943
2254
|
list.length ? h("div", { className: "myt-board" }, FS_ORDER.map(function (col) {
|
|
1944
2255
|
var items = list.filter(function (f) { return (f.status || "planted") === col; });
|
|
1945
2256
|
return h("div", { key: col, className: "myt-boardCol" },
|
|
@@ -1973,17 +2284,33 @@ window.__ModuleLoader__.load({
|
|
|
1973
2284
|
? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
|
|
1974
2285
|
}),
|
|
1975
2286
|
items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
|
|
1976
|
-
})) : 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);
|
|
1977
2289
|
}
|
|
1978
2290
|
|
|
1979
2291
|
// ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
|
|
1980
2292
|
function TimelineTab(props) {
|
|
1981
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"); }));
|
|
1982
2302
|
if (items.length) {
|
|
1983
2303
|
items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
|
|
1984
2304
|
}
|
|
1985
|
-
if (!items.length)
|
|
1986
|
-
|
|
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" },
|
|
1987
2314
|
h("div", { className: "myt-timeline" }, items.map(function (t, i) {
|
|
1988
2315
|
var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
|
|
1989
2316
|
return h("div", { key: i, className: "myt-tlRow" },
|
|
@@ -1993,7 +2320,8 @@ window.__ModuleLoader__.load({
|
|
|
1993
2320
|
h("div", { className: "myt-tlTitle" }, orDash(t.title)),
|
|
1994
2321
|
t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
|
|
1995
2322
|
t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
|
|
1996
|
-
})))
|
|
2323
|
+
}))),
|
|
2324
|
+
srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
|
|
1997
2325
|
}
|
|
1998
2326
|
|
|
1999
2327
|
// ── 设定 tab:world 按 category 分组 + science 三色标签 ─────────────────
|
|
@@ -2141,9 +2469,16 @@ window.__ModuleLoader__.load({
|
|
|
2141
2469
|
|
|
2142
2470
|
function WorldTab(props) {
|
|
2143
2471
|
var snap = props.snap;
|
|
2472
|
+
var wsPath = props.wsPath;
|
|
2473
|
+
var reload = props.reload;
|
|
2144
2474
|
var world = snap.world || [];
|
|
2145
2475
|
var science = snap.science || [];
|
|
2146
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];
|
|
2147
2482
|
// 无原型容器:key 来自数据文件的自由文本(category),值为
|
|
2148
2483
|
// constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
|
|
2149
2484
|
// 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
|
|
@@ -2158,11 +2493,16 @@ window.__ModuleLoader__.load({
|
|
|
2158
2493
|
// state 存在本组件(切走再切回不丢;卡头 h3 随卡一起只画激活分区)。
|
|
2159
2494
|
// research 空时原实现整卡隐藏,现 tab 恒在、空态走 world.researchEmpty。
|
|
2160
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];
|
|
2161
2500
|
var subDefs = [
|
|
2162
2501
|
["world", tt("world.title"), world.length],
|
|
2163
2502
|
["science", tt("world.science"), science.length],
|
|
2164
2503
|
["research", tt("world.research"), research.length],
|
|
2165
2504
|
];
|
|
2505
|
+
var activeCat = catPref !== null && groupKeys.indexOf(catPref) !== -1 ? catPref : groupKeys[0];
|
|
2166
2506
|
return h("div", { className: "myt-stack" },
|
|
2167
2507
|
h("nav", { className: "myt-subTabs", "aria-label": tt("world.subTabsAria") },
|
|
2168
2508
|
subDefs.map(function (s) {
|
|
@@ -2172,21 +2512,32 @@ window.__ModuleLoader__.load({
|
|
|
2172
2512
|
}, s[1], h("span", { className: "myt-boardCount" }, String(s[2])));
|
|
2173
2513
|
})),
|
|
2174
2514
|
subTab === "world" ? h("div", { className: "myt-card" },
|
|
2175
|
-
h("div", { className: "myt-cardHead" },
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
}));
|
|
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); })));
|
|
2186
2536
|
})) : h(Empty, { text: tt("world.empty") })) : null,
|
|
2187
2537
|
subTab === "science" ? h("div", { className: "myt-card" },
|
|
2188
2538
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
|
|
2189
|
-
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"))),
|
|
2190
2541
|
science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
|
|
2191
2542
|
var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
|
|
2192
2543
|
return h("div", { key: s.file || i, className: "myt-worldItem" },
|
|
@@ -2196,7 +2547,10 @@ window.__ModuleLoader__.load({
|
|
|
2196
2547
|
label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
|
|
2197
2548
|
s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
|
|
2198
2549
|
(s.references || []).length ? h("div", { className: "myt-refList" },
|
|
2199
|
-
s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null
|
|
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); })));
|
|
2200
2554
|
})) : h(Empty, { text: tt("world.scienceEmpty") })) : null,
|
|
2201
2555
|
subTab === "research" ? h("div", { className: "myt-card" },
|
|
2202
2556
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
|
|
@@ -2208,7 +2562,25 @@ window.__ModuleLoader__.load({
|
|
|
2208
2562
|
r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
|
|
2209
2563
|
(r.sources || []).length ? h("div", { className: "myt-refList" },
|
|
2210
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);
|
|
2211
|
-
})) : h(Empty, { text: tt("world.researchEmpty") })) : 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);
|
|
2212
2584
|
}
|
|
2213
2585
|
|
|
2214
2586
|
// ── 索引板 tab:clues 按 kind 分组(resolved 划线)+ memories 按 category
|
|
@@ -2239,6 +2611,8 @@ window.__ModuleLoader__.load({
|
|
|
2239
2611
|
memGroups[key].push(m);
|
|
2240
2612
|
});
|
|
2241
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];
|
|
2242
2616
|
return h("div", { className: "myt-stack" },
|
|
2243
2617
|
h("div", { className: "myt-cardHead" },
|
|
2244
2618
|
h("h3", { className: "myt-cardTitle" }, tt("index.title")),
|
|
@@ -2246,7 +2620,8 @@ window.__ModuleLoader__.load({
|
|
|
2246
2620
|
rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
|
|
2247
2621
|
h("div", { className: "myt-indexCols" },
|
|
2248
2622
|
h("div", { className: "myt-card" },
|
|
2249
|
-
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"); })),
|
|
2250
2625
|
clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
|
|
2251
2626
|
var ck = CLUE_KIND_KEYS[kind];
|
|
2252
2627
|
var label = ck ? tt("clue." + ck) : kind;
|
|
@@ -2259,7 +2634,8 @@ window.__ModuleLoader__.load({
|
|
|
2259
2634
|
}));
|
|
2260
2635
|
}) : h(Empty, { text: tt("index.clueEmpty") })),
|
|
2261
2636
|
h("div", { className: "myt-card" },
|
|
2262
|
-
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"); })),
|
|
2263
2639
|
memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
|
|
2264
2640
|
var isPromise = /promise|承诺/i.test(cat);
|
|
2265
2641
|
return h("div", { key: cat, className: "myt-worldGroup" },
|
|
@@ -2271,13 +2647,18 @@ window.__ModuleLoader__.load({
|
|
|
2271
2647
|
h("span", { className: "myt-clueText" }, orDash(m.content)),
|
|
2272
2648
|
m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
|
|
2273
2649
|
}));
|
|
2274
|
-
}) : 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);
|
|
2275
2652
|
}
|
|
2276
2653
|
|
|
2277
|
-
// ── 关系图 tab:canvas 力导向(斥力 + 弹簧 +
|
|
2278
|
-
// 节点固定 18px、名字 label
|
|
2279
|
-
// type
|
|
2280
|
-
//
|
|
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" };
|
|
2281
2662
|
function edgeWidth(intensity) {
|
|
2282
2663
|
var n = typeof intensity === "number" && isFinite(intensity) ? Math.round(intensity) : 3;
|
|
2283
2664
|
n = Math.min(5, Math.max(1, n));
|
|
@@ -2285,6 +2666,10 @@ window.__ModuleLoader__.load({
|
|
|
2285
2666
|
}
|
|
2286
2667
|
function RelationsTab(props) {
|
|
2287
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];
|
|
2288
2673
|
var chars = snap.characters || [];
|
|
2289
2674
|
var rels = (snap.relations || []).filter(function (r) { return r && r.a && r.b && r.a !== r.b; });
|
|
2290
2675
|
var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
|
|
@@ -2311,6 +2696,14 @@ window.__ModuleLoader__.load({
|
|
|
2311
2696
|
nodes.forEach(function (nd, i) { idx[nd.id] = i; });
|
|
2312
2697
|
var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
|
|
2313
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
|
+
});
|
|
2314
2707
|
return { nodes: nodes, edges: edges, unlinked: unlinked };
|
|
2315
2708
|
}, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
|
|
2316
2709
|
useEffect(function () {
|
|
@@ -2352,7 +2745,50 @@ window.__ModuleLoader__.load({
|
|
|
2352
2745
|
nd.vx = nd.vy = 0;
|
|
2353
2746
|
});
|
|
2354
2747
|
function toWorld(sx, sy) { return { x: (sx - sim.w / 2) / sim.zoom, y: (sy - sim.h / 2) / sim.zoom }; }
|
|
2355
|
-
|
|
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
|
+
}
|
|
2356
2792
|
function hitNode(sx, sy) {
|
|
2357
2793
|
var p = toWorld(sx, sy);
|
|
2358
2794
|
for (var i = graph.nodes.length - 1; i >= 0; i--) {
|
|
@@ -2366,13 +2802,23 @@ window.__ModuleLoader__.load({
|
|
|
2366
2802
|
var p = toWorld(sx, sy);
|
|
2367
2803
|
var best = null, bestD = 10;
|
|
2368
2804
|
graph.edges.forEach(function (e) {
|
|
2805
|
+
// 命中跟曲线走:二次贝塞尔采样 9 段(10 点含两端),点到各线段
|
|
2806
|
+
// 距离取最小;edgeCtrl 与 draw 同源(side 存边对象上,天然一致)。
|
|
2369
2807
|
var a = graph.nodes[e.a], b = graph.nodes[e.b];
|
|
2370
|
-
var
|
|
2371
|
-
var
|
|
2372
|
-
var
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
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
|
+
}
|
|
2376
2822
|
});
|
|
2377
2823
|
return best;
|
|
2378
2824
|
}
|
|
@@ -2394,7 +2840,7 @@ window.__ModuleLoader__.load({
|
|
|
2394
2840
|
var a = nodes[e.a], b = nodes[e.b];
|
|
2395
2841
|
var dx = b.x - a.x, dy = b.y - a.y;
|
|
2396
2842
|
var d = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
2397
|
-
var f = (d -
|
|
2843
|
+
var f = (d - 130) * 0.02; // 弹簧,rest 130(原 120,多留缝隙配合硬碰撞分离)
|
|
2398
2844
|
var fx = (dx / d) * f, fy = (dy / d) * f;
|
|
2399
2845
|
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
2400
2846
|
});
|
|
@@ -2406,6 +2852,33 @@ window.__ModuleLoader__.load({
|
|
|
2406
2852
|
nd.x += Math.max(-8, Math.min(8, nd.vx));
|
|
2407
2853
|
nd.y += Math.max(-8, Math.min(8, nd.vy));
|
|
2408
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
|
+
}
|
|
2409
2882
|
}
|
|
2410
2883
|
function draw() {
|
|
2411
2884
|
if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
|
|
@@ -2417,29 +2890,30 @@ window.__ModuleLoader__.load({
|
|
|
2417
2890
|
ctx.scale(sim.zoom, sim.zoom);
|
|
2418
2891
|
graph.edges.forEach(function (e) {
|
|
2419
2892
|
var a = graph.nodes[e.a], b = graph.nodes[e.b];
|
|
2893
|
+
updateSide(e); // 朝向迟滞:另一侧明显更空才翻,防抖(详见 updateSide)
|
|
2894
|
+
var c = edgeCtrl(e);
|
|
2420
2895
|
ctx.strokeStyle = tk.line;
|
|
2421
2896
|
ctx.globalAlpha = 0.8;
|
|
2422
2897
|
ctx.lineWidth = e.w;
|
|
2423
|
-
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();
|
|
2424
2899
|
if (e.type && sim.zoom >= 0.7) {
|
|
2425
2900
|
ctx.globalAlpha = 1;
|
|
2426
2901
|
ctx.fillStyle = tk.faint;
|
|
2427
2902
|
ctx.font = "10px sans-serif";
|
|
2428
2903
|
ctx.textAlign = "center";
|
|
2429
|
-
|
|
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);
|
|
2430
2906
|
}
|
|
2431
2907
|
});
|
|
2432
2908
|
ctx.globalAlpha = 1;
|
|
2433
2909
|
graph.nodes.forEach(function (nd) {
|
|
2434
|
-
var s = toScreen(nd.x, nd.y);
|
|
2435
2910
|
var r = 18; // 世界坐标画:半径随 zoom 缩放
|
|
2911
|
+
var rc = NODE_ROLE_COLORS[nd.role] || NODE_ROLE_COLORS.minor;
|
|
2436
2912
|
ctx.beginPath();
|
|
2437
2913
|
ctx.arc(nd.x, nd.y, r, 0, Math.PI * 2);
|
|
2438
|
-
ctx.fillStyle =
|
|
2439
|
-
ctx.globalAlpha = nd.fixed ? 0.25 : 1;
|
|
2914
|
+
ctx.fillStyle = rc;
|
|
2440
2915
|
ctx.fill();
|
|
2441
|
-
ctx.
|
|
2442
|
-
ctx.lineWidth = 1.5;
|
|
2916
|
+
ctx.lineWidth = nd.fixed ? 3 : 1.5; // 固定态=加粗 accent 描边(全节点恒有色后的唯一固定指示)
|
|
2443
2917
|
ctx.strokeStyle = tk.accent;
|
|
2444
2918
|
ctx.stroke();
|
|
2445
2919
|
ctx.fillStyle = tk.label;
|
|
@@ -2559,12 +3033,20 @@ window.__ModuleLoader__.load({
|
|
|
2559
3033
|
}
|
|
2560
3034
|
} catch (e) { /* 全屏 API 个别浏览器仍有策略性拒绝,静默 */ }
|
|
2561
3035
|
}
|
|
2562
|
-
if (!graph.nodes.length)
|
|
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
|
+
}
|
|
2563
3044
|
return h("div", { className: "myt-stack" },
|
|
2564
3045
|
h("div", { className: "myt-card" },
|
|
2565
3046
|
h("div", { className: "myt-cardHead" },
|
|
2566
3047
|
h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
|
|
2567
|
-
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"); })),
|
|
2568
3050
|
h("div", { className: "myt-graphWrap", ref: wrapRef },
|
|
2569
3051
|
h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
|
|
2570
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,
|
|
@@ -2572,12 +3054,19 @@ window.__ModuleLoader__.load({
|
|
|
2572
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); } }, "-"),
|
|
2573
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); } }, "+"),
|
|
2574
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)),
|
|
2575
|
-
h("div", { className: "myt-graphFoot" },
|
|
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"))))),
|
|
2576
3064
|
graph.unlinked.length ? h("div", { className: "myt-card" },
|
|
2577
3065
|
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
|
|
2578
3066
|
h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
|
|
2579
3067
|
return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
|
|
2580
|
-
}))) : null
|
|
3068
|
+
}))) : null,
|
|
3069
|
+
srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
|
|
2581
3070
|
}
|
|
2582
3071
|
|
|
2583
3072
|
// ── cordis client 插件入口 ──────────────────────────────────────────────
|