@arshdelight/practi 0.9.5 → 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 +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 +96 -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 +49 -14
- package/web-default/app.js +2 -2
- package/web-default/detail.html +5 -1
- package/web-default/detail.js +352 -12
- package/web-default/lang.js +16 -4
- package/web-default/style.css +59 -7
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() {
|
|
@@ -65,15 +86,16 @@ function render() {
|
|
|
65
86
|
app.innerHTML = (view === 'wizard' ? nodeHtml(node) : jsonSectionHtml()) + navHtml();
|
|
66
87
|
updateTabs();
|
|
67
88
|
markSide();
|
|
89
|
+
renderNoteSide(); // 右栏跟人走:每次导航换节点都重渲染(草稿保真在 renderNoteSide 内部处理)
|
|
68
90
|
// 向导视图回顶(瞬移,既有行为);JSON 视图平滑滚到当前高亮块首行。
|
|
69
|
-
//
|
|
70
|
-
//
|
|
91
|
+
// 滚动容器是中间内容列自身(应用壳布局:整页不滚),原生 smooth 滚动天然可中断:
|
|
92
|
+
// 快速连点不同节点时,新目标的滚动指令会取消进行中的动画,从当前位置转向新目标
|
|
71
93
|
if (view === 'wizard') {
|
|
72
|
-
|
|
94
|
+
app.scrollTop = 0;
|
|
73
95
|
} else {
|
|
74
96
|
var hl = document.querySelector('.json-pre .ln.hl');
|
|
75
97
|
if (hl) hl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
76
|
-
else
|
|
98
|
+
else app.scrollTo({ top: 0, behavior: 'smooth' });
|
|
77
99
|
}
|
|
78
100
|
}
|
|
79
101
|
|
|
@@ -172,9 +194,10 @@ function jsonHtml(value, markChild) {
|
|
|
172
194
|
// ── 左侧大纲(学 hub /pop 详情侧栏):根的直接孩子成节(1、2…)递归(1.1),
|
|
173
195
|
// 与 fromRef 的 #编号同一坐标系;点击 goTo 跳转,当前节点高亮 ──
|
|
174
196
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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);
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
/** 图标 tab(lucide list-todo/list-ordered/list-tree):图标即按钮,名字进 tooltip 与 aria-label */
|
|
@@ -185,21 +208,21 @@ function tabIconBtn(view, tipKey, inner) {
|
|
|
185
208
|
}
|
|
186
209
|
|
|
187
210
|
function sideHtml() {
|
|
188
|
-
var total =
|
|
211
|
+
var total = countNodes(doc);
|
|
189
212
|
// 视图三 tab 钉在侧栏最顶:向导正文 / StandardView JSON / document JSON(内容区随之换形态)
|
|
190
213
|
var html = '<div class="side-tabs">' +
|
|
191
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"/>') +
|
|
192
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"/>') +
|
|
193
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"/>') +
|
|
194
217
|
'</div>' +
|
|
195
|
-
'<p class="side-count">' + POP_I18N.t('
|
|
196
|
-
'<a href="#" class="side-item side-root" data-path="">' + escapeHtml(doc.name) + '</a>';
|
|
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>';
|
|
197
220
|
(function walkSide(n, prefix, depth, p) {
|
|
198
221
|
(n.children || []).forEach(function (c, i) {
|
|
199
222
|
var num = prefix + (i + 1);
|
|
200
223
|
var cp = p.concat([i]);
|
|
201
224
|
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>';
|
|
225
|
+
'<span class="side-num">' + num + '</span>' + escapeHtml(c.name) + noteBadge(cp.join(','), notesFor(hashAt(cp)).length) + '</a>';
|
|
203
226
|
if (c.children && c.children.length) walkSide(c, num + '.', depth + 1, cp);
|
|
204
227
|
});
|
|
205
228
|
})(doc, '', 0, []);
|
|
@@ -270,10 +293,173 @@ function nodeHtml(node) {
|
|
|
270
293
|
html += proseHtml(node);
|
|
271
294
|
if (node.type === 'action') html += outputsHtml(node) + attHtml(node);
|
|
272
295
|
html += childrenHtml(node) + choiceHtml(node) + setHtml(node);
|
|
273
|
-
// revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
|
|
296
|
+
// 笔记只住右侧栏(不进内容区):revisions 已挪到左侧大纲底部(全文档汇总);refines 同处
|
|
274
297
|
return html;
|
|
275
298
|
}
|
|
276
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
|
+
|
|
277
463
|
function loopNote(node) {
|
|
278
464
|
if (node.loop && node.loop.mode === 'count') return POP_I18N.t('repeatX', node.loop.count);
|
|
279
465
|
if (node.loop && node.loop.mode === 'until') return POP_I18N.t('repeatUntil', node.loop.until);
|
|
@@ -480,6 +666,15 @@ document.addEventListener('click', function (e) {
|
|
|
480
666
|
goTo(path.concat([Number(jump.getAttribute('data-idx'))]));
|
|
481
667
|
return;
|
|
482
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
|
+
}
|
|
483
678
|
var jump = e.target.closest('[data-path]');
|
|
484
679
|
if (jump) {
|
|
485
680
|
e.preventDefault();
|
|
@@ -490,6 +685,37 @@ document.addEventListener('click', function (e) {
|
|
|
490
685
|
var act = e.target.closest('[data-act]');
|
|
491
686
|
if (!act) return;
|
|
492
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
|
+
}
|
|
493
719
|
if (a === 'next') {
|
|
494
720
|
var n = nextOf(path);
|
|
495
721
|
if (n) goTo(n);
|
|
@@ -503,6 +729,79 @@ document.addEventListener('click', function (e) {
|
|
|
503
729
|
|
|
504
730
|
// ── 启动 ──
|
|
505
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
|
+
|
|
506
805
|
// 语言切换:侧栏大纲与内容区一并重绘
|
|
507
806
|
POP_I18N.onChange(function () {
|
|
508
807
|
if (doc) document.getElementById('side').innerHTML = sideHtml();
|
|
@@ -511,6 +810,45 @@ POP_I18N.onChange(function () {
|
|
|
511
810
|
|
|
512
811
|
var HASH = decodeURIComponent((location.pathname.split('/pop/')[1] || '').replace(/\.json$/, ''));
|
|
513
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
|
+
|
|
514
852
|
fetch('/doc/' + encodeURIComponent(HASH) + '.json')
|
|
515
853
|
.then(function (r) {
|
|
516
854
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
@@ -520,9 +858,11 @@ fetch('/doc/' + encodeURIComponent(HASH) + '.json')
|
|
|
520
858
|
// 数据窗=加法演进:树字段在顶层,nodeIndex 是后加的兄弟键(老响应没有它 → 哈希兜底)
|
|
521
859
|
doc = d;
|
|
522
860
|
nodeIndex = d.nodeIndex || null;
|
|
861
|
+
buildHashByPath();
|
|
523
862
|
document.title = doc.name + ' — practi';
|
|
524
863
|
document.getElementById('side').innerHTML = sideHtml();
|
|
525
864
|
render();
|
|
865
|
+
loadNotesData();
|
|
526
866
|
})
|
|
527
867
|
.catch(function (err) {
|
|
528
868
|
document.getElementById('app').innerHTML =
|
package/web-default/lang.js
CHANGED
|
@@ -15,7 +15,7 @@ var POP_I18N = (function () {
|
|
|
15
15
|
labelDocJson: '文档 JSON', labelSvJson: 'StandardView JSON',
|
|
16
16
|
// 目录页
|
|
17
17
|
pageTitle: '我的实践',
|
|
18
|
-
thDocument: '文档',
|
|
18
|
+
thDocument: '文档', thNodes: '节点', thAdded: '添加时间',
|
|
19
19
|
emptyLead: '还没有记录。写下第一手实践经验,成为一份 POP 文档——哈希可验证,永久保存。',
|
|
20
20
|
emptyHint: '用 <code>practi new</code> 创建',
|
|
21
21
|
countDocs: function (n) { return n + ' 篇文档'; },
|
|
@@ -23,7 +23,7 @@ var POP_I18N = (function () {
|
|
|
23
23
|
pagerPrev: '上一页', pagerNext: '下一页',
|
|
24
24
|
loadFail: '加载失败(/api/directs)',
|
|
25
25
|
// 详情页
|
|
26
|
-
|
|
26
|
+
nodeCount: function (n) { return n + ' 个节点'; },
|
|
27
27
|
loading: '加载中…',
|
|
28
28
|
opPar: '以下任务并行执行——顺序无关',
|
|
29
29
|
opChoice: '选择一个分支继续',
|
|
@@ -41,6 +41,12 @@ var POP_I18N = (function () {
|
|
|
41
41
|
endPractice: '实践完结',
|
|
42
42
|
revisions: function (n) { return '修订 (' + n + ')'; },
|
|
43
43
|
refines: function (n) { return '改进 (' + n + ')'; },
|
|
44
|
+
secNotes: function (n) { return '笔记 (' + n + ')'; },
|
|
45
|
+
notePanel: '笔记',
|
|
46
|
+
noteNew: '新建',
|
|
47
|
+
noteDelete: '删除',
|
|
48
|
+
noteEmpty: '这条节点还没有笔记,点上方「新建」写一条。',
|
|
49
|
+
noteServerStale: '服务器像是旧构建(前端新、服务器旧)——重启 practi web 再试',
|
|
44
50
|
docFail: function (s) { return '文档加载失败(' + s + ')'; },
|
|
45
51
|
backMine: '← 我的实践'
|
|
46
52
|
},
|
|
@@ -49,14 +55,14 @@ var POP_I18N = (function () {
|
|
|
49
55
|
tabWizard: 'Todo', tabSv: 'StandardView', tabDoc: 'Document',
|
|
50
56
|
labelDocJson: 'Document JSON', labelSvJson: 'StandardView JSON',
|
|
51
57
|
pageTitle: 'My practices',
|
|
52
|
-
thDocument: 'Document',
|
|
58
|
+
thDocument: 'Document', thNodes: 'Nodes', thAdded: 'Added',
|
|
53
59
|
emptyLead: 'Nothing recorded yet. Write first-hand experience as a POP document — verifiable by hash, forever.',
|
|
54
60
|
emptyHint: 'create one with <code>practi new</code>',
|
|
55
61
|
countDocs: function (n) { return n + (n === 1 ? ' document' : ' documents'); },
|
|
56
62
|
pageOf: function (a, b) { return 'page ' + a + ' of ' + b; },
|
|
57
63
|
pagerPrev: 'Previous', pagerNext: 'Next',
|
|
58
64
|
loadFail: 'failed to load /api/directs',
|
|
59
|
-
|
|
65
|
+
nodeCount: function (n) { return n + (n === 1 ? ' node' : ' nodes'); },
|
|
60
66
|
loading: 'loading…',
|
|
61
67
|
opPar: 'the tasks below run in parallel — order does not matter',
|
|
62
68
|
opChoice: 'pick one branch to continue',
|
|
@@ -74,6 +80,12 @@ var POP_I18N = (function () {
|
|
|
74
80
|
endPractice: 'end of practice',
|
|
75
81
|
revisions: function (n) { return 'revisions (' + n + ')'; },
|
|
76
82
|
refines: function (n) { return 'refines (' + n + ')'; },
|
|
83
|
+
secNotes: function (n) { return 'notes (' + n + ')'; },
|
|
84
|
+
notePanel: 'Notes',
|
|
85
|
+
noteNew: 'New',
|
|
86
|
+
noteDelete: 'Delete',
|
|
87
|
+
noteEmpty: 'No notes on this node yet — click New above to write one.',
|
|
88
|
+
noteServerStale: 'the server looks like an older build — restart practi web and retry',
|
|
77
89
|
docFail: function (s) { return 'failed to load document (' + s + ')'; },
|
|
78
90
|
backMine: '← My practices'
|
|
79
91
|
}
|
package/web-default/style.css
CHANGED
|
@@ -47,13 +47,13 @@ a:hover { text-decoration: underline; }
|
|
|
47
47
|
fixed 布局是必须的:hash 是无空格长串,auto 布局下 max-width 失效会把表撑出横向滚动 */
|
|
48
48
|
.doc-table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
|
|
49
49
|
.doc-table th { padding: 8px 16px; font-size: 12px; font-weight: 500; color: var(--brand-50); text-align: left; white-space: nowrap; border-bottom: 1px solid var(--border); }
|
|
50
|
-
.doc-table th.col-
|
|
50
|
+
.doc-table th.col-nodes { width: 8ch; }
|
|
51
51
|
.doc-table th.col-added { width: 22ch; }
|
|
52
52
|
.doc-table td { padding: 12px 16px; vertical-align: middle; border-bottom: 1px solid var(--border-light); overflow: hidden; text-overflow: ellipsis; }
|
|
53
53
|
.doc-table tr:last-child td { border-bottom: none; }
|
|
54
54
|
.doc-name { font-weight: 500; color: var(--brand); }
|
|
55
55
|
.doc-desc { font-size: 12px; color: var(--brand-40); margin: 2px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
56
|
-
.doc-
|
|
56
|
+
.doc-nodes { font-size: 12px; color: var(--brand-40); font-variant-numeric: tabular-nums; }
|
|
57
57
|
.doc-date { font-size: 12px; color: var(--brand-40); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
58
58
|
.doc-date.derived { opacity: .6; }
|
|
59
59
|
.count-row { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; font-size: 14px; color: var(--brand-60); }
|
|
@@ -67,7 +67,8 @@ a:hover { text-decoration: underline; }
|
|
|
67
67
|
.empty code { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; }
|
|
68
68
|
|
|
69
69
|
/* ── 详情页(向导模式):一次一节点,op 决定呈现 ── */
|
|
70
|
-
|
|
70
|
+
/* 内容列=详情页唯一的滚动容器(应用壳布局);gutter 常驻防长文档切换时内容左右微移 */
|
|
71
|
+
.detail { max-width: 48rem; overflow-y: auto; scrollbar-gutter: stable; padding-bottom: 110px; }
|
|
71
72
|
.node-title { font-size: 22px; font-weight: 700; color: var(--brand); margin: 8px 0 4px; }
|
|
72
73
|
.node-desc { color: var(--brand-60); margin: 0 0 12px; }
|
|
73
74
|
.op-note { font-size: 13px; color: var(--brand-40); font-style: italic; margin: 0 0 8px; }
|
|
@@ -130,7 +131,9 @@ button.child-card:hover { border-color: var(--brand); color: var(--brand); }
|
|
|
130
131
|
.choice-card:hover { border-color: var(--brand); background: rgba(0, 51, 102, .04); }
|
|
131
132
|
/* 底部导航固定在视口底部:位置恒定,不随内容高度漂移;内行与正文列同宽对齐 */
|
|
132
133
|
/* 底部操作区:固定视口底悬浮于内容列上方,无分界线(仅极轻投影托起),行与正文列同宽 */
|
|
133
|
-
.nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; background: #fff; padding: 10px 24px; box-shadow: 0 -1px 8px rgba(0, 51, 102, .03); }
|
|
134
|
+
.nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; background: #fff; padding: 10px 24px; box-shadow: 0 -1px 8px rgba(0, 51, 102, .03); transition: right .25s ease; }
|
|
135
|
+
/* 笔记栏展开时操作区同步让位(与内容列居中一起平滑重排) */
|
|
136
|
+
body.note-open .nav { right: 18rem; }
|
|
134
137
|
.nav-row { max-width: 48rem; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
135
138
|
.btn { padding: 8px 16px; border: 1px solid #d1d5db; border-radius: 8px; background: #fff; color: var(--brand-60); font-size: 14px; cursor: pointer; text-decoration: none; display: inline-block; }
|
|
136
139
|
.btn:hover { background: var(--hover-bg); text-decoration: none; }
|
|
@@ -138,8 +141,10 @@ button.child-card:hover { border-color: var(--brand); color: var(--brand); }
|
|
|
138
141
|
.btn-primary:hover { background: #1f2937; }
|
|
139
142
|
.nav-hint { font-size: 13px; color: var(--brand-40); }
|
|
140
143
|
.finish { font-size: 13px; color: var(--brand-40); }
|
|
141
|
-
/* ──
|
|
142
|
-
.
|
|
144
|
+
/* ── 详情页外壳:应用壳布局——整页不滚(滚动只属于中间内容列),header 常驻、左右栏满高 ── */
|
|
145
|
+
body.detail-page { height: 100dvh; overflow: hidden; display: flex; flex-direction: column; }
|
|
146
|
+
body.detail-page > header { flex: none; }
|
|
147
|
+
.shell { display: flex; align-items: stretch; flex: 1; min-height: 0; position: relative; }
|
|
143
148
|
.shell main { flex: 1; min-width: 0; }
|
|
144
149
|
.side { display: none; }
|
|
145
150
|
.side-count { font-size: 12px; font-weight: 500; color: var(--brand-40); margin: 0 0 8px; }
|
|
@@ -159,7 +164,7 @@ a.side-note:hover { color: var(--brand); text-decoration: none; background: rgba
|
|
|
159
164
|
/* 档案底仓:钉在侧栏最底(flex 末位),大纲长过侧栏时随滚动自然跟在内容后 */
|
|
160
165
|
.side-foot { margin-top: auto; padding-top: 4px; }
|
|
161
166
|
@media (min-width: 1024px) {
|
|
162
|
-
.side { display: flex; flex-direction: column;
|
|
167
|
+
.side { display: flex; flex-direction: column; overflow-y: auto; width: 16rem; flex: none; border-right: 1px solid var(--border); background: #f8fafc; padding: 12px 14px 24px; }
|
|
163
168
|
/* 操作区只覆盖内容列:left 让出侧栏,侧栏档案区完整可见 */
|
|
164
169
|
.nav { left: 16rem; }
|
|
165
170
|
}
|
|
@@ -179,4 +184,51 @@ a.side-note:hover { color: var(--brand); text-decoration: none; background: rgba
|
|
|
179
184
|
/* revisions 折叠:节点自己的变更史,默认收起,好奇点开 */
|
|
180
185
|
/* revisions 折叠已删:revisions/refines 汇总挪到左侧大纲底部(.side-sec) */
|
|
181
186
|
|
|
187
|
+
/* 学习笔记:只住右侧笔记栏(内容区不出现);note-time 由右栏条目复用 */
|
|
188
|
+
.note-time { display: block; font-size: 12px; color: var(--brand-40); font-variant-numeric: tabular-nums; margin-bottom: 2px; }
|
|
189
|
+
/* 侧栏大纲的笔记数徽标:实心、可点(跳到该节点并展开笔记栏),
|
|
190
|
+
边框/文字继承所在条目的颜色(currentColor)——普通态/选中态/根标题自动同色 */
|
|
191
|
+
.note-badge { display: inline-block; min-width: 14px; height: 13px; line-height: 13px; padding: 0 4px; border-radius: 999px; background: var(--primary); color: #f8fafc; font-size: 9px; font-weight: 600; text-align: center; margin-left: 5px; vertical-align: middle; cursor: pointer; }
|
|
192
|
+
.note-badge:hover { background: #374151; }
|
|
193
|
+
|
|
194
|
+
/* ── 右侧笔记栏:布局列(非悬浮)——宽度 0↔18rem 平滑过渡,内容列随之在两栏之间保持居中 ── */
|
|
195
|
+
#note-side { flex: none; width: 0; overflow: hidden; align-self: stretch; transition: width .25s ease; }
|
|
196
|
+
#note-side.open { width: 18rem; }
|
|
197
|
+
/* 内层定宽:动画期间内容不被挤压,从右缘逐步露出(抽屉观感);边框/投影挂内层,
|
|
198
|
+
收起宽度 0 时被 overflow 裁掉,不留残线 */
|
|
199
|
+
#note-list { width: 18rem; height: 100%; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; background: #f8fafc; border-left: 1px solid var(--border); box-shadow: 0 0 24px rgba(0, 51, 102, .08); padding: 14px 14px 16px; }
|
|
200
|
+
/* 展开收起舌片:钉在面板左缘垂直居中(面板的相邻兄弟,right 与面板宽度同参数过渡即贴合);
|
|
201
|
+
收起后停在屏幕右缘仍可点 */
|
|
202
|
+
#note-toggle { position: absolute; right: 0; top: 50%; transform: translateY(-50%); width: 26px; height: 56px; display: flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-right: none; border-radius: 8px 0 0 8px; background: #f8fafc; color: var(--brand-40); cursor: pointer; z-index: 5; transition: right .25s ease; }
|
|
203
|
+
#note-side.open + #note-toggle { right: 18rem; }
|
|
204
|
+
#note-toggle:hover { color: var(--brand); }
|
|
205
|
+
/* 箭头语义=面板去向:收起态朝左(展开=面板从右缘向左滑出),展开态朝右(收起=向右滑走) */
|
|
206
|
+
#note-toggle svg { width: 14px; height: 14px; transition: transform .25s ease; transform: rotate(180deg); }
|
|
207
|
+
#note-side.open + #note-toggle svg { transform: rotate(0deg); }
|
|
208
|
+
.nside-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
|
|
209
|
+
.nside-title { margin: 0; font-size: 12px; font-weight: 600; color: var(--brand-50); }
|
|
210
|
+
/* 新建按钮:面板头部的小胶囊 */
|
|
211
|
+
.nside-new { flex: none; display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--brand-40); cursor: pointer; padding: 2px; }
|
|
212
|
+
.nside-new svg { width: 12px; height: 12px; }
|
|
213
|
+
.nside-new:hover { color: var(--brand); }
|
|
214
|
+
/* 头部贴面板顶+右缘:气泡朝下开、右缘对齐,避免被 #note-list 的 overflow 裁掉 */
|
|
215
|
+
.nside-new[data-tip]::after { left: auto; right: 0; bottom: auto; top: calc(100% + 6px); transform: none; }
|
|
216
|
+
.nside-empty { font-size: 12px; line-height: 1.6; color: var(--brand-40); margin: 0 0 10px; }
|
|
217
|
+
.nside-err { font-size: 12px; line-height: 1.5; color: #b91c1c; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; padding: 6px 10px; margin: 0 0 10px; overflow-wrap: break-word; }
|
|
218
|
+
.nside-item { background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; margin-bottom: 8px; }
|
|
219
|
+
.nside-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 3px; }
|
|
220
|
+
.nside-acts { display: inline-flex; gap: 2px; }
|
|
221
|
+
.nside-act { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; border-radius: 6px; background: transparent; color: var(--brand-30); cursor: pointer; }
|
|
222
|
+
.nside-act svg { width: 13px; height: 13px; }
|
|
223
|
+
.nside-act:hover { background: var(--hover-bg); color: var(--brand); }
|
|
224
|
+
.nside-item .note-time { font-size: 11px; }
|
|
225
|
+
/* 行内编辑态与底部新增框共用输入框形态 */
|
|
226
|
+
.nside-input { display: block; width: 100%; border: none; background: transparent; resize: none; overflow: hidden; padding: 0; margin: 0; color: var(--brand-60); font-family: inherit; font-size: 13px; line-height: 1.6; cursor: default; caret-color: transparent; transition: color .15s ease; }
|
|
227
|
+
/* 支持 field-sizing 的浏览器:输入框只占文字本身宽度——点卡片空白处不会出现光标,
|
|
228
|
+
点文字才进编辑且光标落在点击处;autoGrow 在 JS 侧对支持的浏览器跳过 */
|
|
229
|
+
@supports (field-sizing: content) {
|
|
230
|
+
.nside-input { field-sizing: content; width: auto; min-width: 24px; max-width: 100%; min-height: 0; }
|
|
231
|
+
}
|
|
232
|
+
.nside-input:focus { outline: none; cursor: text; caret-color: auto; color: var(--primary); }
|
|
233
|
+
|
|
182
234
|
/* LR kick */
|