dsh-quote-annotate-pro 0.2.6 → 0.2.8
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/CHANGELOG.md +22 -2
- package/lib/client.js +153 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
本项目的所有重要变更都记在这里。版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
4
4
|
|
|
5
|
+
## 0.2.8 — 修复"批注编辑框跑到窗口左上角 / 引用不了"
|
|
6
|
+
|
|
7
|
+
- **现象**:选中文字点「批注」后,编辑框出现在窗口左上角(压住侧边栏),看起来像引用功能坏了
|
|
8
|
+
- **根因**:装饰逻辑会替换消息里的文本节点;若用户**正在那条消息里划词**,替换会让选区失效,`getBoundingClientRect()` 随即返回全 0 —— 用这个矩形算出来的位置正好是 `left=12 / top=8`(窗口左上角)
|
|
9
|
+
- **修复**
|
|
10
|
+
- **不再动用户正在划词的那条消息**:`selectionTouches(row)` 守卫,等选区释放后再装饰
|
|
11
|
+
- **`safeRect()` 兜底**:矩形无效时先用当前选区重算,仍无效则退回视口底部居中 —— 编辑框不会再跑到左上角
|
|
12
|
+
- 文本与模型侧行为不变(位置行仍只对模型可见)
|
|
13
|
+
|
|
14
|
+
## 0.2.7 — 引用渲染定版:输入框与对话区都不再"先露原文"
|
|
15
|
+
|
|
16
|
+
- **输入框侧(本次)**:插入引用后**同一帧内**完成装饰 —— 不再走 `setTimeout`,改为在 `MutationObserver` 回调里同步执行(回调先于绘制),消除此前约 40ms 的"先露出完整引用块"窗口
|
|
17
|
+
- **对话区侧(本次)**:位置行不再"先露一百多毫秒再被隐藏"(依据录屏 + diag-sink 日志定位)
|
|
18
|
+
- 用户消息行有两副面孔:宿主的正式行带 `data-chat-anchor-key`,但消息刚发出去的那一下先渲染成 `div.*_userRow`(**还没有锚点属性**)→ 插件此前够不到,实测空窗约 122ms
|
|
19
|
+
- 现在两类行都按用户行处理(`USER_ROW_SEL`):行出现的**同一帧**就完成装饰(MutationObserver 回调先于绘制),不再等防抖
|
|
20
|
+
- 宿主在渲染后还会重排气泡内容(实测再约 120ms 一次),会把已隐藏的位置行换回原文;判定改为**看内容**(`rowHasRawHeader()`)而不是看 `data-dshq-sent` 标记,重排的同一帧即补装饰
|
|
21
|
+
- 观察器增加 `attributeFilter: ['data-chat-anchor-key','data-chat-flow-kind']`,覆盖"先插文本、后挂锚点"的渲染顺序
|
|
22
|
+
- **文本零改动**:装饰只加样式类/包一层隐藏元素,**消息文本仍是 `【引用 · 第N轮 · 角色】` + 原文** → 模型侧定位能力不变
|
|
23
|
+
- 新增静默诊断探针(`cmp:probe` 等),仅在运行 `npm run diag` 时上报;发布版无任何网络请求
|
|
24
|
+
|
|
5
25
|
## 0.2.6 — 输入框与消息统一为紧凑引用(消除插入瞬间的闪烁)
|
|
6
26
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
27
|
+
- **输入框里的引用**:插入后渲染成**紧凑原文块**(位置行隐藏)
|
|
28
|
+
- 实现:装饰函数暴露为模块级钩子,插入成功即调用一次
|
|
9
29
|
- **引用文本去掉 `> ` 前缀**(`【引用 · 第N轮 · 角色】` + 原文),编辑器里不再多露一个符号
|
|
10
30
|
- **文本保持完整**:界面只是隐藏位置行(加样式类,不动编辑器文本),因此**发送出去的内容仍带轮次/角色** → 模型侧定位能力不变
|
|
11
31
|
- 消息侧(0.2.3)的紧凑渲染保持不变,输入与输出观感一致
|
package/lib/client.js
CHANGED
|
@@ -149,6 +149,8 @@ window.__ModuleLoader__.load({
|
|
|
149
149
|
const refCodecs = new Map()
|
|
150
150
|
// 输入框引用装饰的"立即执行"钩子:插入后马上贴样式,避免先露出旧样子再变(用户反馈的瞬间闪烁)
|
|
151
151
|
let composerDecorateNow = null
|
|
152
|
+
// 诊断用:最近一次插入引用的时刻(毫秒),用于测"装饰相对插入晚了多久"
|
|
153
|
+
let lastInsertAt = 0
|
|
152
154
|
|
|
153
155
|
// 页面内反馈 toast(点击 chip 时显示结果)
|
|
154
156
|
const toastStore = {
|
|
@@ -681,7 +683,7 @@ window.__ModuleLoader__.load({
|
|
|
681
683
|
anchorKey: sel.anchorKey,
|
|
682
684
|
turn: sel.turn,
|
|
683
685
|
kind: sel.kind,
|
|
684
|
-
}, sel.rect)
|
|
686
|
+
}, safeRect(sel.rect))
|
|
685
687
|
finish()
|
|
686
688
|
}
|
|
687
689
|
return React.createElement('div', { className: 'dshq-toolbar', style: { left, top } },
|
|
@@ -702,6 +704,31 @@ window.__ModuleLoader__.load({
|
|
|
702
704
|
}
|
|
703
705
|
|
|
704
706
|
// ============ 组件三:悬浮批注编辑框(评论输入框元素永久驻留,复用时输入法上下文不重建) ============
|
|
707
|
+
/**
|
|
708
|
+
* 选区矩形兜底。矩形失效(节点被装饰/重渲染替换后 getBoundingClientRect 会全 0)时,
|
|
709
|
+
* 直接用它会算出 left=12 / top=8 —— 编辑框就跑到窗口左上角(用户反馈的现象)。
|
|
710
|
+
* 处理:无效 → 用当前选区重算 → 仍无效 → 退回视口底部居中。
|
|
711
|
+
*/
|
|
712
|
+
function safeRect(rect) {
|
|
713
|
+
const ok = (r) => !!(r
|
|
714
|
+
&& Number.isFinite(r.left) && Number.isFinite(r.top)
|
|
715
|
+
&& Number.isFinite(r.right) && Number.isFinite(r.bottom)
|
|
716
|
+
&& ((r.right - r.left) > 0 || (r.bottom - r.top) > 0))
|
|
717
|
+
if (ok(rect)) return rect
|
|
718
|
+
try {
|
|
719
|
+
const s = typeof window !== 'undefined' ? window.getSelection() : null
|
|
720
|
+
if (s && s.rangeCount > 0) {
|
|
721
|
+
const r = s.getRangeAt(0).getBoundingClientRect()
|
|
722
|
+
if (ok(r)) return r
|
|
723
|
+
}
|
|
724
|
+
} catch (e) { /* ignore */ }
|
|
725
|
+
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
|
726
|
+
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
|
|
727
|
+
const left = Math.max(12, Math.round(vw / 2 - 140))
|
|
728
|
+
const top = Math.max(12, Math.round(vh - 200))
|
|
729
|
+
return { left, right: left + 280, top, bottom: top + 24, width: 280, height: 24 }
|
|
730
|
+
}
|
|
731
|
+
|
|
705
732
|
const AnnotationEditor = () => {
|
|
706
733
|
const state = useEditor()
|
|
707
734
|
const inputRef = React.useRef(null)
|
|
@@ -719,18 +746,19 @@ window.__ModuleLoader__.load({
|
|
|
719
746
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
|
|
720
747
|
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
|
|
721
748
|
const item = state.item
|
|
749
|
+
const rect = open ? safeRect(state.rect) : null
|
|
722
750
|
let left = -9999
|
|
723
751
|
let top = -9999
|
|
724
752
|
let w = 200
|
|
725
|
-
if (open) {
|
|
753
|
+
if (open && rect) {
|
|
726
754
|
// 宽度:默认约 10 字(120px),随评论输入变宽,封顶 340px
|
|
727
755
|
const quoteW = Math.min(220, item.text.length * 12 + 16)
|
|
728
756
|
const inputW = Math.min(300, Math.max(140, comment.length * 13 + 44))
|
|
729
757
|
w = Math.max(120, Math.min(340, Math.max(quoteW, inputW)))
|
|
730
|
-
const center =
|
|
758
|
+
const center = rect.left + (rect.right - rect.left) / 2
|
|
731
759
|
left = Math.max(12, Math.min(center - w / 2, vw - w - 12))
|
|
732
760
|
const estH = 200
|
|
733
|
-
top =
|
|
761
|
+
top = rect.bottom + 8 + estH <= vh - 8 ? rect.bottom + 8 : Math.max(8, rect.top - estH - 8)
|
|
734
762
|
}
|
|
735
763
|
const insert = () => {
|
|
736
764
|
if (!open || !item) return
|
|
@@ -806,6 +834,7 @@ window.__ModuleLoader__.load({
|
|
|
806
834
|
} catch (e) { /* ignore */ }
|
|
807
835
|
report('insert:chip', { ref, anchorKey: item.anchorKey || null, header, chars: item.text.length })
|
|
808
836
|
// 立刻给刚插入的内容贴样式,避免"先露出旧样子、稍后才变紧凑"的闪烁
|
|
837
|
+
lastInsertAt = (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
|
809
838
|
try { if (typeof composerDecorateNow === 'function') composerDecorateNow() } catch (e) { /* ignore */ }
|
|
810
839
|
setStatus({
|
|
811
840
|
ok: true,
|
|
@@ -1290,6 +1319,20 @@ window.__ModuleLoader__.load({
|
|
|
1290
1319
|
})
|
|
1291
1320
|
return wrap
|
|
1292
1321
|
}
|
|
1322
|
+
/** 用户当前选区是否落在这个行里(是则暂缓装饰,别动 DOM 把选区搞失效) */
|
|
1323
|
+
const selectionTouches = (row) => {
|
|
1324
|
+
try {
|
|
1325
|
+
const s = typeof window !== 'undefined' ? window.getSelection() : null
|
|
1326
|
+
if (!s || s.rangeCount === 0 || s.isCollapsed) return false
|
|
1327
|
+
for (let i = 0; i < s.rangeCount; i++) {
|
|
1328
|
+
const r = s.getRangeAt(i)
|
|
1329
|
+
const a = r.startContainer
|
|
1330
|
+
const b = r.endContainer
|
|
1331
|
+
if ((a && row.contains(a)) || (b && row.contains(b))) return true
|
|
1332
|
+
}
|
|
1333
|
+
} catch (e) { /* ignore */ }
|
|
1334
|
+
return false
|
|
1335
|
+
}
|
|
1293
1336
|
/** 把承载「【引用…】\n原文…」的文本节点拆成:隐藏头部 + 紧凑引用块 + 其余文本 */
|
|
1294
1337
|
const transformTextNode = (node) => {
|
|
1295
1338
|
const raw = node.nodeValue || ''
|
|
@@ -1323,18 +1366,25 @@ window.__ModuleLoader__.load({
|
|
|
1323
1366
|
}
|
|
1324
1367
|
const scan = () => {
|
|
1325
1368
|
timer = null
|
|
1326
|
-
try {
|
|
1327
|
-
|
|
1328
|
-
if (
|
|
1329
|
-
|
|
1369
|
+
try { for (const row of document.querySelectorAll(USER_ROW_SEL)) {
|
|
1370
|
+
const kind = row.getAttribute('data-chat-flow-kind')
|
|
1371
|
+
if (kind && kind !== 'user') continue
|
|
1372
|
+
// 用户正在这条消息里划词时**不要动它的 DOM**:替换文本节点会让选区失效,
|
|
1373
|
+
// 选区矩形随即变成全 0,批注气泡/编辑框就会定位到左上角(用户反馈过)。
|
|
1374
|
+
if (selectionTouches(row)) continue
|
|
1330
1375
|
let walker
|
|
1331
1376
|
try { walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null) } catch (e) { continue }
|
|
1332
1377
|
const targets = []
|
|
1333
1378
|
let n = walker.nextNode()
|
|
1334
1379
|
while (n) {
|
|
1335
|
-
|
|
1380
|
+
const parent = n.parentElement
|
|
1381
|
+
// 已经装饰过的部分(隐藏的位置行 / 紧凑引用块)不再重复处理
|
|
1382
|
+
const managed = parent && typeof parent.closest === 'function'
|
|
1383
|
+
? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
|
|
1384
|
+
if (!managed && HEADER_RE.test(n.nodeValue || '')) targets.push(n)
|
|
1336
1385
|
n = walker.nextNode()
|
|
1337
1386
|
}
|
|
1387
|
+
if (targets.length === 0) continue
|
|
1338
1388
|
let done = false
|
|
1339
1389
|
for (const t of targets) { try { if (transformTextNode(t)) done = true } catch (e) { /* ignore */ } }
|
|
1340
1390
|
if (done && row.dataset) row.dataset.dshqSent = '1'
|
|
@@ -1342,10 +1392,80 @@ window.__ModuleLoader__.load({
|
|
|
1342
1392
|
} catch (e) { report('sent:scan-throw', String((e && e.message) || e)) }
|
|
1343
1393
|
}
|
|
1344
1394
|
const schedule = () => { if (timer === null) timer = setTimeout(scan, 300) }
|
|
1395
|
+
// 消息从输入框进入对话区时,首帧先画出的是原始文本(含"【引用 · …】"位置行),
|
|
1396
|
+
// 300ms 防抖会让它闪一下。MutationObserver 回调在下一次绘制前执行,
|
|
1397
|
+
// 因此只要这次变动牵扯到"还没装饰过的用户消息行"就同步扫一次,首帧即完成隐藏;
|
|
1398
|
+
// 其余情况(例如助手流式输出)仍走防抖,不增加扫描开销。
|
|
1399
|
+
// 用户消息行有两副面孔:正式行带 anchor 属性;但实测"刚发出去"的那一下,
|
|
1400
|
+
// 宿主先渲染出 <div class="cJsG2q_userRow">(还没有 data-chat-anchor-key),
|
|
1401
|
+
// 等属性挂上才轮得到插件 —— 那段空窗就是用户看到的闪。两类都按用户行处理。
|
|
1402
|
+
const USER_ROW_SEL = '[data-chat-anchor-key][data-chat-flow-kind="user"], [class*="userRow" i]'
|
|
1403
|
+
const asElement = (node) => (node && node.nodeType === 1 ? node : (node && node.parentElement) || null)
|
|
1404
|
+
// 行里是否还存在"没被插件接管过的"位置行文本。
|
|
1405
|
+
// 宿主会在渲染后重排/替换气泡内容(实测发送后约 120ms 会有一次),
|
|
1406
|
+
// 那时我们贴好的隐藏 span 会被换回原始文本 —— 用内容判断而不是用标记判断,
|
|
1407
|
+
// 才能在重渲染的同一帧里立刻补装饰。
|
|
1408
|
+
const rowHasRawHeader = (row) => {
|
|
1409
|
+
try {
|
|
1410
|
+
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT, null)
|
|
1411
|
+
let n = walker.nextNode()
|
|
1412
|
+
while (n) {
|
|
1413
|
+
const parent = n.parentElement
|
|
1414
|
+
const managed = parent && typeof parent.closest === 'function'
|
|
1415
|
+
? parent.closest('.dshq-sent-header, .dshq-sent-quote') : null
|
|
1416
|
+
if (!managed && HEADER_RE.test(n.nodeValue || '')) return true
|
|
1417
|
+
n = walker.nextNode()
|
|
1418
|
+
}
|
|
1419
|
+
return false
|
|
1420
|
+
} catch (e) { return false }
|
|
1421
|
+
}
|
|
1422
|
+
const touchesPendingUserRow = (node) => {
|
|
1423
|
+
const el = asElement(node)
|
|
1424
|
+
if (!el || typeof el.closest !== 'function') return false
|
|
1425
|
+
const row = el.closest(USER_ROW_SEL)
|
|
1426
|
+
// ① 变动落在用户消息行内部(含行自身):只要还能找到未接管的位置行就立刻扫
|
|
1427
|
+
if (row) {
|
|
1428
|
+
const kind = row.getAttribute('data-chat-flow-kind')
|
|
1429
|
+
if (kind && kind !== 'user') return false
|
|
1430
|
+
return rowHasRawHeader(row)
|
|
1431
|
+
}
|
|
1432
|
+
// ② 新增的子树里带着这样的用户消息行(宿主可能整块替换容器)
|
|
1433
|
+
if (typeof el.querySelector === 'function') {
|
|
1434
|
+
try {
|
|
1435
|
+
for (const r of el.querySelectorAll(USER_ROW_SEL)) {
|
|
1436
|
+
const kind = r.getAttribute('data-chat-flow-kind')
|
|
1437
|
+
if (kind && kind !== 'user') continue
|
|
1438
|
+
if (rowHasRawHeader(r)) return true
|
|
1439
|
+
}
|
|
1440
|
+
} catch (e) { /* ignore */ }
|
|
1441
|
+
}
|
|
1442
|
+
return false
|
|
1443
|
+
}
|
|
1444
|
+
const onMutations = (records) => {
|
|
1445
|
+
let urgent = false
|
|
1446
|
+
try {
|
|
1447
|
+
for (const r of records) {
|
|
1448
|
+
const cands = [r.target]
|
|
1449
|
+
const added = r.addedNodes
|
|
1450
|
+
if (added) { for (const n of added) cands.push(n) }
|
|
1451
|
+
for (const c of cands) {
|
|
1452
|
+
if (!urgent && touchesPendingUserRow(c)) urgent = true
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
if (urgent) scan()
|
|
1456
|
+
} catch (e) { /* ignore */ }
|
|
1457
|
+
schedule()
|
|
1458
|
+
}
|
|
1345
1459
|
scan()
|
|
1346
1460
|
try {
|
|
1347
|
-
observer = new MutationObserver(
|
|
1348
|
-
observer.observe(document.body, {
|
|
1461
|
+
observer = new MutationObserver(onMutations)
|
|
1462
|
+
observer.observe(document.body, {
|
|
1463
|
+
childList: true,
|
|
1464
|
+
subtree: true,
|
|
1465
|
+
characterData: true,
|
|
1466
|
+
attributes: true,
|
|
1467
|
+
attributeFilter: ['data-chat-anchor-key', 'data-chat-flow-kind'],
|
|
1468
|
+
})
|
|
1349
1469
|
} catch (e) { report('sent:observer-throw', String((e && e.message) || e)) }
|
|
1350
1470
|
return () => {
|
|
1351
1471
|
if (timer !== null) clearTimeout(timer)
|
|
@@ -1364,6 +1484,7 @@ window.__ModuleLoader__.load({
|
|
|
1364
1484
|
let timer = null
|
|
1365
1485
|
let observer = null
|
|
1366
1486
|
const isSingleLine = (t) => t !== '' && t.indexOf('\n') === -1
|
|
1487
|
+
let lastSig = ''
|
|
1367
1488
|
const decorate = () => {
|
|
1368
1489
|
timer = null
|
|
1369
1490
|
try {
|
|
@@ -1382,7 +1503,23 @@ window.__ModuleLoader__.load({
|
|
|
1382
1503
|
if (!isSingleLine(t)) break
|
|
1383
1504
|
quoteEls.push(el)
|
|
1384
1505
|
}
|
|
1385
|
-
if (!headerEl)
|
|
1506
|
+
if (!headerEl) {
|
|
1507
|
+
// 诊断:编辑器里出现了"【引用"但没能匹配成"位置行 + 正文行"时,把结构打出来
|
|
1508
|
+
const raw = String(ed.textContent || '')
|
|
1509
|
+
if (raw.indexOf('【引用') >= 0) {
|
|
1510
|
+
const sig = raw.slice(0, 40) + '|' + kids.length
|
|
1511
|
+
if (sig !== lastSig) {
|
|
1512
|
+
lastSig = sig
|
|
1513
|
+
report('cmp:probe', {
|
|
1514
|
+
ms: lastInsertAt ? Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - lastInsertAt) : null,
|
|
1515
|
+
kids: kids.length,
|
|
1516
|
+
singles: kids.map((el) => (isSingleLine(String(el.textContent || '').trim()) ? 1 : 0)).join(''),
|
|
1517
|
+
first: String((kids[0] && kids[0].textContent) || '').slice(0, 24),
|
|
1518
|
+
})
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
continue
|
|
1522
|
+
}
|
|
1386
1523
|
headerEl.classList.add('dshq-cmp-header')
|
|
1387
1524
|
quoteEls.forEach((el, idx) => {
|
|
1388
1525
|
el.classList.add('dshq-cmp-quote')
|
|
@@ -1393,11 +1530,13 @@ window.__ModuleLoader__.load({
|
|
|
1393
1530
|
}
|
|
1394
1531
|
} catch (e) { report('cmp:scan-throw', String((e && e.message) || e)) }
|
|
1395
1532
|
}
|
|
1396
|
-
|
|
1533
|
+
// 关键:MutationObserver 回调在**下一次绘制之前**执行 —— 因此在这里同步装饰即可消除
|
|
1534
|
+
// "先露出完整引用块、稍后才变紧凑"的闪烁(之前用 setTimeout(40ms) 期间已经画了一帧)。
|
|
1535
|
+
// decorate() 只在缺少 class 时改动 DOM,不会形成观察循环。
|
|
1397
1536
|
decorate()
|
|
1398
1537
|
composerDecorateNow = decorate
|
|
1399
1538
|
try {
|
|
1400
|
-
observer = new MutationObserver(
|
|
1539
|
+
observer = new MutationObserver(() => { decorate() })
|
|
1401
1540
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
1402
1541
|
} catch (e) { report('cmp:observer-throw', String((e && e.message) || e)) }
|
|
1403
1542
|
return () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-quote-annotate-pro",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "选区引用与锚点批注(增强 fork):划词 → 批注 → 带精确锚点的引用;输入框上方只显示原文片段,点一下跳回原文;发送时把轮次/角色写入引用块,让模型精确知道你指的是哪一处。",
|
|
5
5
|
"keywords": ["dsh", "dsh-plugin", "deepseek-harness", "quote", "annotate", "selection", "reference", "anchor"],
|
|
6
6
|
"license": "MIT",
|