@arshdelight/practi 0.9.5 → 0.11.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 +51 -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 +13 -7
- 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 +80 -8
- 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 +50 -3
- 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/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 +1 -1
- package/dist/cmd/repair.js.map +1 -1
- package/dist/cmd/search.js +154 -57
- package/dist/cmd/search.js.map +1 -1
- package/dist/cmd/show.js +97 -9
- package/dist/cmd/show.js.map +1 -1
- package/dist/index.js +134 -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 +2 -1
- package/dist/state.js.map +1 -1
- package/dist/web.js +128 -13
- package/dist/web.js.map +1 -1
- package/package.json +1 -1
- package/skills/use-practi/SKILL.md +52 -15
- package/web-default/app.js +2 -2
- package/web-default/detail.html +6 -1
- package/web-default/detail.js +510 -15
- package/web-default/highlight.min.js +1262 -0
- package/web-default/lang.js +19 -7
- package/web-default/style.css +121 -10
package/web-default/detail.js
CHANGED
|
@@ -10,6 +10,12 @@ var navStack = []; // 走过的路径栈,Prev 回退用(避开只读全局 w
|
|
|
10
10
|
var nodeIndex = null; // 哈希→树内路径登记簿(数据窗 nodeIndex),inputs.from 反解用
|
|
11
11
|
var view = 'wizard'; // 内容区视图:wizard | sv | doc(侧栏顶部三 tab)
|
|
12
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; // 右栏写操作失败的一次性报错(下次动作或重渲染即清)
|
|
13
19
|
|
|
14
20
|
// op 旁注:用自然语言说清组合语义(seq 不需要说明);loop 的旁注由 loopNote 按数据推导
|
|
15
21
|
var OP_NOTES = {
|
|
@@ -55,6 +61,21 @@ function shortHash(h) {
|
|
|
55
61
|
return String(h).length > 16 ? String(h).slice(0, 16) + '…' : String(h);
|
|
56
62
|
}
|
|
57
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
|
+
|
|
58
79
|
// ── 渲染 ──────────────────────────────────────────────
|
|
59
80
|
|
|
60
81
|
function render() {
|
|
@@ -62,18 +83,20 @@ function render() {
|
|
|
62
83
|
var node = walk(path);
|
|
63
84
|
// 三视图:向导正文 / StandardView JSON / document JSON——侧栏大纲始终是导航脊柱,
|
|
64
85
|
// 底部 Prev/Next 也常驻(在 JSON 视图里换节点=高亮跟着走)
|
|
65
|
-
app.innerHTML = (view === 'wizard' ? nodeHtml(node) : jsonSectionHtml()) + navHtml();
|
|
86
|
+
app.innerHTML = topbarHtml() + (view === 'wizard' ? nodeHtml(node) : jsonSectionHtml()) + navHtml();
|
|
87
|
+
enhanceCodeBlocks(app);
|
|
66
88
|
updateTabs();
|
|
67
89
|
markSide();
|
|
90
|
+
renderNoteSide(); // 右栏跟人走:每次导航换节点都重渲染(草稿保真在 renderNoteSide 内部处理)
|
|
68
91
|
// 向导视图回顶(瞬移,既有行为);JSON 视图平滑滚到当前高亮块首行。
|
|
69
|
-
//
|
|
70
|
-
//
|
|
92
|
+
// 滚动容器是中间内容列自身(应用壳布局:整页不滚),原生 smooth 滚动天然可中断:
|
|
93
|
+
// 快速连点不同节点时,新目标的滚动指令会取消进行中的动画,从当前位置转向新目标
|
|
71
94
|
if (view === 'wizard') {
|
|
72
|
-
|
|
95
|
+
app.scrollTop = 0;
|
|
73
96
|
} else {
|
|
74
97
|
var hl = document.querySelector('.json-pre .ln.hl');
|
|
75
98
|
if (hl) hl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
76
|
-
else
|
|
99
|
+
else app.scrollTo({ top: 0, behavior: 'smooth' });
|
|
77
100
|
}
|
|
78
101
|
}
|
|
79
102
|
|
|
@@ -83,14 +106,19 @@ function render() {
|
|
|
83
106
|
// set 孩子的后代不递归聚合、天生不在视图里——老实不高亮
|
|
84
107
|
|
|
85
108
|
function jsonSectionHtml() {
|
|
109
|
+
// label 行右侧复制按钮:复用 data-code-copy 委托(编码 JSON 文本)
|
|
110
|
+
function labelRow(text, json) {
|
|
111
|
+
return '<div class="label-row"><div class="label">' + POP_I18N.t(text) + '</div>' +
|
|
112
|
+
'<button type="button" class="codeblock-copy json-copy" data-code-copy="' + encodeURIComponent(JSON.stringify(json, null, 2)) + '" aria-label="copy json">' + COPY_SVG + '</button></div>';
|
|
113
|
+
}
|
|
86
114
|
if (view === 'doc') {
|
|
87
|
-
return '<div class="section-sm"
|
|
115
|
+
return '<div class="section-sm">' + labelRow('labelDocJson', doc) +
|
|
88
116
|
'<pre class="json-pre">' + jsonHtml(doc, markDoc) + '</pre></div>';
|
|
89
117
|
}
|
|
90
118
|
if (svJson === null) {
|
|
91
119
|
return '<div class="section-sm"><div class="label">' + POP_I18N.t('labelSvJson') + '</div><p class="op-note">' + POP_I18N.t('loading') + '</p></div>';
|
|
92
120
|
}
|
|
93
|
-
return '<div class="section-sm"
|
|
121
|
+
return '<div class="section-sm">' + labelRow('labelSvJson', svJson) +
|
|
94
122
|
'<pre class="json-pre">' + jsonHtml(svJson, markSv) + '</pre></div>';
|
|
95
123
|
}
|
|
96
124
|
|
|
@@ -172,9 +200,10 @@ function jsonHtml(value, markChild) {
|
|
|
172
200
|
// ── 左侧大纲(学 hub /pop 详情侧栏):根的直接孩子成节(1、2…)递归(1.1),
|
|
173
201
|
// 与 fromRef 的 #编号同一坐标系;点击 goTo 跳转,当前节点高亮 ──
|
|
174
202
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
203
|
+
/** 全树节点数(含根本身)——侧栏大纲列的就是节点(1、1.1…),统计行与之同口径;
|
|
204
|
+
* 不叫 steps:steps 只对 op:seq 成立 */
|
|
205
|
+
function countNodes(n) {
|
|
206
|
+
return 1 + (n.children || []).reduce(function (s, c) { return s + countNodes(c); }, 0);
|
|
178
207
|
}
|
|
179
208
|
|
|
180
209
|
/** 图标 tab(lucide list-todo/list-ordered/list-tree):图标即按钮,名字进 tooltip 与 aria-label */
|
|
@@ -185,21 +214,21 @@ function tabIconBtn(view, tipKey, inner) {
|
|
|
185
214
|
}
|
|
186
215
|
|
|
187
216
|
function sideHtml() {
|
|
188
|
-
var total =
|
|
217
|
+
var total = countNodes(doc);
|
|
189
218
|
// 视图三 tab 钉在侧栏最顶:向导正文 / StandardView JSON / document JSON(内容区随之换形态)
|
|
190
219
|
var html = '<div class="side-tabs">' +
|
|
191
220
|
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"/>') +
|
|
192
221
|
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"/>') +
|
|
193
222
|
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"/>') +
|
|
194
223
|
'</div>' +
|
|
195
|
-
'<p class="side-count">' + POP_I18N.t('
|
|
196
|
-
'<a href="#" class="side-item side-root" data-path="">' + escapeHtml(doc.name) + '</a>';
|
|
224
|
+
'<p class="side-count">' + POP_I18N.t('nodeCount', total) + '</p>' +
|
|
225
|
+
'<a href="#" class="side-item side-root" data-path="">' + escapeHtml(doc.name) + noteBadge('', notesFor(HASH).length) + '</a>';
|
|
197
226
|
(function walkSide(n, prefix, depth, p) {
|
|
198
227
|
(n.children || []).forEach(function (c, i) {
|
|
199
228
|
var num = prefix + (i + 1);
|
|
200
229
|
var cp = p.concat([i]);
|
|
201
230
|
html += '<a href="#" class="side-item" data-path="' + cp.join(',') + '" style="padding-left:' + (14 + depth * 14) + 'px">' +
|
|
202
|
-
'<span class="side-num">' + num + '</span>' + escapeHtml(c.name) + '</a>';
|
|
231
|
+
'<span class="side-num">' + num + '</span>' + escapeHtml(c.name) + noteBadge(cp.join(','), notesFor(hashAt(cp)).length) + '</a>';
|
|
203
232
|
if (c.children && c.children.length) walkSide(c, num + '.', depth + 1, cp);
|
|
204
233
|
});
|
|
205
234
|
})(doc, '', 0, []);
|
|
@@ -270,10 +299,173 @@ function nodeHtml(node) {
|
|
|
270
299
|
html += proseHtml(node);
|
|
271
300
|
if (node.type === 'action') html += outputsHtml(node) + attHtml(node);
|
|
272
301
|
html += childrenHtml(node) + choiceHtml(node) + setHtml(node);
|
|
273
|
-
// revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
|
|
302
|
+
// 笔记只住右侧栏(不进内容区):revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
|
|
274
303
|
return html;
|
|
275
304
|
}
|
|
276
305
|
|
|
306
|
+
// ── 学习笔记(/api/notes,本地 sidecar notes.json)──
|
|
307
|
+
// 呈现只住右侧笔记栏;内容区与 JSON 两视图不出现
|
|
308
|
+
|
|
309
|
+
/** ISO → 浏览器本地时区的 YYYY-MM-DD HH:mm(个人日记口径:时间属于看的人,不属于服务器) */
|
|
310
|
+
function fmtNoteTime(iso) {
|
|
311
|
+
var d = new Date(iso);
|
|
312
|
+
if (isNaN(d.getTime())) return String(iso || '');
|
|
313
|
+
function p(x) { return (x < 10 ? '0' : '') + x; }
|
|
314
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── 右侧笔记栏(#note-side):当前节点的笔记工作台,常驻可增改删 ──
|
|
318
|
+
// 交互:点文本进入行内编辑、失焦即存(Esc 取消、清空=还原原文);新建=头部按钮弹出
|
|
319
|
+
// 草稿(失焦有内容才落库,空=静默放弃——存储层不允许空笔记)。写入门之二
|
|
320
|
+
// (其一=CLI practi note):POST /api/notes,CSRF 闸与 /api/run 同规格
|
|
321
|
+
|
|
322
|
+
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>';
|
|
323
|
+
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>';
|
|
324
|
+
|
|
325
|
+
/** 开合同步:面板宽度与内容列让位由 CSS transition 承担,这里只切类
|
|
326
|
+
* (body.note-open 让底部操作区同步让位,与内容列一起平滑重排) */
|
|
327
|
+
function applyNotesOpen() {
|
|
328
|
+
var panel = document.getElementById('note-side');
|
|
329
|
+
if (panel) panel.classList.toggle('open', notesOpen);
|
|
330
|
+
document.body.classList.toggle('note-open', notesOpen);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 展开收起舌片:钉在面板左缘垂直居中(shell 内面板的相邻兄弟,right 与面板宽度
|
|
334
|
+
* 同步过渡即贴合面板边);收起后停在屏幕右缘仍可点 */
|
|
335
|
+
function wireNoteToggle() {
|
|
336
|
+
var btn = document.getElementById('note-toggle');
|
|
337
|
+
if (!btn) return;
|
|
338
|
+
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>';
|
|
339
|
+
btn.setAttribute('aria-label', POP_I18N.t('notePanel'));
|
|
340
|
+
btn.addEventListener('click', function () {
|
|
341
|
+
notesOpen = !notesOpen;
|
|
342
|
+
notesTouched = true;
|
|
343
|
+
noteErrorMsg = null;
|
|
344
|
+
renderNoteSide();
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function renderNoteSide() {
|
|
349
|
+
var panel = document.getElementById('note-side');
|
|
350
|
+
if (!panel) return;
|
|
351
|
+
applyNotesOpen();
|
|
352
|
+
reconcileNoteSide();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 面板渲染=把 DOM 对齐到服务端笔记。笔记本体是常驻可编辑的 textarea(无边框、
|
|
356
|
+
* 自动长高,观感与正文一致)——没有「进入编辑态」这个动作,点了就有光标,失焦即存。
|
|
357
|
+
* 重建时保留未落库的改动与聚焦位(SSE 重拉/语言切换/导航不打断打字) */
|
|
358
|
+
function reconcileNoteSide() {
|
|
359
|
+
var panel = document.getElementById('note-side');
|
|
360
|
+
if (!panel) return;
|
|
361
|
+
applyNotesOpen();
|
|
362
|
+
var list = document.getElementById('note-list');
|
|
363
|
+
if (!list) return;
|
|
364
|
+
var keep = {}; // 未落库改动的值(key=data-id)
|
|
365
|
+
var activeKey = null, caret = null;
|
|
366
|
+
list.querySelectorAll('[data-note-input]').forEach(function (ta) {
|
|
367
|
+
var id = ta.getAttribute('data-id');
|
|
368
|
+
if (!id) return; // 草稿的存活走 draftByHash,不进 keep
|
|
369
|
+
var n = noteById(id);
|
|
370
|
+
if (n && ta.value !== n.content) keep[id] = ta.value;
|
|
371
|
+
if (document.activeElement === ta) { activeKey = id; caret = ta.selectionStart; }
|
|
372
|
+
});
|
|
373
|
+
var hash = hashAt(path);
|
|
374
|
+
var notes = notesFor(hash);
|
|
375
|
+
var draftValue = draftByHash.hasOwnProperty(hash) ? draftByHash[hash] : null;
|
|
376
|
+
var count = notes.length + (draftValue !== null ? 1 : 0);
|
|
377
|
+
var html = '<div class="nside-head"><p class="nside-title">' + POP_I18N.t('secNotes', count) + '</p>' +
|
|
378
|
+
'<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>';
|
|
379
|
+
if (noteErrorMsg) html += '<p class="nside-err">' + escapeHtml(noteErrorMsg) + '</p>';
|
|
380
|
+
if (!notes.length && draftValue === null) html += '<p class="nside-empty">' + escapeHtml(POP_I18N.t('noteEmpty')) + '</p>';
|
|
381
|
+
html += notes.map(function (n) {
|
|
382
|
+
var v = keep.hasOwnProperty(n.id) ? keep[n.id] : n.content;
|
|
383
|
+
return '<div class="nside-item">' +
|
|
384
|
+
'<div class="nside-meta"><span class="note-time">' + escapeHtml(fmtNoteTime(n.createdAt)) + '</span>' +
|
|
385
|
+
'<span class="nside-acts">' +
|
|
386
|
+
'<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>' +
|
|
387
|
+
'</span></div>' +
|
|
388
|
+
'<textarea class="nside-input" data-note-input data-id="' + n.id + '">' + escapeHtml(v) + '</textarea></div>';
|
|
389
|
+
}).join('');
|
|
390
|
+
if (draftValue !== null) html += '<div class="nside-item"><textarea class="nside-input" data-note-input data-hash="' + escapeHtml(hash) + '">' + escapeHtml(draftValue) + '</textarea></div>';
|
|
391
|
+
list.innerHTML = html;
|
|
392
|
+
list.querySelectorAll('[data-note-input]').forEach(function (ta) {
|
|
393
|
+
autoGrow(ta);
|
|
394
|
+
var k = ta.getAttribute('data-id');
|
|
395
|
+
if (activeKey === k) {
|
|
396
|
+
ta.focus();
|
|
397
|
+
try { ta.setSelectionRange(caret, caret); } catch (e) { /* 类型输入框无 selection */ }
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** textarea 高度自适应内容(无边框伪装成正文的代价:得自己长个) */
|
|
403
|
+
function autoGrow(ta) {
|
|
404
|
+
if (window.CSS && CSS.supports && CSS.supports('field-sizing', 'content')) return; // 原生自适应,JS 别插手
|
|
405
|
+
ta.style.height = 'auto';
|
|
406
|
+
ta.style.height = ta.scrollHeight + 'px';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function updateNoteCount() {
|
|
410
|
+
var list = document.getElementById('note-list');
|
|
411
|
+
if (!list) return;
|
|
412
|
+
var n = notesFor(hashAt(path)).length + list.querySelectorAll('[data-note-input]:not([data-id])').length;
|
|
413
|
+
var el = list.querySelector('.nside-title');
|
|
414
|
+
if (el) el.textContent = POP_I18N.t('secNotes', n);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function postNote(body) {
|
|
418
|
+
return fetch('/api/notes', {
|
|
419
|
+
method: 'POST',
|
|
420
|
+
headers: { 'content-type': 'application/json' },
|
|
421
|
+
body: JSON.stringify(body),
|
|
422
|
+
}).then(function (r) {
|
|
423
|
+
return r.json().catch(function () { return null; }).then(function (j) {
|
|
424
|
+
if (!r.ok) {
|
|
425
|
+
var msg = j && j.error ? j.error : 'HTTP ' + r.status;
|
|
426
|
+
// 裸 404(非 JSON 响应体)= 服务器没有这条路由:进程还在跑旧构建
|
|
427
|
+
// (前端静态文件即时读盘是新的,web.ts 服务器代码是冷的——重启 practi web 即愈)
|
|
428
|
+
if (!j && r.status === 404) msg += ' — ' + POP_I18N.t('noteServerStale');
|
|
429
|
+
throw new Error(msg);
|
|
430
|
+
}
|
|
431
|
+
return j;
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** 所有笔记 POST 串行过同一队列:失焦保存与删除并发时不会互相覆盖 */
|
|
437
|
+
var noteQueue = Promise.resolve();
|
|
438
|
+
function queueNoteOp(fn) {
|
|
439
|
+
var p = noteQueue.then(fn, fn);
|
|
440
|
+
noteQueue = p.catch(function () {});
|
|
441
|
+
return p;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function noteById(id) {
|
|
445
|
+
var arr = notesFor(hashAt(path));
|
|
446
|
+
for (var i = 0; i < arr.length; i++) if (arr[i].id === id) return arr[i];
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function noteFail(e) {
|
|
451
|
+
noteErrorMsg = String(e && e.message ? e.message : e);
|
|
452
|
+
renderNoteSide();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function deleteNoteById(id, itemNode) {
|
|
456
|
+
queueNoteOp(function () {
|
|
457
|
+
return postNote({ op: 'delete', id: id }).then(function () {
|
|
458
|
+
Object.keys(notesByHash).forEach(function (k) {
|
|
459
|
+
notesByHash[k] = notesByHash[k].filter(function (x) { return x.id !== id; });
|
|
460
|
+
});
|
|
461
|
+
if (itemNode && itemNode.parentNode) itemNode.parentNode.removeChild(itemNode);
|
|
462
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标计数更新
|
|
463
|
+
updateNoteCount();
|
|
464
|
+
}).catch(function (e) { noteFail(e); });
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
277
469
|
function loopNote(node) {
|
|
278
470
|
if (node.loop && node.loop.mode === 'count') return POP_I18N.t('repeatX', node.loop.count);
|
|
279
471
|
if (node.loop && node.loop.mode === 'until') return POP_I18N.t('repeatUntil', node.loop.until);
|
|
@@ -292,6 +484,125 @@ function proseHtml(node) {
|
|
|
292
484
|
return sectionWrap(proseLite(content, node));
|
|
293
485
|
}
|
|
294
486
|
|
|
487
|
+
// ── 围栏代码块增强:highlight.js 按语言高亮(未标注自动探测,relevance>=3 才信——
|
|
488
|
+
// 与 hub PopCodeBlock / D:/Dev/practi 桌面端同款逻辑),外包深色卡:
|
|
489
|
+
// 顶栏=语言标签 + 复制按钮(委托 data-code-copy)。hljs 缺库(DIY 覆盖前端)时跳过。
|
|
490
|
+
function enhanceCodeBlocks(root) {
|
|
491
|
+
if (typeof hljs === 'undefined') return;
|
|
492
|
+
var codes = root.querySelectorAll('.prose pre > code, .prose pre');
|
|
493
|
+
for (var i = 0; i < codes.length; i++) {
|
|
494
|
+
var el = codes[i];
|
|
495
|
+
var pre = el.tagName === 'PRE' ? el : el.parentElement;
|
|
496
|
+
if (!pre || pre.parentElement && pre.parentElement.classList.contains('codeblock')) continue;
|
|
497
|
+
if (el.tagName !== 'CODE') {
|
|
498
|
+
// proseLite 回落路径:<pre> 直接包文本——包一层 code 再走同一条路
|
|
499
|
+
var codeEl = document.createElement('code');
|
|
500
|
+
codeEl.textContent = pre.textContent;
|
|
501
|
+
pre.textContent = '';
|
|
502
|
+
pre.appendChild(codeEl);
|
|
503
|
+
el = codeEl;
|
|
504
|
+
}
|
|
505
|
+
var raw = el.textContent.replace(/^[`]{3}[\w-]*\r?\n/, '').replace(/\r?\n[`]{3}$/, '');
|
|
506
|
+
var m = /language-([\w-]+)/.exec(el.className);
|
|
507
|
+
var lang = m && hljs.getLanguage(m[1]) ? m[1] : '';
|
|
508
|
+
var detected = '';
|
|
509
|
+
var html;
|
|
510
|
+
if (lang) {
|
|
511
|
+
html = hljs.highlight(raw, { language: lang }).value;
|
|
512
|
+
detected = lang;
|
|
513
|
+
} else {
|
|
514
|
+
var r = hljs.highlightAuto(raw, AUTO_LANGS);
|
|
515
|
+
html = r.value;
|
|
516
|
+
detected = r.relevance >= 3 ? (r.language || '') : '';
|
|
517
|
+
}
|
|
518
|
+
var wrap = document.createElement('div');
|
|
519
|
+
wrap.className = 'codeblock';
|
|
520
|
+
var head = document.createElement('div');
|
|
521
|
+
head.className = 'codeblock-head';
|
|
522
|
+
head.innerHTML = '<span></span><button type="button" class="codeblock-copy" data-code-copy="' +
|
|
523
|
+
encodeURIComponent(raw) + '" aria-label="copy code">' + COPY_SVG + '</button>';
|
|
524
|
+
head.querySelector('span').textContent = detected;
|
|
525
|
+
var newPre = document.createElement('pre');
|
|
526
|
+
var newCode = document.createElement('code');
|
|
527
|
+
newCode.className = 'hljs';
|
|
528
|
+
newCode.innerHTML = html;
|
|
529
|
+
newPre.appendChild(newCode);
|
|
530
|
+
wrap.appendChild(head);
|
|
531
|
+
wrap.appendChild(newPre);
|
|
532
|
+
pre.parentElement.replaceChild(wrap, pre);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function flashCopied(btn) {
|
|
537
|
+
if (btn.getAttribute('data-flashing') || btn.textContent.trim() !== '') return;
|
|
538
|
+
btn.setAttribute('data-flashing', '1');
|
|
539
|
+
btn.innerHTML = CHECK_SVG;
|
|
540
|
+
setTimeout(function () {
|
|
541
|
+
btn.innerHTML = COPY_SVG;
|
|
542
|
+
btn.removeAttribute('data-flashing');
|
|
543
|
+
}, 1500);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 自动探测语言子集(与 hub PopCodeBlock 的 AUTO_LANGS 同步):css/html/xml/markdown
|
|
547
|
+
// 极易误报(winget 命令被判成 css),排除后未命中就退回无标签
|
|
548
|
+
var AUTO_LANGS = ['bash', 'shell', 'powershell', 'json', 'javascript', 'typescript', 'python', 'sql', 'yaml', 'ini', 'rust', 'go', 'java', 'c', 'cpp', 'csharp', 'diff', 'makefile']
|
|
549
|
+
.filter(function (l) { return hljs.getLanguage(l); });
|
|
550
|
+
|
|
551
|
+
var COPY_SVG = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
|
552
|
+
var CHECK_SVG = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
553
|
+
|
|
554
|
+
// 轻量 toast(hub notify 同观感):底部居中深色胶囊,1.6s 自动消退
|
|
555
|
+
function showToast(text) {
|
|
556
|
+
var el = document.getElementById('web-toast');
|
|
557
|
+
if (!el) {
|
|
558
|
+
el = document.createElement('div');
|
|
559
|
+
el.id = 'web-toast';
|
|
560
|
+
el.className = 'web-toast';
|
|
561
|
+
document.body.appendChild(el);
|
|
562
|
+
}
|
|
563
|
+
el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg><span></span>';
|
|
564
|
+
el.querySelector('span').textContent = text;
|
|
565
|
+
el.classList.add('show');
|
|
566
|
+
clearTimeout(el.__timer);
|
|
567
|
+
el.__timer = setTimeout(function () { el.classList.remove('show'); }, 1600);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── 内容列顶栏(hub 同款):返回(历史 back,兜底回目录)+ 文档信息弹窗。
|
|
571
|
+
// 本地文档没有状态/上传者/认领/浏览这些 hub 数据,弹窗只列节点数与根哈希 ──
|
|
572
|
+
var FP_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 16h.01"/><path d="M21.8 16c.2-2 .131-5.354 0-6"/><path d="M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2"/><path d="M8.65 22c.21-.66.45-1.32.57-2"/><path d="M9 6.8a6 6 0 0 1 9 5.2v2"/></svg>';
|
|
573
|
+
var INFO_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>';
|
|
574
|
+
var BACK_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>';
|
|
575
|
+
|
|
576
|
+
function topbarHtml() {
|
|
577
|
+
return '<div class="detail-topbar">' +
|
|
578
|
+
'<button type="button" class="topbar-btn" data-top="back">' + BACK_SVG + '<span>' + POP_I18N.t('back') + '</span></button>' +
|
|
579
|
+
'<button type="button" class="topbar-btn topbar-icon" data-top="info" aria-label="' + POP_I18N.t('metaInfo') + '" title="' + POP_I18N.t('metaInfo') + '">' + INFO_SVG + '</button>' +
|
|
580
|
+
'</div>';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function openInfoDialog() {
|
|
584
|
+
closeInfoDialog();
|
|
585
|
+
var overlay = document.createElement('div');
|
|
586
|
+
overlay.id = 'info-overlay';
|
|
587
|
+
overlay.innerHTML =
|
|
588
|
+
'<div class="info-card" role="dialog" aria-label="' + POP_I18N.t('metaInfo') + '">' +
|
|
589
|
+
'<div class="info-head"><div class="info-title">' + POP_I18N.t('metaInfo') + '</div>' +
|
|
590
|
+
'<button type="button" class="info-close" aria-label="close">' + INFO_SVG + '</button></div>' +
|
|
591
|
+
'<div class="info-row"><span class="info-key">' + POP_I18N.t('nodeCount', countNodes(doc)) + '</span></div>' +
|
|
592
|
+
'<button type="button" class="info-hash-btn" data-code-copy="' + encodeURIComponent(HASH) + '" title="' + POP_I18N.t('copied') + '">' +
|
|
593
|
+
FP_SVG + '<span class="info-hash">' + escapeHtml(HASH) + '</span>' + COPY_SVG + '</button>' +
|
|
594
|
+
'</div>';
|
|
595
|
+
overlay.addEventListener('click', function (e) {
|
|
596
|
+
if (e.target === overlay) closeInfoDialog();
|
|
597
|
+
});
|
|
598
|
+
document.body.appendChild(overlay);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function closeInfoDialog() {
|
|
602
|
+
var el = document.getElementById('info-overlay');
|
|
603
|
+
if (el) el.remove();
|
|
604
|
+
}
|
|
605
|
+
|
|
295
606
|
function sectionWrap(body) {
|
|
296
607
|
return '<div class="section-sm"><div class="label">' + POP_I18N.t('secContent') + '</div><div class="prose">' + body + '</div></div>';
|
|
297
608
|
}
|
|
@@ -309,6 +620,11 @@ function renderMarkdown(content, node) {
|
|
|
309
620
|
// 只转义 < 与 &(足以挡内联 HTML 注入);> 保留——行首 "> " 是块引用语法
|
|
310
621
|
src = src.replace(/&/g, '&').replace(/</g, '<');
|
|
311
622
|
var html = marked.parse(src, { gfm: true, breaks: false, async: false });
|
|
623
|
+
// marked 在 code 段内会把预转义产生的 & 再转一次(< 变 &lt;,页面显示字面
|
|
624
|
+
// <)——code 段内 & 还原回 &:预转义的 < 复原、原文的字面 & 仍只剩一层
|
|
625
|
+
html = html.replace(/(<code[^>]*>)([\s\S]*?)(<\/code>)/g, function (m0, open, body, close) {
|
|
626
|
+
return open + body.replace(/&/g, '&') + close;
|
|
627
|
+
});
|
|
312
628
|
return html.replace(/<a href="mailto:[^"]*">([^<]*)<\/a>/g, '$1');
|
|
313
629
|
}
|
|
314
630
|
|
|
@@ -463,6 +779,31 @@ function navHtml() {
|
|
|
463
779
|
// ── 事件(委托:innerHTML 重渲染不需要重复绑监听) ──
|
|
464
780
|
|
|
465
781
|
document.addEventListener('click', function (e) {
|
|
782
|
+
var top = e.target.closest('[data-top]');
|
|
783
|
+
if (top) {
|
|
784
|
+
if (top.getAttribute('data-top') === 'back') {
|
|
785
|
+
if (history.length > 1) history.back();
|
|
786
|
+
else location.href = '/';
|
|
787
|
+
} else if (top.getAttribute('data-top') === 'info') {
|
|
788
|
+
openInfoDialog();
|
|
789
|
+
}
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (e.target.closest('#info-overlay .info-close')) {
|
|
793
|
+
closeInfoDialog();
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
var copyBtn = e.target.closest('[data-code-copy]');
|
|
797
|
+
if (copyBtn) {
|
|
798
|
+
var text = decodeURIComponent(copyBtn.getAttribute('data-code-copy'));
|
|
799
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
800
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
801
|
+
flashCopied(copyBtn);
|
|
802
|
+
showToast(POP_I18N.t('copied'));
|
|
803
|
+
}, function () {});
|
|
804
|
+
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
466
807
|
var tab = e.target.closest('[data-view]');
|
|
467
808
|
if (tab) {
|
|
468
809
|
view = tab.getAttribute('data-view');
|
|
@@ -480,6 +821,15 @@ document.addEventListener('click', function (e) {
|
|
|
480
821
|
goTo(path.concat([Number(jump.getAttribute('data-idx'))]));
|
|
481
822
|
return;
|
|
482
823
|
}
|
|
824
|
+
var nbadge = e.target.closest('[data-note-badge]');
|
|
825
|
+
if (nbadge) {
|
|
826
|
+
// 徽标语义=看这条节点的笔记:跳转交给下面的 data-path,这里负责展开右栏
|
|
827
|
+
if (!notesOpen) {
|
|
828
|
+
notesOpen = true;
|
|
829
|
+
notesTouched = true;
|
|
830
|
+
renderNoteSide();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
483
833
|
var jump = e.target.closest('[data-path]');
|
|
484
834
|
if (jump) {
|
|
485
835
|
e.preventDefault();
|
|
@@ -490,6 +840,37 @@ document.addEventListener('click', function (e) {
|
|
|
490
840
|
var act = e.target.closest('[data-act]');
|
|
491
841
|
if (!act) return;
|
|
492
842
|
var a = act.getAttribute('data-act');
|
|
843
|
+
if (a === 'note-new') {
|
|
844
|
+
// 新建=在列表末尾补一个草稿框(已有草稿就聚焦它);失焦有内容才落库
|
|
845
|
+
var list = document.getElementById('note-list');
|
|
846
|
+
if (!list) return;
|
|
847
|
+
var existing = list.querySelector('[data-note-input]:not([data-id])');
|
|
848
|
+
if (existing) { existing.focus(); return; }
|
|
849
|
+
var item = document.createElement('div');
|
|
850
|
+
item.className = 'nside-item';
|
|
851
|
+
item.innerHTML = '<textarea class="nside-input" data-note-input></textarea>';
|
|
852
|
+
list.appendChild(item);
|
|
853
|
+
var ta = item.querySelector('textarea');
|
|
854
|
+
ta.focus();
|
|
855
|
+
updateNoteCount();
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (a === 'note-del') {
|
|
859
|
+
// 写写相碰:进串行队列等当前失焦保存落库后再删,防并发读改写互相覆盖
|
|
860
|
+
var id = act.getAttribute('data-id');
|
|
861
|
+
var item = act.closest('.nside-item');
|
|
862
|
+
queueNoteOp(function () {
|
|
863
|
+
return postNote({ op: 'delete', id: id }).then(function () {
|
|
864
|
+
Object.keys(notesByHash).forEach(function (k) {
|
|
865
|
+
notesByHash[k] = notesByHash[k].filter(function (x) { return x.id !== id; });
|
|
866
|
+
});
|
|
867
|
+
if (item && item.parentNode) item.parentNode.removeChild(item);
|
|
868
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标计数更新
|
|
869
|
+
updateNoteCount();
|
|
870
|
+
}).catch(function (e) { noteFail(e); });
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
493
874
|
if (a === 'next') {
|
|
494
875
|
var n = nextOf(path);
|
|
495
876
|
if (n) goTo(n);
|
|
@@ -503,6 +884,79 @@ document.addEventListener('click', function (e) {
|
|
|
503
884
|
|
|
504
885
|
// ── 启动 ──
|
|
505
886
|
|
|
887
|
+
// 右栏键盘(textarea 常驻可编辑):Ctrl/Cmd+Enter 立即失焦保存,Esc 还原原文/放弃草稿
|
|
888
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
889
|
+
var list = document.getElementById('note-list');
|
|
890
|
+
if (!list) return;
|
|
891
|
+
list.addEventListener('keydown', function (e) {
|
|
892
|
+
var ta = e.target;
|
|
893
|
+
if (!ta.matches || !ta.matches('[data-note-input]')) return;
|
|
894
|
+
if (e.key === 'Escape') {
|
|
895
|
+
var id = ta.getAttribute('data-id');
|
|
896
|
+
if (id) {
|
|
897
|
+
var n = noteById(id);
|
|
898
|
+
if (n) ta.value = n.content; // 还原原文
|
|
899
|
+
} else {
|
|
900
|
+
ta.value = ''; // 草稿:清空
|
|
901
|
+
}
|
|
902
|
+
autoGrow(ta);
|
|
903
|
+
ta.blur(); // focusout 委托统一收口(未改动/空草稿都不会落库)
|
|
904
|
+
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
905
|
+
ta.blur();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
// 输入即长高
|
|
909
|
+
list.addEventListener('input', function (e) {
|
|
910
|
+
if (e.target.matches && e.target.matches('[data-note-input]')) autoGrow(e.target);
|
|
911
|
+
});
|
|
912
|
+
// 失焦即存:blur 不冒泡、focusout 冒泡,委托在列表上(innerHTML 重建不丢监听)
|
|
913
|
+
list.addEventListener('focusout', function (e) {
|
|
914
|
+
var ta = e.target;
|
|
915
|
+
if (!ta.matches || !ta.matches('[data-note-input]')) return;
|
|
916
|
+
var id = ta.getAttribute('data-id');
|
|
917
|
+
if (id) {
|
|
918
|
+
var n = noteById(id);
|
|
919
|
+
if (!n) return;
|
|
920
|
+
if (ta.value === n.content) return; // 未改动
|
|
921
|
+
if (!ta.value.trim()) { ta.value = n.content; autoGrow(ta); return; } // 清空=还原原文
|
|
922
|
+
var content = ta.value;
|
|
923
|
+
queueNoteOp(function () {
|
|
924
|
+
return postNote({ op: 'edit', id: id, content: content }).then(function (j) {
|
|
925
|
+
n.content = content;
|
|
926
|
+
if (j.note && j.note.updatedAt) n.updatedAt = j.note.updatedAt;
|
|
927
|
+
}).catch(function (err) { noteFail(err); });
|
|
928
|
+
});
|
|
929
|
+
} else {
|
|
930
|
+
var hash = ta.getAttribute('data-hash') || hashAt(path);
|
|
931
|
+
if (!ta.value.trim()) { // 空草稿=放弃
|
|
932
|
+
var item = ta.closest('.nside-item');
|
|
933
|
+
if (item) item.remove();
|
|
934
|
+
delete draftByHash[hash];
|
|
935
|
+
updateNoteCount();
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
draftByHash[hash] = ta.value; // 先记着:POST 在途时重渲染不丢
|
|
939
|
+
var content2 = ta.value;
|
|
940
|
+
queueNoteOp(function () {
|
|
941
|
+
return postNote({ op: 'add', hash: hash, content: content2 }).then(function (j) {
|
|
942
|
+
var note = j.note;
|
|
943
|
+
if (note) (notesByHash[hash] = notesByHash[hash] || []).push(note);
|
|
944
|
+
delete draftByHash[hash];
|
|
945
|
+
ta.setAttribute('data-id', note.id);
|
|
946
|
+
document.getElementById('side').innerHTML = sideHtml(); // 徽标 0→N
|
|
947
|
+
updateNoteCount();
|
|
948
|
+
}).catch(function (err) { noteFail(err); });
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
// 右栏舌片事件接线(元素静态在 HTML;提示文案等 lang.js 就绪,故挂 DOMContentLoaded)
|
|
955
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
956
|
+
wireNoteToggle();
|
|
957
|
+
applyNotesOpen();
|
|
958
|
+
});
|
|
959
|
+
|
|
506
960
|
// 语言切换:侧栏大纲与内容区一并重绘
|
|
507
961
|
POP_I18N.onChange(function () {
|
|
508
962
|
if (doc) document.getElementById('side').innerHTML = sideHtml();
|
|
@@ -511,6 +965,45 @@ POP_I18N.onChange(function () {
|
|
|
511
965
|
|
|
512
966
|
var HASH = decodeURIComponent((location.pathname.split('/pop/')[1] || '').replace(/\.json$/, ''));
|
|
513
967
|
|
|
968
|
+
// nodeIndex(哈希→路径)逆成 hashByPath(路径→哈希):笔记按哈希钉,侧栏/节点卡按路径定位
|
|
969
|
+
function buildHashByPath() {
|
|
970
|
+
hashByPath = {};
|
|
971
|
+
if (!nodeIndex) return;
|
|
972
|
+
for (var h in nodeIndex) hashByPath[nodeIndex[h].join(',')] = h;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** 笔记到货后的重绘:只动左右两栏(侧栏徽标 + 右栏),内容区不碰——
|
|
976
|
+
* 笔记不进内容区,动它只会带来无谓的重绘与闪烁。
|
|
977
|
+
* 侧栏是整段 innerHTML 重建,重建后必须补回 tab 选中态与当前节点高亮 */
|
|
978
|
+
function onNotesArrived() {
|
|
979
|
+
if (!doc) return;
|
|
980
|
+
document.getElementById('side').innerHTML = sideHtml();
|
|
981
|
+
updateTabs();
|
|
982
|
+
markSide();
|
|
983
|
+
renderNoteSide();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function loadNotesData() {
|
|
987
|
+
fetch('/api/notes?ref=' + encodeURIComponent(HASH))
|
|
988
|
+
.then(function (r) { return r.ok ? r.json() : { notes: [] }; })
|
|
989
|
+
.then(function (j) {
|
|
990
|
+
notesByHash = {};
|
|
991
|
+
(j.notes || []).forEach(function (n) {
|
|
992
|
+
(notesByHash[n.hash] = notesByHash[n.hash] || []).push(n);
|
|
993
|
+
});
|
|
994
|
+
// 默认折叠;首载本文档有笔记则自动展开(用户手动开合过就不再自动介入)
|
|
995
|
+
if (!notesTouched && (j.notes || []).length) notesOpen = true;
|
|
996
|
+
onNotesArrived();
|
|
997
|
+
})
|
|
998
|
+
.catch(function () { /* 笔记是锦上添花:拉取失败静默,文档照常显示 */ });
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// 另一终端写笔记(CLI/别的页面)→ 服务器发 notes 轻事件 → 只重拉笔记,绝不整刷。
|
|
1002
|
+
// reconcile 会保留未落库改动与聚焦位,编辑中收到推送也不丢字
|
|
1003
|
+
document.addEventListener('practi:notes', function () {
|
|
1004
|
+
loadNotesData();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
514
1007
|
fetch('/doc/' + encodeURIComponent(HASH) + '.json')
|
|
515
1008
|
.then(function (r) {
|
|
516
1009
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
@@ -520,9 +1013,11 @@ fetch('/doc/' + encodeURIComponent(HASH) + '.json')
|
|
|
520
1013
|
// 数据窗=加法演进:树字段在顶层,nodeIndex 是后加的兄弟键(老响应没有它 → 哈希兜底)
|
|
521
1014
|
doc = d;
|
|
522
1015
|
nodeIndex = d.nodeIndex || null;
|
|
1016
|
+
buildHashByPath();
|
|
523
1017
|
document.title = doc.name + ' — practi';
|
|
524
1018
|
document.getElementById('side').innerHTML = sideHtml();
|
|
525
1019
|
render();
|
|
1020
|
+
loadNotesData();
|
|
526
1021
|
})
|
|
527
1022
|
.catch(function (err) {
|
|
528
1023
|
document.getElementById('app').innerHTML =
|