collab-canvas 0.3.0 → 0.4.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 +16 -5
- package/dist/host.js +957 -161
- package/lib/ccv-logic.js +281 -0
- package/lib/client.js +1058 -1633
- package/lib/doc-model.js +291 -0
- package/lib/selref.js +107 -20
- package/lib/viewer-md.js +4075 -0
- package/package.json +4 -3
- package/lib/docdrop.js +0 -362
- package/lib/doclink.js +0 -436
package/lib/ccv-logic.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// DSH Collab-Canvas · 纯逻辑共享层(2026-10-26 重构 P1)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件:
|
|
4
|
+
// · 客户端有两块代码需要同一套算法(主模块 editor-panel.js、查看器 viewer-md.js)
|
|
5
|
+
// 而侧模块**不能 require** ✗ 只能靠全局量交接 ✓
|
|
6
|
+
// · 这些逻辑又**全都不碰 DOM** ✓ → 放进一个可被 Node 直接 require 的模块 ✓
|
|
7
|
+
// → 终于能给它们写自动化测试 ✓(本会话多次出错的地方正是这些算法 ✓)
|
|
8
|
+
//
|
|
9
|
+
// 加载方式:
|
|
10
|
+
// 浏览器:由 editor-panel.js 的侧模块引导拉取,执行后挂到 window.__ccvLogic
|
|
11
|
+
// (同时挂 window.__ccvKit.logic,给其它侧模块用 ✓)
|
|
12
|
+
// Node :require('./lib/ccv-logic.js') ✓ 直接拿到同一份实现 ✓
|
|
13
|
+
//
|
|
14
|
+
// ⚠️ 铁律:**这里只准放纯函数** ✗ 不许碰 DOM / window API / 定时器
|
|
15
|
+
// (碰了就没法在 Node 里测 ✓ 也就失去抽出来的意义 ✗)
|
|
16
|
+
var CCV_LOGIC = (function () {
|
|
17
|
+
'use strict'
|
|
18
|
+
|
|
19
|
+
// ── 1. 会话键哈希(djb2 → 36 进制)──
|
|
20
|
+
// 历史:会话键曾是 "sess-" + hash(会话名),落盘文件名就是它 ✓
|
|
21
|
+
// 现已优先用真 sessionId,但**旧键反查**仍需要这个哈希 ✓
|
|
22
|
+
// 实证:hash('DeepSeek Harness') === 'qberq7'(与老日志一致 ✓)
|
|
23
|
+
function hashStr(s) {
|
|
24
|
+
var h = 5381
|
|
25
|
+
var str = String(s == null ? '' : s)
|
|
26
|
+
for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0
|
|
27
|
+
return h.toString(36)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 2. dsh-resource 地址拼装(照官方 fileAddressFor 的编码规则)──
|
|
31
|
+
// 规则:逐段 encodeURIComponent ✓ 但盘符冒号保留 ✓
|
|
32
|
+
var FILE_PREFIX = 'dsh-resource://file/'
|
|
33
|
+
function encSeg(seg) {
|
|
34
|
+
var s = String(seg == null ? '' : seg)
|
|
35
|
+
try { s = encodeURIComponent(s) } catch (_) {}
|
|
36
|
+
return s.split('%3A').join(':').split('%3a').join(':')
|
|
37
|
+
}
|
|
38
|
+
function encPath(p) {
|
|
39
|
+
return String(p == null ? '' : p).split('/').map(encSeg).join('/')
|
|
40
|
+
}
|
|
41
|
+
function sessionFileAddress(sessionId, absPath) {
|
|
42
|
+
var sid = String(sessionId == null ? '' : sessionId).trim()
|
|
43
|
+
if (!sid) return ''
|
|
44
|
+
var p = String(absPath == null ? '' : absPath).replace(/\\/g, '/')
|
|
45
|
+
if (!p) return ''
|
|
46
|
+
while (p.indexOf('./') === 0) {
|
|
47
|
+
p = p.slice(2)
|
|
48
|
+
if (p.indexOf('./') !== 0) break
|
|
49
|
+
}
|
|
50
|
+
return FILE_PREFIX + 'session/' + encSeg(sid) + '/' + encPath(p)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 3. 会话键选择(纯逻辑部分:真 id 优先)──
|
|
54
|
+
// ⚠️ 读 window.__ccvSessionId / DOM 的部分留在调用方 ✗ 这里只做判断 ✓
|
|
55
|
+
function isRealSessionId(v) {
|
|
56
|
+
return typeof v === 'string' && /^session-/.test(v)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 4. 宽高判据:宽 ≥ 高 → 停靠(真·纯函数,测起来最简单)──
|
|
60
|
+
function pickDocked(w, h) {
|
|
61
|
+
var W = Number(w) || 0
|
|
62
|
+
var H = Number(h) || 0
|
|
63
|
+
if (W < 60 || H < 60) return null // 太小,先别判 ✓
|
|
64
|
+
return W >= H
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── 5. 旧哈希键 → 真会话 id 的反查表 ──
|
|
68
|
+
// byId 形如 { 'session-xxx': { displayTitle, title } }
|
|
69
|
+
// 返回 { 'sess-<hash>': sid, 't<hash>': sid } ✓
|
|
70
|
+
function hashKeyMap(byId) {
|
|
71
|
+
var map = {}
|
|
72
|
+
if (!byId || typeof byId !== 'object') return map
|
|
73
|
+
Object.keys(byId).forEach(function (sid) {
|
|
74
|
+
var row = byId[sid]
|
|
75
|
+
if (!row) return
|
|
76
|
+
var t = row.displayTitle || row.title || ''
|
|
77
|
+
if (!t) return
|
|
78
|
+
map['sess-' + hashStr(t)] = sid
|
|
79
|
+
map['t' + hashStr(t)] = sid
|
|
80
|
+
})
|
|
81
|
+
return map
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── 6. 分组拼装(本会话反复出错的地方 ✓ 现在能被测试锁住)──
|
|
85
|
+
// data = { canvases:[{id,title,updatedAt,filePath}], groups:[{sid,real,ids}] }
|
|
86
|
+
// curSid = 当前会话 id(用来把它的组置顶)
|
|
87
|
+
// byId = 会话快照(用来取真名 + 旧键反查)
|
|
88
|
+
// titleOf = function(sid) -> string(取会话名;为空则回落)
|
|
89
|
+
// 返回 = { groups:[{sid,key,keys,legacy,title,matched,items,latest}], total }
|
|
90
|
+
// 规则:
|
|
91
|
+
// · 同一篇文档只出现一次 ✓(先到先得:宿主已按唯一归属返回 ✓)
|
|
92
|
+
// · 旧哈希键反查成功 → 归到真会话名下 ✓ 合并到同一组 ✓
|
|
93
|
+
// · 当前会话置顶 ✓ 认不出的旧组垫底 ✓ 未归属最后 ✓
|
|
94
|
+
function buildGroups(data, curSid, byId, titleOf) {
|
|
95
|
+
var canvases = (data && data.canvases) || []
|
|
96
|
+
var rawGroups = (data && data.groups) || []
|
|
97
|
+
var map = hashKeyMap(byId)
|
|
98
|
+
var byCanvas = {}
|
|
99
|
+
canvases.forEach(function (c) { if (c && c.id) byCanvas[c.id] = c })
|
|
100
|
+
var seen = {}
|
|
101
|
+
var byEff = {}
|
|
102
|
+
var order = []
|
|
103
|
+
rawGroups.forEach(function (g) {
|
|
104
|
+
var items = []
|
|
105
|
+
;(g.ids || []).forEach(function (id) {
|
|
106
|
+
var c = byCanvas[id]
|
|
107
|
+
if (!c || seen[id]) return
|
|
108
|
+
seen[id] = 1
|
|
109
|
+
items.push(c)
|
|
110
|
+
})
|
|
111
|
+
if (!items.length) return
|
|
112
|
+
var guess = (!g.real && map[g.sid]) ? map[g.sid] : ''
|
|
113
|
+
var effSid = guess || g.sid
|
|
114
|
+
var title = (titleOf && titleOf(effSid)) || (titleOf && titleOf(g.sid)) || ''
|
|
115
|
+
if (byEff[effSid]) {
|
|
116
|
+
byEff[effSid].items = byEff[effSid].items.concat(items)
|
|
117
|
+
byEff[effSid].keys.push(g.sid)
|
|
118
|
+
if (!byEff[effSid].title && title) byEff[effSid].title = title
|
|
119
|
+
} else {
|
|
120
|
+
byEff[effSid] = {
|
|
121
|
+
sid: effSid,
|
|
122
|
+
key: g.sid,
|
|
123
|
+
keys: [g.sid],
|
|
124
|
+
legacy: !g.real && !guess,
|
|
125
|
+
title: title,
|
|
126
|
+
matched: !!guess,
|
|
127
|
+
items: items,
|
|
128
|
+
}
|
|
129
|
+
order.push(effSid)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
var groups = order.map(function (sid) {
|
|
133
|
+
var g = byEff[sid]
|
|
134
|
+
g.items.sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0) })
|
|
135
|
+
g.latest = g.items[0].updatedAt || 0
|
|
136
|
+
return g
|
|
137
|
+
})
|
|
138
|
+
var orphans = canvases.filter(function (c) { return c && c.id && !seen[c.id] })
|
|
139
|
+
groups.sort(function (a, b) {
|
|
140
|
+
var ac = (a.sid === curSid) ? 1 : 0
|
|
141
|
+
var bc = (b.sid === curSid) ? 1 : 0
|
|
142
|
+
if (ac !== bc) return bc - ac
|
|
143
|
+
if (a.legacy !== b.legacy) return a.legacy ? 1 : -1
|
|
144
|
+
return (b.latest || 0) - (a.latest || 0)
|
|
145
|
+
})
|
|
146
|
+
if (orphans.length) {
|
|
147
|
+
orphans.sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0) })
|
|
148
|
+
groups.push({ sid: '__orphan__', key: '', keys: [], legacy: false, title: '未归属', matched: false, items: orphans, latest: 0 })
|
|
149
|
+
}
|
|
150
|
+
return { groups: groups, total: canvases.length }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 7. 归属归一(服务端也有一份实现;这里是同一套规则的纯函数版 ✓ 用于测试)──
|
|
154
|
+
// entries = [{ key, ids }](顺序无关 ✓ 规则确定性 ✓ 与时间戳无关 ✓)
|
|
155
|
+
// 规则:真会话键优先 → 其余按键名升序 ✓
|
|
156
|
+
// 返回 { owner:{ id: key }, keep:{ key: [ids] } }
|
|
157
|
+
function normalizeOwnership(entries) {
|
|
158
|
+
var list = (entries || []).slice().sort(function (a, b) {
|
|
159
|
+
var ar = /^session-/.test(a.key) ? 1 : 0
|
|
160
|
+
var br = /^session-/.test(b.key) ? 1 : 0
|
|
161
|
+
if (ar !== br) return br - ar
|
|
162
|
+
return a.key < b.key ? -1 : (a.key > b.key ? 1 : 0)
|
|
163
|
+
})
|
|
164
|
+
var owner = {}
|
|
165
|
+
list.forEach(function (e) {
|
|
166
|
+
(e.ids || []).forEach(function (id) { if (!owner[id]) owner[id] = e.key })
|
|
167
|
+
})
|
|
168
|
+
var keep = {}
|
|
169
|
+
;(entries || []).forEach(function (e) {
|
|
170
|
+
keep[e.key] = (e.ids || []).filter(function (id) { return owner[id] === e.key })
|
|
171
|
+
})
|
|
172
|
+
return { owner: owner, keep: keep }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── 8. 目录条目切分(顶部标题 vs 列表)──
|
|
176
|
+
// outline = [{ lv, text }];首个一级标题当"文档标题"✓ 列表里跳过它 ✓
|
|
177
|
+
function splitOutline(outline) {
|
|
178
|
+
var list = outline || []
|
|
179
|
+
var headTitle = ''
|
|
180
|
+
var listStart = 0
|
|
181
|
+
if (list.length && list[0] && list[0].lv === 1) {
|
|
182
|
+
headTitle = list[0].text || ''
|
|
183
|
+
listStart = 1
|
|
184
|
+
}
|
|
185
|
+
return { headTitle: headTitle, listStart: listStart, rest: list.slice(listStart) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 9. 目录行/列宽(纯换算)──
|
|
189
|
+
function tocWidth(docked) { return docked ? 220 : 190 }
|
|
190
|
+
function shellPadLeft(tocOn, docked) {
|
|
191
|
+
if (!tocOn) return '16px'
|
|
192
|
+
return docked ? 0 : (tocWidth(false) + 12) + 'px'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── 10. 文档标题(2026-10-26 P3:从 BodyInner 里搬出来的纯计算)──
|
|
196
|
+
// 优先正文第一个 h1(= 文档标题)✓ 兜底文件名(去 .md,反斜杠也认)✓
|
|
197
|
+
function docTitle(outline, absPath) {
|
|
198
|
+
var list = outline || []
|
|
199
|
+
if (list.length && list[0] && list[0].lv === 1 && list[0].text) return list[0].text
|
|
200
|
+
try {
|
|
201
|
+
return String(absPath || '').replace(/\\/g, '/').split('/').pop().replace(/\.md$/i, '')
|
|
202
|
+
} catch (_) { return '' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 11. 目录条目 class(层级夹到 1..4 + 高亮)──
|
|
206
|
+
function tocItemClass(lv, isActive) {
|
|
207
|
+
var n = Math.min(4, Math.max(1, Number(lv) || 1))
|
|
208
|
+
return 'ccv-toc-item ccv-l' + n + (isActive ? ' is-active' : '')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── 12. 目录面板样式(停靠 / 浮层 / 悬浮三种)✓ 纯对象,可被测试比对 ──
|
|
212
|
+
function tocPanelStyle(mode) {
|
|
213
|
+
if (mode === 'dock') {
|
|
214
|
+
var w = tocWidth(true)
|
|
215
|
+
return { flex: '0 0 ' + w + 'px', width: w + 'px' }
|
|
216
|
+
}
|
|
217
|
+
if (mode === 'overlay') {
|
|
218
|
+
return {
|
|
219
|
+
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 40,
|
|
220
|
+
width: tocWidth(false) + 'px',
|
|
221
|
+
background: 'var(--dsw-alias-bg-layer-1,rgba(30,30,30,.96))',
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
position: 'absolute', left: 0, top: 0, bottom: 0, zIndex: 41,
|
|
226
|
+
width: tocWidth(false) + 'px',
|
|
227
|
+
background: 'var(--dsw-alias-bg-layer-1,rgba(30,30,30,.97))',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 13. 外层壳样式(包住目录 + 正文的那一层)──
|
|
232
|
+
function shellStyle(tocOn, docked) {
|
|
233
|
+
return {
|
|
234
|
+
position: 'relative', display: 'flex', height: '100%',
|
|
235
|
+
minHeight: 0, minWidth: 0, alignItems: 'stretch',
|
|
236
|
+
boxSizing: 'border-box',
|
|
237
|
+
paddingLeft: shellPadLeft(!!tocOn, !!docked),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── 14. 正文盒模型样式(2026-10-27 从 viewer 搬来;固定值,不随目录开合变)──
|
|
242
|
+
// ⚠️ whiteSpace 必须是 normal:宿主容器是 pre-wrap ✗ 会把我产出的 HTML
|
|
243
|
+
// 标签间换行当真换行渲染 ✓ 段落间就凭空多一行(2026-10-11 实测过)✓
|
|
244
|
+
function editorBoxStyle() {
|
|
245
|
+
return {
|
|
246
|
+
height: '100%', overflow: 'auto', boxSizing: 'border-box',
|
|
247
|
+
padding: '18px 24px 18px 20px', lineHeight: 1.75,
|
|
248
|
+
fontSize: '14px', wordBreak: 'break-word',
|
|
249
|
+
whiteSpace: 'normal',
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
hashStr: hashStr,
|
|
254
|
+
isRealSessionId: isRealSessionId,
|
|
255
|
+
encSeg: encSeg,
|
|
256
|
+
encPath: encPath,
|
|
257
|
+
sessionFileAddress: sessionFileAddress,
|
|
258
|
+
pickDocked: pickDocked,
|
|
259
|
+
hashKeyMap: hashKeyMap,
|
|
260
|
+
buildGroups: buildGroups,
|
|
261
|
+
normalizeOwnership: normalizeOwnership,
|
|
262
|
+
splitOutline: splitOutline,
|
|
263
|
+
docTitle: docTitle,
|
|
264
|
+
tocItemClass: tocItemClass,
|
|
265
|
+
tocPanelStyle: tocPanelStyle,
|
|
266
|
+
shellStyle: shellStyle,
|
|
267
|
+
editorBoxStyle: editorBoxStyle,
|
|
268
|
+
tocWidth: tocWidth,
|
|
269
|
+
shellPadLeft: shellPadLeft,
|
|
270
|
+
FILE_PREFIX: FILE_PREFIX,
|
|
271
|
+
}
|
|
272
|
+
})()
|
|
273
|
+
|
|
274
|
+
// 浏览器:挂全局(侧模块 / 主模块共用 ✓)
|
|
275
|
+
try { if (typeof window !== 'undefined' && window) window.__ccvLogic = CCV_LOGIC } catch (_) {}
|
|
276
|
+
// 主模块的 kit 也挂一份(其它侧模块可以通过 __ccvKit.logic 取 ✓)
|
|
277
|
+
try {
|
|
278
|
+
if (typeof window !== 'undefined' && window && window.__ccvKit) window.__ccvKit.logic = CCV_LOGIC
|
|
279
|
+
} catch (_) {}
|
|
280
|
+
// Node:直接 require ✓(自动化测试用)
|
|
281
|
+
if (typeof module === 'object' && module && module.exports) module.exports = CCV_LOGIC
|