@arshdelight/practi 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -14
- package/dist/client.js +49 -6
- package/dist/client.js.map +1 -1
- package/dist/cmd/blob.js +10 -1
- package/dist/cmd/blob.js.map +1 -1
- package/dist/cmd/clone.js +15 -10
- package/dist/cmd/clone.js.map +1 -1
- package/dist/cmd/comment.js +50 -15
- package/dist/cmd/comment.js.map +1 -1
- package/dist/cmd/config.js +1 -1
- package/dist/cmd/config.js.map +1 -1
- package/dist/cmd/edit.js +82 -11
- package/dist/cmd/edit.js.map +1 -1
- package/dist/cmd/gc.js +77 -0
- package/dist/cmd/gc.js.map +1 -0
- package/dist/cmd/lifecycle.js +63 -13
- package/dist/cmd/lifecycle.js.map +1 -1
- package/dist/cmd/login.js +13 -4
- package/dist/cmd/login.js.map +1 -1
- package/dist/cmd/ls.js +41 -2
- package/dist/cmd/ls.js.map +1 -1
- package/dist/cmd/migrate.js +3 -0
- package/dist/cmd/migrate.js.map +1 -1
- package/dist/cmd/new.js +52 -6
- package/dist/cmd/new.js.map +1 -1
- package/dist/cmd/note.js +160 -0
- package/dist/cmd/note.js.map +1 -0
- package/dist/cmd/pull.js +2 -3
- package/dist/cmd/pull.js.map +1 -1
- package/dist/cmd/push.js +6 -1
- package/dist/cmd/push.js.map +1 -1
- package/dist/cmd/remote.js +15 -8
- package/dist/cmd/remote.js.map +1 -1
- package/dist/cmd/remove.js +42 -0
- package/dist/cmd/remove.js.map +1 -0
- package/dist/cmd/repair.js +34 -0
- package/dist/cmd/repair.js.map +1 -0
- package/dist/cmd/search.js +154 -57
- package/dist/cmd/search.js.map +1 -1
- package/dist/cmd/show.js +96 -9
- package/dist/cmd/show.js.map +1 -1
- package/dist/cmd/skill.js +2 -3
- package/dist/cmd/skill.js.map +1 -1
- package/dist/index.js +145 -36
- package/dist/index.js.map +1 -1
- package/dist/notes.js +121 -0
- package/dist/notes.js.map +1 -0
- package/dist/oauth.js +3 -0
- package/dist/oauth.js.map +1 -1
- package/dist/state.js +32 -1
- package/dist/state.js.map +1 -1
- package/dist/web.js +420 -139
- package/dist/web.js.map +1 -1
- package/dist/workspace.js +10 -0
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -2
- package/skills/use-practi/SKILL.md +49 -14
- package/web-default/app.js +106 -0
- package/web-default/detail.html +36 -0
- package/web-default/detail.js +871 -0
- package/web-default/index.html +28 -0
- package/web-default/lang.js +181 -0
- package/web-default/marked.min.js +69 -0
- package/web-default/style.css +234 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
// practi web 详情页(向导模式):一次只显示一个节点,从根出发,Next 逐步走。
|
|
2
|
+
// op 决定呈现:seq=顺序列表预告(可点直达);par=双列网格卡片;choice=可点选项卡(必须
|
|
3
|
+
// 择一才继续,选过的分支走完自动翻过整个 choice);set=目录链接自由跳转;
|
|
4
|
+
// loop=条件旁注。数据走 /doc/<hash>.json(文档树,children 递归内联)。
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
var doc = null;
|
|
8
|
+
var path = []; // 当前节点 = 从根出发的 children 下标路径;[] = 根
|
|
9
|
+
var navStack = []; // 走过的路径栈,Prev 回退用(避开只读全局 window.history)
|
|
10
|
+
var nodeIndex = null; // 哈希→树内路径登记簿(数据窗 nodeIndex),inputs.from 反解用
|
|
11
|
+
var view = 'wizard'; // 内容区视图:wizard | sv | doc(侧栏顶部三 tab)
|
|
12
|
+
var svJson = null; // StandardView JSON 缓存(首次切到该 tab 才拉取)
|
|
13
|
+
var notesByHash = {}; // 本地学习笔记(/api/notes),按节点哈希分组;写入口=CLI(practi note)+ 右栏(POST /api/notes)
|
|
14
|
+
var hashByPath = {}; // nodeIndex 的逆映射(树内路径 → 哈希);根(空路径)= HASH 本身
|
|
15
|
+
var notesOpen = false; // 右栏开合:默认折叠;首载若本文档有笔记则自动展开(不持久化,每页按规则重判)
|
|
16
|
+
var notesTouched = false; // 用户本页手动开合过——之后自动规则不再介入
|
|
17
|
+
var draftByHash = {}; // 未落库的新建草稿(文档哈希 → 文本):跨重渲染/导航存活,失焦有内容才落库
|
|
18
|
+
var noteErrorMsg = null; // 右栏写操作失败的一次性报错(下次动作或重渲染即清)
|
|
19
|
+
|
|
20
|
+
// op 旁注:用自然语言说清组合语义(seq 不需要说明);loop 的旁注由 loopNote 按数据推导
|
|
21
|
+
var OP_NOTES = {
|
|
22
|
+
par: function () { return POP_I18N.t('opPar'); },
|
|
23
|
+
choice: function () { return POP_I18N.t('opChoice'); },
|
|
24
|
+
set: function () { return POP_I18N.t('opSet'); },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function escapeHtml(s) {
|
|
28
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
29
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function walk(p) {
|
|
34
|
+
var n = doc;
|
|
35
|
+
for (var i = 0; i < p.length; i++) n = n.children[p[i]];
|
|
36
|
+
return n;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
// 下一个节点:有孩子且非 choice → 首个孩子;否则向上找下一个兄弟。
|
|
41
|
+
// choice 的其他选项是「未选的分支」,不算下一步——选过的分支走完直接翻过整个 choice
|
|
42
|
+
function nextOf(p) {
|
|
43
|
+
var node = walk(p);
|
|
44
|
+
if (node.children && node.children.length && node.op !== 'choice') return p.concat([0]);
|
|
45
|
+
var q = p.slice();
|
|
46
|
+
while (q.length) {
|
|
47
|
+
var idx = q.pop();
|
|
48
|
+
var parent = walk(q);
|
|
49
|
+
if (idx + 1 < parent.children.length && parent.op !== 'choice') return q.concat([idx + 1]);
|
|
50
|
+
}
|
|
51
|
+
return null; // 走完了
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function goTo(p) {
|
|
55
|
+
navStack.push(path.slice());
|
|
56
|
+
path = p;
|
|
57
|
+
render();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function shortHash(h) {
|
|
61
|
+
return String(h).length > 16 ? String(h).slice(0, 16) + '…' : String(h);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function notesFor(hash) {
|
|
65
|
+
return notesByHash[hash] || [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 当前节点哈希:根=HASH(URL 里那份),树内=逆登记簿 */
|
|
69
|
+
function hashAt(p) {
|
|
70
|
+
return p.length ? hashByPath[p.join(',')] : HASH;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 大纲里的笔记数徽标:0 条不出现;点击=跳到该节点并展开笔记栏(点击委托 data-note-badge) */
|
|
74
|
+
function noteBadge(pathKey, count) {
|
|
75
|
+
if (!count) return '';
|
|
76
|
+
return '<span class="note-badge" data-note-badge data-path="' + pathKey + '">' + count + '</span>';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 渲染 ──────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function render() {
|
|
82
|
+
var app = document.getElementById('app');
|
|
83
|
+
var node = walk(path);
|
|
84
|
+
// 三视图:向导正文 / StandardView JSON / document JSON——侧栏大纲始终是导航脊柱,
|
|
85
|
+
// 底部 Prev/Next 也常驻(在 JSON 视图里换节点=高亮跟着走)
|
|
86
|
+
app.innerHTML = (view === 'wizard' ? nodeHtml(node) : jsonSectionHtml()) + navHtml();
|
|
87
|
+
updateTabs();
|
|
88
|
+
markSide();
|
|
89
|
+
renderNoteSide(); // 右栏跟人走:每次导航换节点都重渲染(草稿保真在 renderNoteSide 内部处理)
|
|
90
|
+
// 向导视图回顶(瞬移,既有行为);JSON 视图平滑滚到当前高亮块首行。
|
|
91
|
+
// 滚动容器是中间内容列自身(应用壳布局:整页不滚),原生 smooth 滚动天然可中断:
|
|
92
|
+
// 快速连点不同节点时,新目标的滚动指令会取消进行中的动画,从当前位置转向新目标
|
|
93
|
+
if (view === 'wizard') {
|
|
94
|
+
app.scrollTop = 0;
|
|
95
|
+
} else {
|
|
96
|
+
var hl = document.querySelector('.json-pre .ln.hl');
|
|
97
|
+
if (hl) hl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
98
|
+
else app.scrollTo({ top: 0, behavior: 'smooth' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── JSON 视图:pretty 渲染 + 当前节点高亮 ──
|
|
103
|
+
// document 视图:树即嵌套对象,当前节点 = walk(path) 的对象引用,按引用相等高亮(双胞胎只亮所在那份)
|
|
104
|
+
// StandardView 视图:practice 容器不进 steps,按「子树 steps 区段」高亮(steps 顺序与树遍历一致);
|
|
105
|
+
// set 孩子的后代不递归聚合、天生不在视图里——老实不高亮
|
|
106
|
+
|
|
107
|
+
function jsonSectionHtml() {
|
|
108
|
+
if (view === 'doc') {
|
|
109
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelDocJson') + '</div>' +
|
|
110
|
+
'<pre class="json-pre">' + jsonHtml(doc, markDoc) + '</pre></div>';
|
|
111
|
+
}
|
|
112
|
+
if (svJson === null) {
|
|
113
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelSvJson') + '</div><p class="op-note">' + POP_I18N.t('loading') + '</p></div>';
|
|
114
|
+
}
|
|
115
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelSvJson') + '</div>' +
|
|
116
|
+
'<pre class="json-pre">' + jsonHtml(svJson, markSv) + '</pre></div>';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 按对象引用高亮:document 视图的目标就是当前节点对象本身 */
|
|
120
|
+
function markDoc(container, key, index, child) {
|
|
121
|
+
return child === walk(path);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** StandardView 高亮:复算 steps 遍历顺序(action/set 直接孩子=一步;其余 practice 递归),
|
|
125
|
+
* 当前节点的子树 steps(含自身)全部高亮 */
|
|
126
|
+
function markSv(container, key, index, child) {
|
|
127
|
+
if (!svJson || container !== svJson.steps) return false;
|
|
128
|
+
var hits = svMarkedSteps();
|
|
129
|
+
for (var i = 0; i < hits.length; i++) if (svJson.steps[hits[i]] === child) return true;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function svMarkedSteps() {
|
|
134
|
+
// 根节点不高亮(与 document 视图对齐):全树都亮等于没有定位
|
|
135
|
+
if (!path.length) return [];
|
|
136
|
+
var marks = [];
|
|
137
|
+
var idx = 0;
|
|
138
|
+
(function w(n, p, parentIsSet) {
|
|
139
|
+
(n.children || []).forEach(function (c, i) {
|
|
140
|
+
var cp = p.concat([i]);
|
|
141
|
+
// 聚合口径:action 叶子成一步;set 的直接孩子无论类型都成条目;其余 practice 递归
|
|
142
|
+
var isStep = parentIsSet || c.type === 'action';
|
|
143
|
+
if (isStep) {
|
|
144
|
+
// 当前路径是该 step 路径的前缀(含相等)= 此 step 落在当前节点子树内
|
|
145
|
+
if (cp.length >= path.length && path.every(function (v, k) { return cp[k] === v; })) marks.push(idx);
|
|
146
|
+
idx++;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
w(c, cp, c.op === 'set');
|
|
150
|
+
});
|
|
151
|
+
})(doc, [], false);
|
|
152
|
+
return marks;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON → HTML:标准 pretty(2 空格缩进),markChild(container,key,index,child) 决定该孩子起是否高亮。
|
|
156
|
+
* 行级输出:每行一个 span.ln(display:block),命中子树(hl 继承给后代)的开行、内部行、
|
|
157
|
+
* 闭括号行各自整行铺底——编辑器选中行形态,不再按文本片段断续着色 */
|
|
158
|
+
function jsonHtml(value, markChild) {
|
|
159
|
+
function esc(s) { return escapeHtml(s); }
|
|
160
|
+
function w(v, depth, hl, key) {
|
|
161
|
+
var pad = new Array(depth + 1).join(' ');
|
|
162
|
+
var k = key === null ? '' : '<span class="jk">' + esc(JSON.stringify(key)) + '</span>: ';
|
|
163
|
+
if (v === null) return [{ t: pad + k + 'null', h: hl }];
|
|
164
|
+
if (Array.isArray(v)) {
|
|
165
|
+
if (!v.length) return [{ t: pad + k + '[]', h: hl }];
|
|
166
|
+
var lines = [{ t: pad + k + '[', h: hl }];
|
|
167
|
+
v.forEach(function (x, i) {
|
|
168
|
+
var block = w(x, depth + 1, hl || markChild(v, null, i, x), null);
|
|
169
|
+
if (i < v.length - 1) block[block.length - 1].t += ',';
|
|
170
|
+
lines = lines.concat(block);
|
|
171
|
+
});
|
|
172
|
+
lines.push({ t: pad + ']', h: hl });
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
if (typeof v === 'object') {
|
|
176
|
+
var keys = Object.keys(v);
|
|
177
|
+
if (!keys.length) return [{ t: pad + k + '{}', h: hl }];
|
|
178
|
+
var lines2 = [{ t: pad + k + '{', h: hl }];
|
|
179
|
+
keys.forEach(function (kk, i) {
|
|
180
|
+
var block2 = w(v[kk], depth + 1, hl || markChild(v, kk, null, v[kk]), kk);
|
|
181
|
+
if (i < keys.length - 1) block2[block2.length - 1].t += ',';
|
|
182
|
+
lines2 = lines2.concat(block2);
|
|
183
|
+
});
|
|
184
|
+
lines2.push({ t: pad + '}', h: hl });
|
|
185
|
+
return lines2;
|
|
186
|
+
}
|
|
187
|
+
return [{ t: pad + k + (typeof v === 'string' ? esc(JSON.stringify(v)) : String(v)), h: hl }];
|
|
188
|
+
}
|
|
189
|
+
return w(value, 0, false, null).map(function (l) {
|
|
190
|
+
return '<span class="ln' + (l.h ? ' hl' : '') + '">' + l.t + '</span>';
|
|
191
|
+
}).join('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── 左侧大纲(学 hub /pop 详情侧栏):根的直接孩子成节(1、2…)递归(1.1),
|
|
195
|
+
// 与 fromRef 的 #编号同一坐标系;点击 goTo 跳转,当前节点高亮 ──
|
|
196
|
+
|
|
197
|
+
/** 全树节点数(含根本身)——侧栏大纲列的就是节点(1、1.1…),统计行与之同口径;
|
|
198
|
+
* 不叫 steps:steps 只对 op:seq 成立 */
|
|
199
|
+
function countNodes(n) {
|
|
200
|
+
return 1 + (n.children || []).reduce(function (s, c) { return s + countNodes(c); }, 0);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 图标 tab(lucide list-todo/list-ordered/list-tree):图标即按钮,名字进 tooltip 与 aria-label */
|
|
204
|
+
function tabIconBtn(view, tipKey, inner) {
|
|
205
|
+
return '<button type="button" class="side-tab" data-view="' + view + '" data-tip="' + POP_I18N.t(tipKey) +
|
|
206
|
+
'" aria-label="' + POP_I18N.t(tipKey) + '">' +
|
|
207
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + inner + '</svg></button>';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sideHtml() {
|
|
211
|
+
var total = countNodes(doc);
|
|
212
|
+
// 视图三 tab 钉在侧栏最顶:向导正文 / StandardView JSON / document JSON(内容区随之换形态)
|
|
213
|
+
var html = '<div class="side-tabs">' +
|
|
214
|
+
tabIconBtn('wizard', 'tabWizard', '<path d="M13 5h8"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="m3 17 2 2 4-4"/><rect x="3" y="4" width="6" height="6" rx="1"/>') +
|
|
215
|
+
tabIconBtn('sv', 'tabSv', '<path d="M11 5h10"/><path d="M11 12h10"/><path d="M11 19h10"/><path d="M4 4h1v5"/><path d="M4 9h2"/><path d="M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02"/>') +
|
|
216
|
+
tabIconBtn('doc', 'tabDoc', '<path d="M8 5h13"/><path d="M13 12h8"/><path d="M13 19h8"/><path d="M3 10a2 2 0 0 0 2 2h3"/><path d="M3 5v12a2 2 0 0 0 2 2h3"/>') +
|
|
217
|
+
'</div>' +
|
|
218
|
+
'<p class="side-count">' + POP_I18N.t('nodeCount', total) + '</p>' +
|
|
219
|
+
'<a href="#" class="side-item side-root" data-path="">' + escapeHtml(doc.name) + noteBadge('', notesFor(HASH).length) + '</a>';
|
|
220
|
+
(function walkSide(n, prefix, depth, p) {
|
|
221
|
+
(n.children || []).forEach(function (c, i) {
|
|
222
|
+
var num = prefix + (i + 1);
|
|
223
|
+
var cp = p.concat([i]);
|
|
224
|
+
html += '<a href="#" class="side-item" data-path="' + cp.join(',') + '" style="padding-left:' + (14 + depth * 14) + 'px">' +
|
|
225
|
+
'<span class="side-num">' + num + '</span>' + escapeHtml(c.name) + noteBadge(cp.join(','), notesFor(hashAt(cp)).length) + '</a>';
|
|
226
|
+
if (c.children && c.children.length) walkSide(c, num + '.', depth + 1, cp);
|
|
227
|
+
});
|
|
228
|
+
})(doc, '', 0, []);
|
|
229
|
+
|
|
230
|
+
// 底部附加区:全文档 revisions(新→旧,点击跳到所属节点)+ refines 边
|
|
231
|
+
// (目标在文档内 → #编号跳转;悬空 → 短哈希);两段空则整段不出现。
|
|
232
|
+
// 包在 .side-foot(margin-top:auto)里钉在侧栏最底,不贴着大纲
|
|
233
|
+
var foot = '';
|
|
234
|
+
var revs = [];
|
|
235
|
+
var refs = [];
|
|
236
|
+
(function w(n, p) {
|
|
237
|
+
(n.revisions || []).forEach(function (r) { revs.push({ p: p, name: n.name, r: r }); });
|
|
238
|
+
if (n.type === 'practice' && n.refines) refs.push({ p: p, name: n.name, target: n.refines });
|
|
239
|
+
(n.children || []).forEach(function (c, i) { w(c, p.concat([i])); });
|
|
240
|
+
})(doc, []);
|
|
241
|
+
if (revs.length) {
|
|
242
|
+
revs.sort(function (a, b) { return a.r.when < b.r.when ? 1 : -1; });
|
|
243
|
+
foot += '<div class="side-sec"><p class="side-count">' + POP_I18N.t('revisions', revs.length) + '</p>' +
|
|
244
|
+
revs.map(function (v) {
|
|
245
|
+
return '<a href="#" class="side-note" data-path="' + v.p.join(',') + '" data-tip="' + escapeHtml(v.name) + '">' +
|
|
246
|
+
escapeHtml(String(v.r.when).slice(0, 10)) + ' — ' + escapeHtml(v.r.what) + '</a>';
|
|
247
|
+
}).join('') + '</div>';
|
|
248
|
+
}
|
|
249
|
+
if (refs.length) {
|
|
250
|
+
foot += '<div class="side-sec"><p class="side-count">' + POP_I18N.t('refines', refs.length) + '</p>' +
|
|
251
|
+
refs.map(function (v) {
|
|
252
|
+
var tp = nodeIndex && nodeIndex[v.target];
|
|
253
|
+
var tail = tp
|
|
254
|
+
? '<a href="#" class="side-note mono" data-path="' + tp.join(',') + '">→ #' + tp.map(function (i) { return i + 1; }).join('.') + '</a>'
|
|
255
|
+
: '<span class="side-note mono plain">→ ' + escapeHtml(shortHash(v.target)) + '</span>';
|
|
256
|
+
return '<div class="side-note-row"><span class="side-note plain" data-tip="' + escapeHtml(v.name) + '">' +
|
|
257
|
+
escapeHtml(v.name) + '</span>' + tail + '</div>';
|
|
258
|
+
}).join('') + '</div>';
|
|
259
|
+
}
|
|
260
|
+
return html + (foot ? '<div class="side-foot">' + foot + '</div>' : '');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function updateTabs() {
|
|
264
|
+
var tabs = document.querySelectorAll('.side-tab');
|
|
265
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
266
|
+
tabs[i].classList.toggle('on', tabs[i].getAttribute('data-view') === view);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function markSide() {
|
|
271
|
+
var cur = path.join(',');
|
|
272
|
+
var items = document.querySelectorAll('.side-item');
|
|
273
|
+
for (var i = 0; i < items.length; i++) {
|
|
274
|
+
// root 条目不参与 selected——点击前后观感一致,hover 仍可用
|
|
275
|
+
if (items[i].classList.contains('side-root')) continue;
|
|
276
|
+
items[i].classList.toggle('on', items[i].getAttribute('data-path') === cur);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 顶部区已撤:返回职责归 header 左上角 brand-link(回目录),定位职责归左侧大纲
|
|
281
|
+
|
|
282
|
+
function nodeHtml(node) {
|
|
283
|
+
var html = '<h1 class="node-title">' + escapeHtml(node.name) + '</h1>';
|
|
284
|
+
if (node.description) html += '<p class="node-desc">' + escapeHtml(node.description) + '</p>';
|
|
285
|
+
|
|
286
|
+
if (node.type === 'practice') {
|
|
287
|
+
// loop 的 repeat 条件已升为循环体小节头,不再另发旁注
|
|
288
|
+
var note = OP_NOTES[node.op];
|
|
289
|
+
if (note) html += '<p class="op-note">' + escapeHtml(note()) + '</p>';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (node.type === 'action') html += inputsHtml(node);
|
|
293
|
+
html += proseHtml(node);
|
|
294
|
+
if (node.type === 'action') html += outputsHtml(node) + attHtml(node);
|
|
295
|
+
html += childrenHtml(node) + choiceHtml(node) + setHtml(node);
|
|
296
|
+
// 笔记只住右侧栏(不进内容区):revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
|
|
297
|
+
return html;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── 学习笔记(/api/notes,本地 sidecar notes.json)──
|
|
301
|
+
// 呈现只住右侧笔记栏;内容区与 JSON 两视图不出现
|
|
302
|
+
|
|
303
|
+
/** ISO → 浏览器本地时区的 YYYY-MM-DD HH:mm(个人日记口径:时间属于看的人,不属于服务器) */
|
|
304
|
+
function fmtNoteTime(iso) {
|
|
305
|
+
var d = new Date(iso);
|
|
306
|
+
if (isNaN(d.getTime())) return String(iso || '');
|
|
307
|
+
function p(x) { return (x < 10 ? '0' : '') + x; }
|
|
308
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── 右侧笔记栏(#note-side):当前节点的笔记工作台,常驻可增改删 ──
|
|
312
|
+
// 交互:点文本进入行内编辑、失焦即存(Esc 取消、清空=还原原文);新建=头部按钮弹出
|
|
313
|
+
// 草稿(失焦有内容才落库,空=静默放弃——存储层不允许空笔记)。写入门之二
|
|
314
|
+
// (其一=CLI practi note):POST /api/notes,CSRF 闸与 /api/run 同规格
|
|
315
|
+
|
|
316
|
+
var TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
|
317
|
+
var PLUS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg>';
|
|
318
|
+
|
|
319
|
+
/** 开合同步:面板宽度与内容列让位由 CSS transition 承担,这里只切类
|
|
320
|
+
* (body.note-open 让底部操作区同步让位,与内容列一起平滑重排) */
|
|
321
|
+
function applyNotesOpen() {
|
|
322
|
+
var panel = document.getElementById('note-side');
|
|
323
|
+
if (panel) panel.classList.toggle('open', notesOpen);
|
|
324
|
+
document.body.classList.toggle('note-open', notesOpen);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 展开收起舌片:钉在面板左缘垂直居中(shell 内面板的相邻兄弟,right 与面板宽度
|
|
328
|
+
* 同步过渡即贴合面板边);收起后停在屏幕右缘仍可点 */
|
|
329
|
+
function wireNoteToggle() {
|
|
330
|
+
var btn = document.getElementById('note-toggle');
|
|
331
|
+
if (!btn) return;
|
|
332
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>';
|
|
333
|
+
btn.setAttribute('aria-label', POP_I18N.t('notePanel'));
|
|
334
|
+
btn.addEventListener('click', function () {
|
|
335
|
+
notesOpen = !notesOpen;
|
|
336
|
+
notesTouched = true;
|
|
337
|
+
noteErrorMsg = null;
|
|
338
|
+
renderNoteSide();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function renderNoteSide() {
|
|
343
|
+
var panel = document.getElementById('note-side');
|
|
344
|
+
if (!panel) return;
|
|
345
|
+
applyNotesOpen();
|
|
346
|
+
reconcileNoteSide();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 面板渲染=把 DOM 对齐到服务端笔记。笔记本体是常驻可编辑的 textarea(无边框、
|
|
350
|
+
* 自动长高,观感与正文一致)——没有「进入编辑态」这个动作,点了就有光标,失焦即存。
|
|
351
|
+
* 重建时保留未落库的改动与聚焦位(SSE 重拉/语言切换/导航不打断打字) */
|
|
352
|
+
function reconcileNoteSide() {
|
|
353
|
+
var panel = document.getElementById('note-side');
|
|
354
|
+
if (!panel) return;
|
|
355
|
+
applyNotesOpen();
|
|
356
|
+
var list = document.getElementById('note-list');
|
|
357
|
+
if (!list) return;
|
|
358
|
+
var keep = {}; // 未落库改动的值(key=data-id)
|
|
359
|
+
var activeKey = null, caret = null;
|
|
360
|
+
list.querySelectorAll('[data-note-input]').forEach(function (ta) {
|
|
361
|
+
var id = ta.getAttribute('data-id');
|
|
362
|
+
if (!id) return; // 草稿的存活走 draftByHash,不进 keep
|
|
363
|
+
var n = noteById(id);
|
|
364
|
+
if (n && ta.value !== n.content) keep[id] = ta.value;
|
|
365
|
+
if (document.activeElement === ta) { activeKey = id; caret = ta.selectionStart; }
|
|
366
|
+
});
|
|
367
|
+
var hash = hashAt(path);
|
|
368
|
+
var notes = notesFor(hash);
|
|
369
|
+
var draftValue = draftByHash.hasOwnProperty(hash) ? draftByHash[hash] : null;
|
|
370
|
+
var count = notes.length + (draftValue !== null ? 1 : 0);
|
|
371
|
+
var html = '<div class="nside-head"><p class="nside-title">' + POP_I18N.t('secNotes', count) + '</p>' +
|
|
372
|
+
'<button type="button" class="nside-new" data-act="note-new" data-tip="' + escapeHtml(POP_I18N.t('noteNew')) + '" aria-label="' + escapeHtml(POP_I18N.t('noteNew')) + '">' + PLUS_ICON + '</button></div>';
|
|
373
|
+
if (noteErrorMsg) html += '<p class="nside-err">' + escapeHtml(noteErrorMsg) + '</p>';
|
|
374
|
+
if (!notes.length && draftValue === null) html += '<p class="nside-empty">' + escapeHtml(POP_I18N.t('noteEmpty')) + '</p>';
|
|
375
|
+
html += notes.map(function (n) {
|
|
376
|
+
var v = keep.hasOwnProperty(n.id) ? keep[n.id] : n.content;
|
|
377
|
+
return '<div class="nside-item">' +
|
|
378
|
+
'<div class="nside-meta"><span class="note-time">' + escapeHtml(fmtNoteTime(n.createdAt)) + '</span>' +
|
|
379
|
+
'<span class="nside-acts">' +
|
|
380
|
+
'<button type="button" class="nside-act" data-act="note-del" data-id="' + n.id + '" data-tip="' + escapeHtml(POP_I18N.t('noteDelete')) + '" aria-label="' + escapeHtml(POP_I18N.t('noteDelete')) + '">' + TRASH_ICON + '</button>' +
|
|
381
|
+
'</span></div>' +
|
|
382
|
+
'<textarea class="nside-input" data-note-input data-id="' + n.id + '">' + escapeHtml(v) + '</textarea></div>';
|
|
383
|
+
}).join('');
|
|
384
|
+
if (draftValue !== null) html += '<div class="nside-item"><textarea class="nside-input" data-note-input data-hash="' + escapeHtml(hash) + '">' + escapeHtml(draftValue) + '</textarea></div>';
|
|
385
|
+
list.innerHTML = html;
|
|
386
|
+
list.querySelectorAll('[data-note-input]').forEach(function (ta) {
|
|
387
|
+
autoGrow(ta);
|
|
388
|
+
var k = ta.getAttribute('data-id');
|
|
389
|
+
if (activeKey === k) {
|
|
390
|
+
ta.focus();
|
|
391
|
+
try { ta.setSelectionRange(caret, caret); } catch (e) { /* 类型输入框无 selection */ }
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** textarea 高度自适应内容(无边框伪装成正文的代价:得自己长个) */
|
|
397
|
+
function autoGrow(ta) {
|
|
398
|
+
if (window.CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; // 原生自适应,JS 别插手
|
|
399
|
+
ta.style.height = 'auto';
|
|
400
|
+
ta.style.height = ta.scrollHeight + 'px';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function updateNoteCount() {
|
|
404
|
+
var list = document.getElementById('note-list');
|
|
405
|
+
if (!list) return;
|
|
406
|
+
var n = notesFor(hashAt(path)).length + list.querySelectorAll('[data-note-input]:not([data-id])').length;
|
|
407
|
+
var el = list.querySelector('.nside-title');
|
|
408
|
+
if (el) el.textContent = POP_I18N.t('secNotes', n);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function postNote(body) {
|
|
412
|
+
return fetch('/api/notes', {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
headers: { 'content-type': 'application/json' },
|
|
415
|
+
body: JSON.stringify(body),
|
|
416
|
+
}).then(function (r) {
|
|
417
|
+
return r.json().catch(function () { return null; }).then(function (j) {
|
|
418
|
+
if (!r.ok) {
|
|
419
|
+
var msg = j && j.error ? j.error : 'HTTP ' + r.status;
|
|
420
|
+
// 裸 404(非 JSON 响应体)= 服务器没有这条路由:进程还在跑旧构建
|
|
421
|
+
// (前端静态文件即时读盘是新的,web.ts 服务器代码是冷的——重启 practi web 即愈)
|
|
422
|
+
if (!j && r.status === 404) msg += ' — ' + POP_I18N.t('noteServerStale');
|
|
423
|
+
throw new Error(msg);
|
|
424
|
+
}
|
|
425
|
+
return j;
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 所有笔记 POST 串行过同一队列:失焦保存与删除并发时不会互相覆盖 */
|
|
431
|
+
var noteQueue = Promise.resolve();
|
|
432
|
+
function queueNoteOp(fn) {
|
|
433
|
+
var p = noteQueue.then(fn, fn);
|
|
434
|
+
noteQueue = p.catch(function () {});
|
|
435
|
+
return p;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function noteById(id) {
|
|
439
|
+
var arr = notesFor(hashAt(path));
|
|
440
|
+
for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i];
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function noteFail(e) {
|
|
445
|
+
noteErrorMsg = String(e && e.message ? e.message : e);
|
|
446
|
+
renderNoteSide();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function deleteNoteById(id, itemNode) {
|
|
450
|
+
queueNoteOp(function () {
|
|
451
|
+
return postNote({ op: 'delete', id: id }).then(function () {
|
|
452
|
+
Object.keys(notesByHash).forEach(function (k) {
|
|
453
|
+
notesByHash[k] = notesByHash[k].filter(function (x) { return x.id !== id; });
|
|
454
|
+
});
|
|
455
|
+
if (itemNode && itemNode.parentNode) itemNode.parentNode.removeChild(itemNode);
|
|
456
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标计数更新
|
|
457
|
+
updateNoteCount();
|
|
458
|
+
}).catch(function (e) { noteFail(e); });
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
function loopNote(node) {
|
|
464
|
+
if (node.loop && node.loop.mode === 'count') return POP_I18N.t('repeatX', node.loop.count);
|
|
465
|
+
if (node.loop && node.loop.mode === 'until') return POP_I18N.t('repeatUntil', node.loop.until);
|
|
466
|
+
return POP_I18N.t('repeat');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── 正文(markdown-lite:围栏代码块 + 图片引用,其余按段落转义) ──
|
|
470
|
+
|
|
471
|
+
function proseHtml(node) {
|
|
472
|
+
var content = node.content || '';
|
|
473
|
+
if (!content.trim()) return '';
|
|
474
|
+
// marked 在位(detail.html 引入 /marked.min.js)走全语法渲染;DIY 覆盖前端缺库时回落 lite
|
|
475
|
+
if (typeof marked !== 'undefined' && marked.parse) {
|
|
476
|
+
return sectionWrap(renderMarkdown(content, node));
|
|
477
|
+
}
|
|
478
|
+
return sectionWrap(proseLite(content, node));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function sectionWrap(body) {
|
|
482
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secContent') + '</div><div class="prose">' + body + '</div></div>';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** content → HTML:对齐 hub MarkdownView(CommonMark+GFM,marked v15 vendored)。
|
|
486
|
+
* 安全口径同 react-markdown 默认:内联 HTML 一律按文本显示——渲染前预转义 &<>,
|
|
487
|
+
* marked 只做语法渲染;mailto 自动链接降级为文本(hub 同款规则) */
|
|
488
|
+
function renderMarkdown(content, node) {
|
|
489
|
+
var src = content.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, function (whole, alt, target) {
|
|
490
|
+
var url = mediaUrl(target, node);
|
|
491
|
+
// 未命中的图片引用整段按文本保留(转义 markdown 控制符)
|
|
492
|
+
if (url === null) return whole.replace(/([!\[\]()\\])/g, '\\$1');
|
|
493
|
+
return '';
|
|
494
|
+
});
|
|
495
|
+
// 只转义 < 与 &(足以挡内联 HTML 注入);> 保留——行首 "> " 是块引用语法
|
|
496
|
+
src = src.replace(/&/g, '&').replace(/</g, '<');
|
|
497
|
+
var html = marked.parse(src, { gfm: true, breaks: false, async: false });
|
|
498
|
+
return html.replace(/<a href="mailto:[^"]*">([^<]*)<\/a>/g, '$1');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function proseLite(content, node) {
|
|
502
|
+
var blocks = [];
|
|
503
|
+
var prose = content.replace(/```[\s\S]*?```/g, function (m) {
|
|
504
|
+
blocks.push(m);
|
|
505
|
+
return '\u0000B' + (blocks.length - 1) + '\u0000';
|
|
506
|
+
});
|
|
507
|
+
var parts = [];
|
|
508
|
+
var last = 0;
|
|
509
|
+
var re = /!\[([^\]]*)\]\(([^)]*)\)/g;
|
|
510
|
+
var m;
|
|
511
|
+
while ((m = re.exec(prose)) !== null) {
|
|
512
|
+
parts.push(paraHtml(prose.slice(last, m.index)));
|
|
513
|
+
var url = mediaUrl(m[2], node);
|
|
514
|
+
if (url === null) {
|
|
515
|
+
parts.push('<p>' + escapeHtml(m[0]) + '</p>');
|
|
516
|
+
} else {
|
|
517
|
+
parts.push('<figure><img src="' + escapeHtml(url) + '" alt="' + escapeHtml(m[1]) + '">' +
|
|
518
|
+
'<figcaption>' + escapeHtml(m[1]) + '</figcaption></figure>');
|
|
519
|
+
}
|
|
520
|
+
last = m.index + m[0].length;
|
|
521
|
+
}
|
|
522
|
+
parts.push(paraHtml(prose.slice(last)));
|
|
523
|
+
var out = parts.join('');
|
|
524
|
+
return out.replace(/\u0000B(\d+)\u0000/g, function (_, i) {
|
|
525
|
+
return '<pre>' + escapeHtml(blocks[Number(i)]) + '</pre>';
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function paraHtml(text) {
|
|
530
|
+
var t = text.trim();
|
|
531
|
+
if (!t) return '';
|
|
532
|
+
return t.split(/\n{2,}/).map(function (p) {
|
|
533
|
+
return '<p>' + escapeHtml(p).replace(/\n/g, '<br>') + '</p>';
|
|
534
|
+
}).join('');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// §5.1 media:http(s) 外链原样;按名命中附件 → url ?? /blobs/hash;未命中留原文
|
|
538
|
+
function mediaUrl(target, node) {
|
|
539
|
+
if (/^https?:\/\//i.test(target)) return target;
|
|
540
|
+
if (!node || node.type !== 'action' || !node.attachments) return null;
|
|
541
|
+
for (var i = 0; i < node.attachments.length; i++) {
|
|
542
|
+
if (node.attachments[i].name === target) {
|
|
543
|
+
return node.attachments[i].url || ('/blobs/' + node.attachments[i].hash);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── action 字段:管道读序 inputs → 正文 → outputs,有序列表;空缺的块整个不出现 ──
|
|
550
|
+
|
|
551
|
+
// from 反解(同 hub 口径):命中登记簿 → #编号引用(可点跳转 data-path 委托、
|
|
552
|
+
// 悬停气泡 data-tip=来源节点名);未命中/无登记簿/指向根 → 短哈希(可验真)
|
|
553
|
+
function fromRef(from) {
|
|
554
|
+
if (!from) return '';
|
|
555
|
+
var p = nodeIndex && nodeIndex[from];
|
|
556
|
+
if (!p) return ' <span class="io-from">← ' + escapeHtml(shortHash(from)) + '</span>';
|
|
557
|
+
var num = p.map(function (i) { return i + 1; }).join('.');
|
|
558
|
+
return ' <a href="#" class="io-ref" data-path="' + p.join(',') + '" data-tip="' + escapeHtml(walk(p).name) + '">← #' + num + '</a>';
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function ioItem(f) {
|
|
562
|
+
return '<li>' + escapeHtml(f.name) + fromRef(f.from) +
|
|
563
|
+
(f.spec ? '<span class="io-spec">' + escapeHtml(f.spec) + '</span>' : '') + '</li>';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function inputsHtml(node) {
|
|
567
|
+
var ins = node.inputs || [];
|
|
568
|
+
if (!ins.length) return '';
|
|
569
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secInputs') + '</div><ol class="io-list">' + ins.map(ioItem).join('') + '</ol></div>';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function outputsHtml(node) {
|
|
573
|
+
var outs = node.outputs || [];
|
|
574
|
+
if (!outs.length) return '';
|
|
575
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secOutputs') + '</div><ol class="io-list">' + outs.map(ioItem).join('') + '</ol></div>';
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function attHtml(node) {
|
|
579
|
+
var atts = node.attachments || [];
|
|
580
|
+
if (!atts.length) return '';
|
|
581
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secAttachments') + '</div><ul class="att-list">' + atts.map(function (a) {
|
|
582
|
+
var href = a.url || ('/blobs/' + a.hash);
|
|
583
|
+
var meta = [a.mime, a.hash ? shortHash(a.hash) : ''].filter(Boolean).join(' · ');
|
|
584
|
+
return '<li><a href="' + escapeHtml(href) + '">' + escapeHtml(a.name) + '</a>' +
|
|
585
|
+
(meta ? ' <span class="att-meta">' + escapeHtml(meta) + '</span>' : '') + '</li>';
|
|
586
|
+
}).join('') + '</ul></div>';
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── practice 的孩子呈现(按 op 分形态) ──
|
|
590
|
+
|
|
591
|
+
function childrenHtml(node) {
|
|
592
|
+
var kids = node.children || [];
|
|
593
|
+
if (!kids.length || node.op === 'choice' || node.op === 'set') return '';
|
|
594
|
+
// 小节头:seq=steps(N)、par=tasks(N)、loop=repeat 条件本身当头(旁注不再重复)
|
|
595
|
+
var head = node.op === 'loop'
|
|
596
|
+
? '<div class="section-sm"><div class="label">' + escapeHtml(loopNote(node)) + '</div></div>'
|
|
597
|
+
: '<div class="section-sm"><div class="label">' + POP_I18N.t(node.op === 'seq' ? 'secSteps' : 'secTasks', kids.length) + '</div></div>';
|
|
598
|
+
var items = kids.map(function (k, i) {
|
|
599
|
+
var body = (i + 1) + '. ' + escapeHtml(k.name) +
|
|
600
|
+
(k.description ? '<div class="child-desc">' + escapeHtml(k.description) + '</div>' : '');
|
|
601
|
+
// 序号卡一律=预告+跳转入口(seq/par/loop 全可点直达)
|
|
602
|
+
return '<button type="button" class="child-card" data-idx="' + i + '">' + body + '</button>';
|
|
603
|
+
}).join('');
|
|
604
|
+
return head + items;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function choiceHtml(node) {
|
|
608
|
+
if (node.op !== 'choice') return '';
|
|
609
|
+
var kids = node.children || [];
|
|
610
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secOptions', kids.length) + '</div></div>' +
|
|
611
|
+
kids.map(function (k, i) {
|
|
612
|
+
return '<button type="button" class="choice-card" data-idx="' + i + '">' +
|
|
613
|
+
'<span class="choice-body">' + escapeHtml(k.name) +
|
|
614
|
+
(k.description ? '<span class="child-desc">' + escapeHtml(k.description) + '</span>' : '') + '</span></button>';
|
|
615
|
+
}).join('');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function setHtml(node) {
|
|
619
|
+
if (node.op !== 'set') return '';
|
|
620
|
+
var kids = node.children || [];
|
|
621
|
+
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secItems', kids.length) + '</div></div>' +
|
|
622
|
+
kids.map(function (k, i) {
|
|
623
|
+
var body = (i + 1) + '. ' + escapeHtml(k.name) +
|
|
624
|
+
(k.description ? '<div class="child-desc">' + escapeHtml(k.description) + '</div>' : '');
|
|
625
|
+
return '<button type="button" class="child-card" data-idx="' + i + '">' + body + '</button>';
|
|
626
|
+
}).join('');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ── 导航 ──
|
|
630
|
+
|
|
631
|
+
// 底部操作区:固定在视口底(悬浮于内容列上方,宽屏 left 让出侧栏),无分界线
|
|
632
|
+
function navHtml() {
|
|
633
|
+
var node = walk(path);
|
|
634
|
+
var next = nextOf(path);
|
|
635
|
+
var choiceGate = node.type === 'practice' && node.op === 'choice';
|
|
636
|
+
var html = '<div class="nav"><div class="nav-row">';
|
|
637
|
+
html += navStack.length ? '<button type="button" class="btn" data-act="prev">' + POP_I18N.t('wizardPrev') + '</button>' : '<span></span>';
|
|
638
|
+
if (choiceGate) {
|
|
639
|
+
html += '<span class="nav-hint">' + POP_I18N.t('chooseBranch') + '</span>';
|
|
640
|
+
} else if (next) {
|
|
641
|
+
html += '<button type="button" class="btn btn-primary" data-act="next">' + POP_I18N.t('next') + '</button>';
|
|
642
|
+
} else {
|
|
643
|
+
html += '<span class="finish">' + POP_I18N.t('endPractice') + '</span>';
|
|
644
|
+
}
|
|
645
|
+
html += '</div></div>';
|
|
646
|
+
return html;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ── 事件(委托:innerHTML 重渲染不需要重复绑监听) ──
|
|
650
|
+
|
|
651
|
+
document.addEventListener('click', function (e) {
|
|
652
|
+
var tab = e.target.closest('[data-view]');
|
|
653
|
+
if (tab) {
|
|
654
|
+
view = tab.getAttribute('data-view');
|
|
655
|
+
if (view === 'sv' && svJson === null) {
|
|
656
|
+
// StandardView 首切拉取一次即缓存;期间先渲染 loading,到了再重绘
|
|
657
|
+
fetch('/pop/' + encodeURIComponent(HASH) + '.json')
|
|
658
|
+
.then(function (r) { return r.json(); })
|
|
659
|
+
.then(function (j) { svJson = j; if (view === 'sv') render(); });
|
|
660
|
+
}
|
|
661
|
+
render();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
var jump = e.target.closest('[data-idx]');
|
|
665
|
+
if (jump) {
|
|
666
|
+
goTo(path.concat([Number(jump.getAttribute('data-idx'))]));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
var nbadge = e.target.closest('[data-note-badge]');
|
|
670
|
+
if (nbadge) {
|
|
671
|
+
// 徽标语义=看这条节点的笔记:跳转交给下面的 data-path,这里负责展开右栏
|
|
672
|
+
if (!notesOpen) {
|
|
673
|
+
notesOpen = true;
|
|
674
|
+
notesTouched = true;
|
|
675
|
+
renderNoteSide();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
var jump = e.target.closest('[data-path]');
|
|
679
|
+
if (jump) {
|
|
680
|
+
e.preventDefault();
|
|
681
|
+
var raw = jump.getAttribute('data-path');
|
|
682
|
+
goTo(raw ? raw.split(',').map(Number) : []);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
var act = e.target.closest('[data-act]');
|
|
686
|
+
if (!act) return;
|
|
687
|
+
var a = act.getAttribute('data-act');
|
|
688
|
+
if (a === 'note-new') {
|
|
689
|
+
// 新建=在列表末尾补一个草稿框(已有草稿就聚焦它);失焦有内容才落库
|
|
690
|
+
var list = document.getElementById('note-list');
|
|
691
|
+
if (!list) return;
|
|
692
|
+
var existing = list.querySelector('[data-note-input]:not([data-id])');
|
|
693
|
+
if (existing) { existing.focus(); return; }
|
|
694
|
+
var item = document.createElement('div');
|
|
695
|
+
item.className = 'nside-item';
|
|
696
|
+
item.innerHTML = '<textarea class="nside-input" data-note-input></textarea>';
|
|
697
|
+
list.appendChild(item);
|
|
698
|
+
var ta = item.querySelector('textarea');
|
|
699
|
+
ta.focus();
|
|
700
|
+
updateNoteCount();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (a === 'note-del') {
|
|
704
|
+
// 写写相碰:进串行队列等当前失焦保存落库后再删,防并发读改写互相覆盖
|
|
705
|
+
var id = act.getAttribute('data-id');
|
|
706
|
+
var item = act.closest('.nside-item');
|
|
707
|
+
queueNoteOp(function () {
|
|
708
|
+
return postNote({ op: 'delete', id: id }).then(function () {
|
|
709
|
+
Object.keys(notesByHash).forEach(function (k) {
|
|
710
|
+
notesByHash[k] = notesByHash[k].filter(function (x) { return x.id !== id; });
|
|
711
|
+
});
|
|
712
|
+
if (item && item.parentNode) item.parentNode.removeChild(item);
|
|
713
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标计数更新
|
|
714
|
+
updateNoteCount();
|
|
715
|
+
}).catch(function (e) { noteFail(e); });
|
|
716
|
+
});
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (a === 'next') {
|
|
720
|
+
var n = nextOf(path);
|
|
721
|
+
if (n) goTo(n);
|
|
722
|
+
} else if (a === 'prev') {
|
|
723
|
+
if (navStack.length) {
|
|
724
|
+
path = navStack.pop();
|
|
725
|
+
render();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// ── 启动 ──
|
|
731
|
+
|
|
732
|
+
// 右栏键盘(textarea 常驻可编辑):Ctrl/Cmd+Enter 立即失焦保存,Esc 还原原文/放弃草稿
|
|
733
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
734
|
+
var list = document.getElementById('note-list');
|
|
735
|
+
if (!list) return;
|
|
736
|
+
list.addEventListener('keydown', function (e) {
|
|
737
|
+
var ta = e.target;
|
|
738
|
+
if (!ta.matches || !ta.matches('[data-note-input]')) return;
|
|
739
|
+
if (e.key === 'Escape') {
|
|
740
|
+
var id = ta.getAttribute('data-id');
|
|
741
|
+
if (id) {
|
|
742
|
+
var n = noteById(id);
|
|
743
|
+
if (n) ta.value = n.content; // 还原原文
|
|
744
|
+
} else {
|
|
745
|
+
ta.value = ''; // 草稿:清空
|
|
746
|
+
}
|
|
747
|
+
autoGrow(ta);
|
|
748
|
+
ta.blur(); // focusout 委托统一收口(未改动/空草稿都不会落库)
|
|
749
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
750
|
+
ta.blur();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
// 输入即长高
|
|
754
|
+
list.addEventListener('input', function (e) {
|
|
755
|
+
if (e.target.matches && e.target.matches('[data-note-input]')) autoGrow(e.target);
|
|
756
|
+
});
|
|
757
|
+
// 失焦即存:blur 不冒泡、focusout 冒泡,委托在列表上(innerHTML 重建不丢监听)
|
|
758
|
+
list.addEventListener('focusout', function (e) {
|
|
759
|
+
var ta = e.target;
|
|
760
|
+
if (!ta.matches || !ta.matches('[data-note-input]')) return;
|
|
761
|
+
var id = ta.getAttribute('data-id');
|
|
762
|
+
if (id) {
|
|
763
|
+
var n = noteById(id);
|
|
764
|
+
if (!n) return;
|
|
765
|
+
if (ta.value === n.content) return; // 未改动
|
|
766
|
+
if (!ta.value.trim()) { ta.value = n.content; autoGrow(ta); return; } // 清空=还原原文
|
|
767
|
+
var content = ta.value;
|
|
768
|
+
queueNoteOp(function () {
|
|
769
|
+
return postNote({ op: 'edit', id: id, content: content }).then(function (j) {
|
|
770
|
+
n.content = content;
|
|
771
|
+
if (j.note && j.note.updatedAt) n.updatedAt = j.note.updatedAt;
|
|
772
|
+
}).catch(function (err) { noteFail(err); });
|
|
773
|
+
});
|
|
774
|
+
} else {
|
|
775
|
+
var hash = ta.getAttribute('data-hash') || hashAt(path);
|
|
776
|
+
if (!ta.value.trim()) { // 空草稿=放弃
|
|
777
|
+
var item = ta.closest('.nside-item');
|
|
778
|
+
if (item) item.remove();
|
|
779
|
+
delete draftByHash[hash];
|
|
780
|
+
updateNoteCount();
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
draftByHash[hash] = ta.value; // 先记着:POST 在途时重渲染不丢
|
|
784
|
+
var content2 = ta.value;
|
|
785
|
+
queueNoteOp(function () {
|
|
786
|
+
return postNote({ op: 'add', hash: hash, content: content2 }).then(function (j) {
|
|
787
|
+
var note = j.note;
|
|
788
|
+
if (note) (notesByHash[hash] = notesByHash[hash] || []).push(note);
|
|
789
|
+
delete draftByHash[hash];
|
|
790
|
+
ta.setAttribute('data-id', note.id);
|
|
791
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标 0→N
|
|
792
|
+
updateNoteCount();
|
|
793
|
+
}).catch(function (err) { noteFail(err); });
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// 右栏舌片事件接线(元素静态在 HTML;提示文案等 lang.js 就绪,故挂 DOMContentLoaded)
|
|
800
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
801
|
+
wireNoteToggle();
|
|
802
|
+
applyNotesOpen();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// 语言切换:侧栏大纲与内容区一并重绘
|
|
806
|
+
POP_I18N.onChange(function () {
|
|
807
|
+
if (doc) document.getElementById('side').innerHTML = sideHtml();
|
|
808
|
+
render();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
var HASH = decodeURIComponent((location.pathname.split('/pop/')[1] || '').replace(/\.json$/, ''));
|
|
812
|
+
|
|
813
|
+
// nodeIndex(哈希→路径)逆成 hashByPath(路径→哈希):笔记按哈希钉,侧栏/节点卡按路径定位
|
|
814
|
+
function buildHashByPath() {
|
|
815
|
+
hashByPath = {};
|
|
816
|
+
if (!nodeIndex) return;
|
|
817
|
+
for (var h in nodeIndex) hashByPath[nodeIndex[h].join(',')] = h;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** 笔记到货后的重绘:只动左右两栏(侧栏徽标 + 右栏),内容区不碰——
|
|
821
|
+
* 笔记不进内容区,动它只会带来无谓的重绘与闪烁。
|
|
822
|
+
* 侧栏是整段 innerHTML 重建,重建后必须补回 tab 选中态与当前节点高亮 */
|
|
823
|
+
function onNotesArrived() {
|
|
824
|
+
if (!doc) return;
|
|
825
|
+
document.getElementById('side').innerHTML = sideHtml();
|
|
826
|
+
updateTabs();
|
|
827
|
+
markSide();
|
|
828
|
+
renderNoteSide();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function loadNotesData() {
|
|
832
|
+
fetch('/api/notes?ref=' + encodeURIComponent(HASH))
|
|
833
|
+
.then(function (r) { return r.ok ? r.json() : { notes: [] }; })
|
|
834
|
+
.then(function (j) {
|
|
835
|
+
notesByHash = {};
|
|
836
|
+
(j.notes || []).forEach(function (n) {
|
|
837
|
+
(notesByHash[n.hash] = notesByHash[n.hash] || []).push(n);
|
|
838
|
+
});
|
|
839
|
+
// 默认折叠;首载本文档有笔记则自动展开(用户手动开合过就不再自动介入)
|
|
840
|
+
if (!notesTouched && (j.notes || []).length) notesOpen = true;
|
|
841
|
+
onNotesArrived();
|
|
842
|
+
})
|
|
843
|
+
.catch(function () { /* 笔记是锦上添花:拉取失败静默,文档照常显示 */ });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// 另一终端写笔记(CLI/别的页面)→ 服务器发 notes 轻事件 → 只重拉笔记,绝不整刷。
|
|
847
|
+
// reconcile 会保留未落库改动与聚焦位,编辑中收到推送也不丢字
|
|
848
|
+
document.addEventListener('practi:notes', function () {
|
|
849
|
+
loadNotesData();
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
fetch('/doc/' + encodeURIComponent(HASH) + '.json')
|
|
853
|
+
.then(function (r) {
|
|
854
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
855
|
+
return r.json();
|
|
856
|
+
})
|
|
857
|
+
.then(function (d) {
|
|
858
|
+
// 数据窗=加法演进:树字段在顶层,nodeIndex 是后加的兄弟键(老响应没有它 → 哈希兜底)
|
|
859
|
+
doc = d;
|
|
860
|
+
nodeIndex = d.nodeIndex || null;
|
|
861
|
+
buildHashByPath();
|
|
862
|
+
document.title = doc.name + ' — practi';
|
|
863
|
+
document.getElementById('side').innerHTML = sideHtml();
|
|
864
|
+
render();
|
|
865
|
+
loadNotesData();
|
|
866
|
+
})
|
|
867
|
+
.catch(function (err) {
|
|
868
|
+
document.getElementById('app').innerHTML =
|
|
869
|
+
'<div class="empty"><p class="lead">' + POP_I18N.t('docFail', escapeHtml(String(err.message || err))) + '</p>' +
|
|
870
|
+
'<p class="hint"><a href="/">' + POP_I18N.t('backMine') + '</a></p></div>';
|
|
871
|
+
});
|