wezard 2.1.1 → 2.1.3
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/dist/daemon/detail.js +6 -2
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/index.js +1 -1
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +33 -7
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/shared/role-view.js +43 -16
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +1 -1
- package/dist/shared/session-label.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +23 -3
- package/web/chat.js +246 -46
package/web/chat.js
CHANGED
|
@@ -142,7 +142,11 @@
|
|
|
142
142
|
};
|
|
143
143
|
var bindGo = function (root, sel) {
|
|
144
144
|
root.querySelectorAll(sel || '.go[data-r]').forEach(function (b) {
|
|
145
|
-
b.onclick = function (e) {
|
|
145
|
+
b.onclick = function (e) {
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
var row = b.closest('.mrow');
|
|
148
|
+
switchRole(b.getAttribute('data-r'), row && row.getAttribute('data-id'));
|
|
149
|
+
};
|
|
146
150
|
});
|
|
147
151
|
};
|
|
148
152
|
|
|
@@ -307,15 +311,50 @@
|
|
|
307
311
|
ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
|
|
308
312
|
};
|
|
309
313
|
|
|
310
|
-
var line = function (title, ts, pv, lamp) {
|
|
314
|
+
var line = function (title, ts, pv, lamp, unread) {
|
|
311
315
|
return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
|
|
312
316
|
'<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
|
|
313
|
-
'<span class="pv">' + esc(pv) + '</span
|
|
317
|
+
'<span class="l2"><span class="pv">' + esc(pv) + '</span>' +
|
|
318
|
+
(unread ? '<b class="ub">' + (unread > 99 ? '99+' : unread) + '</b>' : '') + '</span></span>';
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// ── 未读: 别人说完、我还没读到的话 ──
|
|
322
|
+
// 服务端给每个会话一份 heard = [说完的时刻, 属于我与谁的那一对], 以及我在群里 / 每一对里
|
|
323
|
+
// 最后开口的时刻 (mine)。一句话的已读水位 = 它所在那一处「我最后开口」与「我看过」的较晚者:
|
|
324
|
+
// 属于某一对的看那一对 (群里说过话不等于读过别的线程), 不属于任何一对的看群。
|
|
325
|
+
// 「看过」记在本地 (刷新 / 换视角都还在), 按视角分账 —— 同一个群换个 role 看, 未读是另一回事。
|
|
326
|
+
var READ_KEY = 'wezard.role.read';
|
|
327
|
+
var READ = (function () {
|
|
328
|
+
try { var v = JSON.parse(localStorage.getItem(READ_KEY) || '{}'); if (v && v.at) return v; } catch (e) { }
|
|
329
|
+
return { at: {} };
|
|
330
|
+
})();
|
|
331
|
+
var saveRead = function () { try { localStorage.setItem(READ_KEY, JSON.stringify(READ)); } catch (e) { } };
|
|
332
|
+
var readKey = function (key, withRole) { return ROLE + '|' + key + '|' + (withRole || ''); };
|
|
333
|
+
var seenAt = function (key, withRole) { return READ.at[readKey(key, withRole)] || 0; };
|
|
334
|
+
var unreadOf = function (c, withRole) {
|
|
335
|
+
var g = seenAt(c.key);
|
|
336
|
+
var mark = {};
|
|
337
|
+
(c.subs || []).forEach(function (s) { mark[s.role] = Math.max(g, s.mine || 0, seenAt(c.key, s.role)); });
|
|
338
|
+
var groupMark = Math.max(g, c.mine || 0);
|
|
339
|
+
return (c.heard || []).filter(function (h) {
|
|
340
|
+
if (withRole && h[1] !== withRole) return false;
|
|
341
|
+
return h[0] > (h[1] ? (mark[h[1]] !== undefined ? mark[h[1]] : groupMark) : groupMark);
|
|
342
|
+
}).length;
|
|
343
|
+
};
|
|
344
|
+
var reading = function () { return VIEW === 'msgs' && !document.hidden; };
|
|
345
|
+
// 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
|
|
346
|
+
var markRead = function (c) {
|
|
347
|
+
if (!reading() || c.key !== CONV || !unreadOf(c, WITH)) return;
|
|
348
|
+
var h = c.heard[c.heard.length - 1];
|
|
349
|
+
READ.at[readKey(c.key, WITH)] = Math.max(R.at, h ? h[0] : 0);
|
|
350
|
+
saveRead();
|
|
314
351
|
};
|
|
315
352
|
|
|
316
353
|
// 展开态按会话各记各的: 点开一个不收起别的, 轮询重画也不动它。
|
|
317
354
|
// OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算。
|
|
318
355
|
var OPEN = {}, OPEN_AT = '';
|
|
356
|
+
// 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按会话各记各的。
|
|
357
|
+
var SUB_FOLD = 5, MORE = {};
|
|
319
358
|
var reveal = function () {
|
|
320
359
|
if (CONV && CONV !== OPEN_AT) { OPEN[CONV] = true; OPEN_AT = CONV; }
|
|
321
360
|
};
|
|
@@ -335,24 +374,32 @@
|
|
|
335
374
|
var title = c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>';
|
|
336
375
|
// 只列与我有往来的: 在群里但没和我说过话的人, 点进去也是空的。
|
|
337
376
|
var talked = OPEN[c.key] ? c.subs.filter(function (s) { return s.count; }) : [];
|
|
377
|
+
var hidden = talked.length - SUB_FOLD;
|
|
378
|
+
var bar = hidden > 0
|
|
379
|
+
? '<button class="si-more" data-more="' + esc(c.key) + '">' + (MORE[c.key] ? '折叠' : '展开更多') + ' × ' + hidden + '</button>'
|
|
380
|
+
: '';
|
|
381
|
+
// 展开条钉在第 SUB_FOLD+1 位, 展开与折叠都不挪: 其余子项展开后接在它下面。
|
|
382
|
+
var sub = function (s) {
|
|
383
|
+
var sel = on && s.role === WITH;
|
|
384
|
+
return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
|
|
385
|
+
'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
|
|
386
|
+
goSpan('av', s.role, esc(s.label)) +
|
|
387
|
+
line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status), unreadOf(c, s.role)) + '</button>';
|
|
388
|
+
};
|
|
389
|
+
var rest = MORE[c.key] ? talked.slice(SUB_FOLD).map(sub).join('') : '';
|
|
338
390
|
var subs = talked.length
|
|
339
|
-
? '<div class="subs">' + talked.
|
|
340
|
-
var sel = on && s.role === WITH;
|
|
341
|
-
return '<button class="si' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '" data-with="' + esc(s.role) + '" ' +
|
|
342
|
-
'title="' + esc('我与 ' + nameOf(s.role) + ' 在这里的 ' + s.count + ' 条往来') + '">' +
|
|
343
|
-
goSpan('av', s.role, esc(s.label)) +
|
|
344
|
-
line(nm(s.role, s.name, true), s.lastTs, s.preview, lampOf(s.status)) + '</button>';
|
|
345
|
-
}).join('') + '</div>'
|
|
391
|
+
? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
|
|
346
392
|
: '';
|
|
347
393
|
var sel = on && !WITH;
|
|
348
394
|
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
|
|
349
|
-
avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status)) +
|
|
395
|
+
avatarOf(c) + line(title, c.lastTs, c.preview, lampOf(c.status), unreadOf(c)) +
|
|
350
396
|
'</button>' + subs;
|
|
351
397
|
};
|
|
352
398
|
|
|
353
399
|
var renderConvs = function () {
|
|
354
400
|
var groups = R.convs.filter(function (c) { return c.kind === 'group'; });
|
|
355
401
|
var dms = R.convs.filter(function (c) { return c.kind !== 'group'; });
|
|
402
|
+
R.convs.forEach(markRead);
|
|
356
403
|
// 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
|
|
357
404
|
var sec = function (title, list) {
|
|
358
405
|
return list.length ? '<h2>' + title + '<span>' + list.length + '</span></h2>' + list.map(convItem).join('') : '';
|
|
@@ -365,6 +412,10 @@
|
|
|
365
412
|
var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '';
|
|
366
413
|
b.onclick = function () { clickItem(key, w); };
|
|
367
414
|
});
|
|
415
|
+
convsEl.querySelectorAll('[data-more]').forEach(function (b) {
|
|
416
|
+
var key = b.getAttribute('data-more');
|
|
417
|
+
b.onclick = function () { MORE[key] = !MORE[key]; renderConvs(); };
|
|
418
|
+
});
|
|
368
419
|
bindGo(convsEl);
|
|
369
420
|
};
|
|
370
421
|
|
|
@@ -427,8 +478,9 @@
|
|
|
427
478
|
// 名字下面那一行就是出身 —— 有父亲的直接写成「谁的什么」(可点过去), 不再另起一条重复。
|
|
428
479
|
// 分身 = 从父亲某个 session 节点 fork 出来的 (带着那一刻的上下文);
|
|
429
480
|
// 子 wizard = 父亲 spawn 的白板, 只有出身、没有继承。
|
|
430
|
-
|
|
431
|
-
|
|
481
|
+
// 人不挂身份行 —— 名字本身就够了。
|
|
482
|
+
var kind = r.kind === 'human' ? ''
|
|
483
|
+
: !r.parent ? 'wizard'
|
|
432
484
|
: r.forkedFrom
|
|
433
485
|
? '<span class="go" data-r="' + esc(r.parent.id) + '" title="从它的 session ' + esc(r.forkedFrom) + ' fork, 开局带着那一刻的上下文">⧉ .' + esc(r.parent.name) + ' 的分身 · @' + esc(r.forkedFrom.slice(0, 8)) + '</span>'
|
|
434
486
|
: '<span class="go" data-r="' + esc(r.parent.id) + '" title="它 spawn 的白板, 没有继承上下文">↳ .' + esc(r.parent.name) + ' 的子 wizard</span>';
|
|
@@ -444,7 +496,7 @@
|
|
|
444
496
|
$('#rb-who').innerHTML =
|
|
445
497
|
'<div class="id"><span class="av">' + esc(r.label) + '</span>' +
|
|
446
498
|
'<span class="l">' + nm(r.id, r.name) +
|
|
447
|
-
'<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span
|
|
499
|
+
(kind ? '<span class="k">' + kind + (r.kind === 'wizard' ? '<span class="st" id="rb-st"></span>' : '') + '</span>' : '') + '</span></div>' +
|
|
448
500
|
(r.description ? '<p class="job">' + esc(r.description) + '</p>' : '') +
|
|
449
501
|
'<div class="facts">' + facts.join('') + '</div>';
|
|
450
502
|
paintStatus();
|
|
@@ -577,7 +629,7 @@
|
|
|
577
629
|
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
578
630
|
var av = mine ? '' : '<button class="av' + (canSwitch(m.from) ? ' go' : '') + '" data-r="' + esc(m.from) + '" title="' + esc(nameOf(m.from)) + '"' + (canSwitch(m.from) ? '' : ' disabled') + '>' + esc(m.fromLabel || roleLabel(m.from)) + '</button>';
|
|
579
631
|
return '<div class="mrow ' + (mine ? 'mine' : 'them') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
580
|
-
(m.ping ? ' data-ping="1"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
632
|
+
(m.ping ? ' data-ping="1" data-ping-who="' + esc(m.dir === 'in' ? m.toName : m.fromName) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
581
633
|
'<div class="mcol"><div class="mwho">' + av + who + '</div><div class="mb">' + m.html + '</div></div>' +
|
|
582
634
|
flip +
|
|
583
635
|
'</div>';
|
|
@@ -598,18 +650,24 @@
|
|
|
598
650
|
var run = [];
|
|
599
651
|
var flush = function (before) {
|
|
600
652
|
if (!run.length) { return; }
|
|
601
|
-
|
|
653
|
+
// 群窗里几个 wizard 同时挂机, 它们的 ping 会交错成一串 —— 按 wizard 分开计数, 不相加。
|
|
654
|
+
var seen = {}, per = {}, order = [];
|
|
602
655
|
run.forEach(function (r) {
|
|
603
656
|
var t = r.getAttribute('data-turn') || r.getAttribute('data-id');
|
|
604
|
-
|
|
657
|
+
var w = r.getAttribute('data-ping-who') || '';
|
|
658
|
+
if (seen[t]) { return; }
|
|
659
|
+
seen[t] = 1;
|
|
660
|
+
if (!per[w]) { per[w] = 0; order.push(w); }
|
|
661
|
+
per[w]++;
|
|
605
662
|
});
|
|
663
|
+
var tally = order.map(function (w) { return (w ? '.' + w + ' ' : '') + '×' + per[w]; }).join(' · ');
|
|
606
664
|
var t0 = Number(run[0].getAttribute('data-ts') || 0);
|
|
607
665
|
var t1 = Number(run[run.length - 1].getAttribute('data-ts') || 0);
|
|
608
666
|
var span = fmtHM(t0) + (fmtHM(t1) !== fmtHM(t0) ? ' – ' + fmtHM(t1) : '');
|
|
609
667
|
var d = document.createElement('details');
|
|
610
668
|
d.className = 'ping-fold';
|
|
611
669
|
d.open = PING_OPEN;
|
|
612
|
-
d.innerHTML = '<summary class="ping-sum"><span class="pb">🏓
|
|
670
|
+
d.innerHTML = '<summary class="ping-sum"><span class="pb">🏓 ' + esc(tally) + '</span>' +
|
|
613
671
|
'<span class="ps">保温 ping</span><span class="pt">' + esc(span) + '</span></summary>' +
|
|
614
672
|
'<div class="ping-body"></div>';
|
|
615
673
|
root.insertBefore(d, before);
|
|
@@ -635,7 +693,8 @@
|
|
|
635
693
|
return null;
|
|
636
694
|
};
|
|
637
695
|
|
|
638
|
-
|
|
696
|
+
// land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
|
|
697
|
+
var loadMsgs = function (limit, land) {
|
|
639
698
|
var gen = S.gen;
|
|
640
699
|
if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
|
|
641
700
|
return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
|
|
@@ -653,6 +712,7 @@
|
|
|
653
712
|
render(inner);
|
|
654
713
|
foldPings(inner);
|
|
655
714
|
expireRows();
|
|
715
|
+
if (land && land()) return;
|
|
656
716
|
S.pinned = true; toBottom(true);
|
|
657
717
|
// CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
|
|
658
718
|
setTimeout(function () { toBottom(); }, 60);
|
|
@@ -706,7 +766,9 @@
|
|
|
706
766
|
};
|
|
707
767
|
|
|
708
768
|
// ── role 摘要 ──
|
|
709
|
-
var applyRole = function (d) {
|
|
769
|
+
var applyRole = function (d) { takeRole(d); paintRole(); };
|
|
770
|
+
// 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
|
|
771
|
+
var takeRole = function (d) {
|
|
710
772
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
711
773
|
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || [];
|
|
712
774
|
R.relations = !!d.relations; R.schedules = d.schedules || 0;
|
|
@@ -720,10 +782,15 @@
|
|
|
720
782
|
// 没带 conv 的链接 (群里点名字进来) 落在服务端挑的窗口上, 连同它挑的「只看我与谁」;
|
|
721
783
|
// 手里的 conv 失效则只退回整个会话。
|
|
722
784
|
if (!CONV || !convOf(CONV)) { WITH = CONV ? '' : d['with'] || ''; CONV = d.conv || ''; }
|
|
785
|
+
};
|
|
786
|
+
var paintRole = function () {
|
|
723
787
|
reveal();
|
|
724
788
|
renderRole(); renderUsage(); renderConvs(); renderHead();
|
|
725
789
|
};
|
|
726
790
|
|
|
791
|
+
// 切回标签页时正在读的那项就算读过了。
|
|
792
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) renderConvs(); });
|
|
793
|
+
|
|
727
794
|
// ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
|
|
728
795
|
var backoff = 1000;
|
|
729
796
|
var connect = function () {
|
|
@@ -742,19 +809,24 @@
|
|
|
742
809
|
};
|
|
743
810
|
|
|
744
811
|
/** 视角或窗口变了: 摘要、正文、SSE 全部按新参数重来。 */
|
|
745
|
-
var refresh = function () {
|
|
812
|
+
var refresh = function (land) {
|
|
746
813
|
S.gen++;
|
|
747
814
|
syncUrl();
|
|
748
|
-
|
|
815
|
+
// 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
|
|
816
|
+
if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
|
|
749
817
|
return api('api/role', viewParams()).then(function (d) {
|
|
750
818
|
if (!d.ok) {
|
|
751
819
|
document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
|
|
752
820
|
return;
|
|
753
821
|
}
|
|
754
|
-
|
|
822
|
+
// 带 land 的 (换视角) 先不画: 页头换了高度, 旧行会在新行到来前先被顶一下, FLIP 的起点就错了。
|
|
823
|
+
if (land) takeRole(d); else applyRole(d);
|
|
755
824
|
syncUrl();
|
|
756
825
|
if (VIEW === 'world' || VIEW === 'plan') { W.sel = ROLE; loadWorld(); }
|
|
757
|
-
return loadMsgs().then(
|
|
826
|
+
return loadMsgs(undefined, land && function () { paintRole(); return land(); }).then(function () {
|
|
827
|
+
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
828
|
+
connect();
|
|
829
|
+
});
|
|
758
830
|
});
|
|
759
831
|
};
|
|
760
832
|
|
|
@@ -774,7 +846,8 @@
|
|
|
774
846
|
|
|
775
847
|
// 换视角: 窗口尽量留在同一个群 —— 从群里的一条消息切过去, 最想看的是对方在这个群
|
|
776
848
|
// 里的样子; 对方不在这个群 (私聊的另一头) 就交给服务端挑它最近的会话。
|
|
777
|
-
|
|
849
|
+
// at: 被点的那条消息 —— 换视角后它留在指针底下, 其余的相对它滑动。
|
|
850
|
+
var switchRole = function (id, at) {
|
|
778
851
|
if (!canSwitch(id) || id === ROLE) return;
|
|
779
852
|
var keep = CONV && CONV.indexOf('c:') === 0 ? CONV : '';
|
|
780
853
|
var from = ROLE;
|
|
@@ -784,34 +857,161 @@
|
|
|
784
857
|
var withBack = keep && WITH ? from : '';
|
|
785
858
|
ROLE = id; WITH = withBack; SESSION = '';
|
|
786
859
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
787
|
-
|
|
788
|
-
if (same) flipWindow();
|
|
860
|
+
var snap = VIEW === 'msgs' && !calm() ? snapRows(at) : null;
|
|
861
|
+
if (same) { flipWindow(snap); return; }
|
|
862
|
+
var kept = VIEW === 'msgs' ? keepBodies() : {};
|
|
863
|
+
refresh(function () {
|
|
864
|
+
adoptBodies(kept);
|
|
865
|
+
if (landFlip(snap)) return true;
|
|
866
|
+
// 一条旧消息都不在了 (换到了别的会话): 没有可配对的, 才整列淡入。
|
|
867
|
+
inner.classList.remove('flipping'); void inner.offsetWidth; inner.classList.add('flipping');
|
|
868
|
+
return false;
|
|
869
|
+
});
|
|
789
870
|
};
|
|
790
871
|
|
|
791
|
-
|
|
792
|
-
|
|
872
|
+
// ── 换视角的 FLIP: 按 msgid 记下每条消息各块的位置与底色, 重排后从旧处滑到新处、旧色渐到新色 ──
|
|
873
|
+
// 动的是行里的每一块 (名字行、问句气泡、过程框、终句), 不是整列 .mcol: 换边时块在列里的
|
|
874
|
+
// 相对位置也会变 —— 名字行有没有头像差 6px, 带过程框的列占满整宽、终句在列内从左跳到右,
|
|
875
|
+
// 只挪整列的话这些块都是直接闪过去的。
|
|
876
|
+
// 全量记, 不只记视窗里的: 行高随视角变, 屏外的行会被推进视窗。只在前后有一头落在视窗里时才真动。
|
|
877
|
+
// 回复的外壳 .turn-group 是 overflow:hidden 的 (工具行 nowrap, 不裁会撑出横向滚动)。它一换边就
|
|
878
|
+
// 立刻在新位置, 里面的过程框 / 终句要是按屏上绝对位移平移, 起跑时整个在壳外, 被裁得一干二净 ——
|
|
879
|
+
// 看上去就是对侧空白盖住了气泡。所以壳自己算一块, 壳里的块只补「相对壳」的那点差: 前后都在壳内,
|
|
880
|
+
// 途中也就不出壳。
|
|
881
|
+
var PIECES = '.mwho, .mb > *, .mb > .turn-group > .bubbles > *';
|
|
882
|
+
var BUB = '.bubble.mq, .say > .bubble';
|
|
883
|
+
var calm = function () { return window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; };
|
|
884
|
+
var rowsById = function () {
|
|
885
|
+
return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).reduce(function (acc, r) {
|
|
886
|
+
acc[r.getAttribute('data-id')] = r; return acc;
|
|
887
|
+
}, {});
|
|
888
|
+
};
|
|
889
|
+
var piecesOf = function (row) { return [].slice.call(row.querySelectorAll(PIECES)); };
|
|
890
|
+
// 先把所有矩形读完再读底色 —— 读与读之间没有写, 整次快照只排一次版。
|
|
891
|
+
var snapRows = function (at) {
|
|
892
|
+
var rows = rowsById();
|
|
893
|
+
var shot = Object.keys(rows).reduce(function (acc, id) {
|
|
894
|
+
var rects = piecesOf(rows[id]).map(function (el) { return el.getBoundingClientRect(); });
|
|
895
|
+
if (rects.some(function (b) { return b.height; })) acc[id] = { rects: rects }; // 关着的 ping 组里 = 看不见
|
|
896
|
+
return acc;
|
|
897
|
+
}, {});
|
|
898
|
+
Object.keys(shot).forEach(function (id) {
|
|
899
|
+
shot[id].bubs = [].slice.call(rows[id].querySelectorAll(BUB)).map(function (b) { return getComputedStyle(b).backgroundColor; });
|
|
900
|
+
});
|
|
901
|
+
return { at: at && shot[at] ? at : '', rows: shot };
|
|
902
|
+
};
|
|
903
|
+
// 滚动锚: 优先被点的那条, 否则第一条前后都看得见的; 把它的首块挪回旧的屏上位置。
|
|
904
|
+
var anchorScroll = function (snap, rows) {
|
|
905
|
+
var head = function (id) { var el = rows[id] && rows[id].querySelector(PIECES); return el && el.getBoundingClientRect(); };
|
|
906
|
+
var shown = function (id) { var b = head(id); return b && b.height; };
|
|
907
|
+
var id = snap.at && shown(snap.at) ? snap.at : Object.keys(snap.rows).filter(shown)[0];
|
|
908
|
+
if (!id) return false;
|
|
909
|
+
thread.scrollTop += head(id).top - snap.rows[id].rects[0].top;
|
|
910
|
+
S.pinned = atBottom();
|
|
911
|
+
return true;
|
|
912
|
+
};
|
|
913
|
+
var playFlip = function (snap, rows) {
|
|
914
|
+
var ease = { duration: 340, easing: 'cubic-bezier(.2,.7,.2,1)' };
|
|
915
|
+
var vp = thread.getBoundingClientRect();
|
|
916
|
+
var onScreen = function (b) { return b.height && b.bottom > vp.top && b.top < vp.bottom; };
|
|
917
|
+
// 同样先整批读, 再整批写。宽度变了的块 (别人的气泡被列撑宽, 自己的收回内容宽) 贴着新的
|
|
918
|
+
// 那一侧对齐: 靠右的行比右边缘, 靠左的比左边缘。
|
|
919
|
+
var plan = Object.keys(snap.rows).reduce(function (acc, id) {
|
|
920
|
+
var row = rows[id], was = snap.rows[id];
|
|
921
|
+
if (!row) return acc;
|
|
922
|
+
var right = row.classList.contains('mine');
|
|
923
|
+
var shell = null, lift = [0, 0]; // 这一行 .turn-group 的位移, 壳里的块要减掉它
|
|
924
|
+
piecesOf(row).forEach(function (el, i) {
|
|
925
|
+
var a = was.rects[i], b = el.getBoundingClientRect();
|
|
926
|
+
if (!a || !el.animate) return;
|
|
927
|
+
var dx = right ? a.right - b.right : a.left - b.left, dy = a.top - b.top;
|
|
928
|
+
if (el.classList.contains('turn-group')) { shell = el; lift = [dx, dy]; }
|
|
929
|
+
else if (shell && shell.contains(el)) { dx -= lift[0]; dy -= lift[1]; }
|
|
930
|
+
if (!(onScreen(a) || onScreen(b))) return;
|
|
931
|
+
if (dx || dy) acc.push([el, [{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }]]);
|
|
932
|
+
});
|
|
933
|
+
var bubs = row.querySelectorAll(BUB);
|
|
934
|
+
was.bubs.forEach(function (from, i) {
|
|
935
|
+
var b = bubs[i], to = b && getComputedStyle(b).backgroundColor;
|
|
936
|
+
if (to && to !== from) acc.push([b, [{ backgroundColor: from }, { backgroundColor: to }]]);
|
|
937
|
+
});
|
|
938
|
+
return acc;
|
|
939
|
+
}, []);
|
|
940
|
+
plan.forEach(function (p) { p[0].animate(p[1], ease); });
|
|
941
|
+
// 对侧那片空白 (.flip) 是立即到新布局的, 它的 sticky 箭头与 hover 底色会画在正滑过来的
|
|
942
|
+
// 气泡上面 —— 滑行期间把它藏起来、把列抬到上层 (chat.css 的 .fl-run), 落定再放出来。
|
|
943
|
+
if (!plan.length) return;
|
|
944
|
+
clearTimeout(S.flRun);
|
|
945
|
+
inner.classList.add('fl-run');
|
|
946
|
+
S.flRun = setTimeout(function () { inner.classList.remove('fl-run'); }, ease.duration);
|
|
947
|
+
};
|
|
948
|
+
// 重排之后: 先锚滚动, 再从旧位置滑过来。没有一条可配对的返回 false。
|
|
949
|
+
var landFlip = function (snap) {
|
|
950
|
+
if (!snap) return false;
|
|
951
|
+
var rows = rowsById();
|
|
952
|
+
if (!anchorScroll(snap, rows)) return false;
|
|
953
|
+
playFlip(snap, rows);
|
|
954
|
+
return true;
|
|
955
|
+
};
|
|
956
|
+
// 整窗重拉会把正文换成服务端的新片段 —— 过程框按「已有终句就收起」重新出生, 点开的工具
|
|
957
|
+
// 与取过的正文全丢, 看上去就是工具那一截在切换中突然没了。sig 没变的行把旧 .mb 整块搬回来,
|
|
958
|
+
// 与 flipWindow 同一个做法: 折叠 / 展开态、懒取的工具正文、已渲染的 markdown 一并保住。
|
|
959
|
+
var keepBodies = function () {
|
|
960
|
+
return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).reduce(function (acc, r) {
|
|
961
|
+
var mb = r.querySelector('.mb');
|
|
962
|
+
if (mb) acc[r.getAttribute('data-id')] = { mb: mb, sig: r.getAttribute('data-sig') };
|
|
963
|
+
return acc;
|
|
964
|
+
}, {});
|
|
965
|
+
};
|
|
966
|
+
var adoptBodies = function (kept) {
|
|
967
|
+
Object.keys(kept).forEach(function (id) {
|
|
968
|
+
var row = rowNode(id), k = kept[id];
|
|
969
|
+
var nb = row && row.getAttribute('data-sig') === k.sig && row.querySelector('.mb');
|
|
970
|
+
if (nb) nb.replaceWith(k.mb);
|
|
971
|
+
});
|
|
972
|
+
};
|
|
973
|
+
/** 同一窗口换视角: 拿手里的片段就地重包左右, 已渲染的正文整块搬过去。
|
|
974
|
+
* 先等 role 摘要到手、页头侧栏按新视角画完再重包 —— 页头换了高度 (人没有职责行、有的 wizard 有),
|
|
975
|
+
* 在动画起跑之后才变, 整列就会在半路被顶一下。旧行在这几十毫秒里原样留着。 */
|
|
976
|
+
var flipWindow = function (snap) {
|
|
793
977
|
S.gen++;
|
|
794
978
|
syncUrl();
|
|
795
|
-
unfoldPings(inner);
|
|
796
|
-
[].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
|
|
797
|
-
var m = S.frags[row.getAttribute('data-id')];
|
|
798
|
-
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
799
|
-
if (!m || m.dir === 'mark') { row.remove(); return; }
|
|
800
|
-
var next = frag(rowHTML(m)).firstElementChild;
|
|
801
|
-
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
802
|
-
if (ob && nb) nb.replaceWith(ob);
|
|
803
|
-
row.replaceWith(next);
|
|
804
|
-
bindRow(next);
|
|
805
|
-
});
|
|
806
|
-
foldPings(inner);
|
|
807
|
-
expireRows();
|
|
808
|
-
connect();
|
|
809
979
|
var gen = S.gen;
|
|
810
|
-
api('api/
|
|
980
|
+
api('api/role', viewParams()).then(function (d) {
|
|
811
981
|
if (!d.ok || gen !== S.gen) return;
|
|
812
|
-
d
|
|
982
|
+
applyRole(d);
|
|
983
|
+
unfoldPings(inner);
|
|
984
|
+
[].slice.call(inner.querySelectorAll('.mrow')).forEach(function (row) {
|
|
985
|
+
var m = S.frags[row.getAttribute('data-id')];
|
|
986
|
+
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
987
|
+
if (!m || m.dir === 'mark') { row.remove(); return; }
|
|
988
|
+
var next = frag(rowHTML(m)).firstElementChild;
|
|
989
|
+
var ob = row.querySelector('.mb'), nb = next.querySelector('.mb');
|
|
990
|
+
if (ob && nb) nb.replaceWith(ob);
|
|
991
|
+
row.replaceWith(next);
|
|
992
|
+
bindRow(next);
|
|
993
|
+
});
|
|
994
|
+
foldPings(inner);
|
|
995
|
+
expireRows();
|
|
996
|
+
landFlip(snap);
|
|
997
|
+
connect();
|
|
998
|
+
return api('api/msgs', viewParams());
|
|
999
|
+
}).then(function (d) {
|
|
1000
|
+
if (!d || !d.ok || gen !== S.gen) return;
|
|
1001
|
+
// 断点晚到, 插在视窗上方会把正在滑的整列顶下去 —— 没吸底时锚住屏上第一条, 插完挪回原处。
|
|
1002
|
+
var marks = d.msgs.filter(function (m) { return m.dir === 'mark'; });
|
|
1003
|
+
if (!marks.length) return;
|
|
1004
|
+
var ref = !S.pinned && firstShown(), top = ref && ref.getBoundingClientRect().top;
|
|
1005
|
+
marks.forEach(upsertMsg);
|
|
1006
|
+
if (ref && ref.isConnected) thread.scrollTop += ref.getBoundingClientRect().top - top;
|
|
813
1007
|
});
|
|
814
1008
|
};
|
|
1009
|
+
var firstShown = function () {
|
|
1010
|
+
var vp = thread.getBoundingClientRect();
|
|
1011
|
+
return [].slice.call(inner.querySelectorAll('.mrow:not(.mark)')).filter(function (r) {
|
|
1012
|
+
var b = r.getBoundingClientRect(); return b.height && b.bottom > vp.top;
|
|
1013
|
+
})[0];
|
|
1014
|
+
};
|
|
815
1015
|
|
|
816
1016
|
$('#tb-back').onclick = function () { app.classList.remove('reading'); };
|
|
817
1017
|
|
|
@@ -1555,7 +1755,7 @@
|
|
|
1555
1755
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
1556
1756
|
if (v !== 'msgs') app.classList.add('reading');
|
|
1557
1757
|
renderRole(); renderHead(); renderUsage();
|
|
1558
|
-
if (v === 'msgs') { fxStop(); toBottom(true); }
|
|
1758
|
+
if (v === 'msgs') { fxStop(); toBottom(true); renderConvs(); }
|
|
1559
1759
|
else {
|
|
1560
1760
|
// 关系图一打开就聚焦在当前 role 身上: 它的邻居亮着, 其余压暗。
|
|
1561
1761
|
if (v === 'world') W.sel = ROLE;
|