@yfwu2020/dsh-selection-explain 0.1.3 → 0.2.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/LICENSE +21 -0
- package/README.md +50 -6
- package/cordis.patch.yml +4 -0
- package/lib/client.js +727 -1
- package/lib/index.js +8 -0
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +20 -0
- package/package.json +4 -2
- package/scripts/smoke-bridge.mjs +365 -0
- package/scripts/test-bridge.mjs +408 -0
- package/scripts/test-client.mjs +221 -1
- package/src/client/index.js +727 -1
- package/src/index.ts +18 -0
package/src/client/index.js
CHANGED
|
@@ -1276,6 +1276,359 @@ window.__ModuleLoader__.load({
|
|
|
1276
1276
|
return String(text || '').replace(/\s+/g, ' ').trim()
|
|
1277
1277
|
}
|
|
1278
1278
|
|
|
1279
|
+
// ────────────────────── 侧边栏网页:iframe 划词桥 ──────────────────────
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* 侧边栏的 HTML 预览是**不透明源**沙箱 iframe(`sandbox="allow-scripts"`,
|
|
1283
|
+
* 刻意不给 `allow-same-origin`):父页面拿不到 contentDocument,
|
|
1284
|
+
* 顶层 `window.getSelection()` 永远是空的 —— 划词在那里等于瞎的。
|
|
1285
|
+
*
|
|
1286
|
+
* 解法只有一个方向:**让帧内自己上报**。下面这段脚本被注入进被预览的文档,
|
|
1287
|
+
* 在帧内读选区、就地采集上下文(算法与父页面 `collectContext` 完全一致),
|
|
1288
|
+
* 再用 postMessage 报出来;父页面把"帧内坐标 + iframe 的 getBoundingClientRect()"
|
|
1289
|
+
* 换算成视口坐标,复用同一套浮标与面板。
|
|
1290
|
+
*
|
|
1291
|
+
* 安全边界(不能退让):**不加 `allow-same-origin`**,帧仍是不透明源 ——
|
|
1292
|
+
* 预览页照样读不到 GUI 的 DOM / Cookie / localStorage,也照样不能导航顶层窗口。
|
|
1293
|
+
* 桥只往外报"选中了什么 + 周围那点文本",别的什么都不给。
|
|
1294
|
+
*
|
|
1295
|
+
* 两处细节不能省:
|
|
1296
|
+
* ① 监听挂在 **window** 上、并且用轮询兜底 —— 交互式预览的外层文档是 bootstrap,
|
|
1297
|
+
* 它 `document.open()/write()/close()` 会把 document 上的监听全部冲掉,
|
|
1298
|
+
* 只有 window 上的监听能活下来(见宿主 ui-sidebar-documentpreview 的 createHtmlDocument)。
|
|
1299
|
+
* ② 报的是**帧内视口坐标**(range.getClientRects 的最后一段),父页面再加偏移。
|
|
1300
|
+
*/
|
|
1301
|
+
function bridgeBody() {
|
|
1302
|
+
if (window.__dshSelBridge) return
|
|
1303
|
+
window.__dshSelBridge = 1
|
|
1304
|
+
var WINDOW_CHARS = 1500
|
|
1305
|
+
var KEY_CHARS = 300
|
|
1306
|
+
var MAX_SELECTION = 4000
|
|
1307
|
+
/** 鼠标按着超过这么久还没等到 mouseup,就当松手落在帧外了(别永久卡住上报)。 */
|
|
1308
|
+
var STALE_PRESS_MS = 6000
|
|
1309
|
+
/** 轮询连续这么多拍都读不到选区,才认为选区真没了(约 1.5s;瞬时读不到不清)。 */
|
|
1310
|
+
var NULL_TICKS_TO_CLEAR = 6
|
|
1311
|
+
/** 上一次报出去的选区指纹(文字 + 位置),用来去重。 */
|
|
1312
|
+
var last = ''
|
|
1313
|
+
/** 鼠标是不是按着(拖拽划词进行中)。 */
|
|
1314
|
+
var pressed = false
|
|
1315
|
+
var pressedAt = 0
|
|
1316
|
+
/** 上一拍轮询看到的指纹:用于"稳定一拍再报"。 */
|
|
1317
|
+
var pollKey = ''
|
|
1318
|
+
/** 轮询连续读不到选区的拍数。 */
|
|
1319
|
+
var nullTicks = 0
|
|
1320
|
+
|
|
1321
|
+
function norm(text) {
|
|
1322
|
+
return String(text || '').replace(/\s+/g, ' ').trim()
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function send(kind, sel) {
|
|
1326
|
+
try {
|
|
1327
|
+
parent.postMessage({ __dshSel: 1, kind: kind, sel: sel || null }, '*')
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
/* 顶层被导航走了之类:报不出去就算了,不能因为报错把预览页搞坏 */
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** 选区所在的语义容器:与父页面 pickContainer 同一套规则(往上找到第一个够长的元素)。 */
|
|
1334
|
+
function containerOf(node) {
|
|
1335
|
+
var element = node && node.nodeType === 1 ? node : node && node.parentElement
|
|
1336
|
+
while (element && element !== document.body) {
|
|
1337
|
+
var length = (element.innerText || '').length
|
|
1338
|
+
if (length >= 120) return element
|
|
1339
|
+
element = element.parentElement
|
|
1340
|
+
}
|
|
1341
|
+
return element || document.body
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function labelOf(element) {
|
|
1345
|
+
try {
|
|
1346
|
+
if (element && element.closest && element.closest('pre,code')) return '代码块'
|
|
1347
|
+
if (element && element.closest && element.closest('table')) return '表格'
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
/* 孤立节点上 closest 会抛,忽略 */
|
|
1350
|
+
}
|
|
1351
|
+
return ''
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** 这些标签的文本不算正文(脚本源码 / 样式 / 模板 / 文档头)。 */
|
|
1355
|
+
var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, TITLE: 1, META: 1, LINK: 1 }
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* 走 DOM 收集容器的可见文本,并给出 `target`(选区起点)在这段文本里的偏移。
|
|
1359
|
+
* 比 `Range.toString()` 多一步:跳过 script/style 等——否则页面里的 JS 源码会混进上下文。
|
|
1360
|
+
* target 是元素节点(整段选中的情况)时,取"进入该元素那一刻"的长度作为偏移;
|
|
1361
|
+
* 是文本节点时还要加上 `targetOffset`(选区在文本节点内的字符偏移)。
|
|
1362
|
+
*/
|
|
1363
|
+
function collectText(root, target, targetOffset) {
|
|
1364
|
+
var text = ''
|
|
1365
|
+
var offset = -1
|
|
1366
|
+
function walk(node) {
|
|
1367
|
+
if (!node) return
|
|
1368
|
+
if (node.nodeType === 3) {
|
|
1369
|
+
if (node === target) offset = text.length + (targetOffset || 0)
|
|
1370
|
+
text += node.nodeValue || ''
|
|
1371
|
+
return
|
|
1372
|
+
}
|
|
1373
|
+
if (node.nodeType !== 1) return
|
|
1374
|
+
if (SKIP_TAGS[node.tagName]) return
|
|
1375
|
+
if (node === target) offset = text.length
|
|
1376
|
+
var kids = node.childNodes || []
|
|
1377
|
+
for (var i = 0; i < kids.length; i += 1) walk(kids[i])
|
|
1378
|
+
}
|
|
1379
|
+
walk(root)
|
|
1380
|
+
return { text: text, offset: offset }
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** 读一次选区:文字 + 上下文窗口 + 帧内坐标;没有有效选区返回 null。 */
|
|
1384
|
+
function read() {
|
|
1385
|
+
var selection = window.getSelection && window.getSelection()
|
|
1386
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null
|
|
1387
|
+
var text = String(selection.toString() || '')
|
|
1388
|
+
if (!text.trim() || text.length > MAX_SELECTION) return null
|
|
1389
|
+
var range
|
|
1390
|
+
try {
|
|
1391
|
+
range = selection.getRangeAt(0)
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
return null
|
|
1394
|
+
}
|
|
1395
|
+
var start = range.startContainer
|
|
1396
|
+
var startElement = start && start.nodeType === 1 ? start : start && start.parentElement
|
|
1397
|
+
try {
|
|
1398
|
+
if (startElement && startElement.closest && startElement.closest('input,textarea')) return null
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
/* 输入框判断失败不致命 */
|
|
1401
|
+
}
|
|
1402
|
+
var container = containerOf(startElement)
|
|
1403
|
+
var out = { text: text.trim(), context: '', keyContext: '', label: labelOf(container) }
|
|
1404
|
+
try {
|
|
1405
|
+
// 容器正文:**走 DOM 取文本**,跳过 script / style / noscript / template / head。
|
|
1406
|
+
// 不能用 Range.toString():它把 <script> 里的源码也算进正文——实测(真浏览器冒烟)
|
|
1407
|
+
// 交互式预览里的上下文会带上整段页面 JS,喂给模型纯属噪音。
|
|
1408
|
+
var walked = collectText(container, range.startContainer, range.startOffset)
|
|
1409
|
+
var rawFull = walked.text
|
|
1410
|
+
var offset = walked.offset
|
|
1411
|
+
if (offset < 0) {
|
|
1412
|
+
// 选区起点不在这个容器里(跨容器选区之类):退回 Range 的算法,至少别丢上下文
|
|
1413
|
+
var prefix = document.createRange()
|
|
1414
|
+
prefix.selectNodeContents(container)
|
|
1415
|
+
prefix.setEnd(range.startContainer, range.startOffset)
|
|
1416
|
+
offset = prefix.toString().length
|
|
1417
|
+
}
|
|
1418
|
+
var from = Math.max(0, offset - WINDOW_CHARS)
|
|
1419
|
+
var to = Math.min(rawFull.length, offset + text.length + WINDOW_CHARS)
|
|
1420
|
+
var windowText = rawFull.slice(from, to)
|
|
1421
|
+
var relative = offset - from
|
|
1422
|
+
var probe = windowText.slice(relative, relative + text.length)
|
|
1423
|
+
var marked
|
|
1424
|
+
if (norm(probe) === norm(text)) {
|
|
1425
|
+
marked = windowText.slice(0, relative) + '【' + probe + '】' + windowText.slice(relative + text.length)
|
|
1426
|
+
} else {
|
|
1427
|
+
var found = rawFull.indexOf(text)
|
|
1428
|
+
if (found >= 0) {
|
|
1429
|
+
var f2 = Math.max(0, found - WINDOW_CHARS)
|
|
1430
|
+
var t2 = Math.min(rawFull.length, found + text.length + WINDOW_CHARS)
|
|
1431
|
+
var w2 = rawFull.slice(f2, t2)
|
|
1432
|
+
var r2 = found - f2
|
|
1433
|
+
marked = w2.slice(0, r2) + '【' + w2.slice(r2, r2 + text.length) + '】' + w2.slice(r2 + text.length)
|
|
1434
|
+
} else {
|
|
1435
|
+
marked = text + '\n---\n' + windowText
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
out.context = marked
|
|
1439
|
+
.replace(/\u00a0/g, ' ')
|
|
1440
|
+
.replace(/[ \t]+/g, ' ')
|
|
1441
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
1442
|
+
.trim()
|
|
1443
|
+
out.keyContext = rawFull
|
|
1444
|
+
.slice(Math.max(0, offset - KEY_CHARS), offset)
|
|
1445
|
+
.replace(/\u00a0/g, ' ')
|
|
1446
|
+
.replace(/\s+/g, ' ')
|
|
1447
|
+
.trim()
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
/* 取不到上下文就给空串:父页面照样能开面板,只是少了背景 */
|
|
1450
|
+
}
|
|
1451
|
+
var rect = null
|
|
1452
|
+
try {
|
|
1453
|
+
var rects = range.getClientRects()
|
|
1454
|
+
rect = rects && rects.length > 0 ? rects[rects.length - 1] : null
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
rect = null
|
|
1457
|
+
}
|
|
1458
|
+
if (!rect) {
|
|
1459
|
+
try {
|
|
1460
|
+
rect = range.getBoundingClientRect()
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
rect = null
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
if (!rect || (!rect.width && !rect.height)) return null
|
|
1466
|
+
out.rect = {
|
|
1467
|
+
x: rect.left,
|
|
1468
|
+
y: rect.top,
|
|
1469
|
+
right: rect.right,
|
|
1470
|
+
bottom: rect.bottom,
|
|
1471
|
+
w: rect.width,
|
|
1472
|
+
h: rect.height,
|
|
1473
|
+
}
|
|
1474
|
+
return out
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/** 选区指纹:文字 + 帧内位置(位置变了也要重报,父页面据此挪浮标)。 */
|
|
1478
|
+
function fingerprint(now) {
|
|
1479
|
+
if (!now) return ''
|
|
1480
|
+
return now.text + '\u0000' + now.rect.x + ',' + now.rect.y + ',' + now.rect.bottom + ',' + now.rect.right
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* 读一次并上报。
|
|
1485
|
+
*
|
|
1486
|
+
* `allowClear === false` 时**只报选区、不报"选区没了"**:滚动/改尺寸/轮询这些
|
|
1487
|
+
* "顺手重报一下位置"的路径不该有清空权 —— 页面重绘、动画、字体回流时
|
|
1488
|
+
* `getClientRects()` 会短暂拿不到矩形,一抖就 clear 会把浮标误收掉
|
|
1489
|
+
* (用户看到的就是"浮标自己消失了")。真正的清空信号来自 mouseup / keyup /
|
|
1490
|
+
* selectionchange 这些**用户动作**。
|
|
1491
|
+
*/
|
|
1492
|
+
function report(allowClear) {
|
|
1493
|
+
var now = read()
|
|
1494
|
+
if (!now) {
|
|
1495
|
+
if (last && allowClear !== false) {
|
|
1496
|
+
last = ''
|
|
1497
|
+
send('clear', null)
|
|
1498
|
+
}
|
|
1499
|
+
return
|
|
1500
|
+
}
|
|
1501
|
+
var key = fingerprint(now)
|
|
1502
|
+
if (key === last) return
|
|
1503
|
+
last = key
|
|
1504
|
+
send('selection', now)
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* 轮询兜底(250ms):只负责"没人通知我们"的情况。
|
|
1509
|
+
*
|
|
1510
|
+
* 三道闸门都是为了**时机**跟主会话一致(主会话只在 mouseup / keyup 后弹):
|
|
1511
|
+
* ① 鼠标按着(拖拽划词进行中)一律不报 —— 否则拖到一半浮标就冒出来,还跟着手指跑;
|
|
1512
|
+
* ② 选区还在变(这一拍与上一拍指纹不同)先记账,**稳定一拍**再报;
|
|
1513
|
+
* ③ 连续 NULL_TICKS_TO_CLEAR 拍都读不到选区才认为它真没了 —— 一次瞬时读不到
|
|
1514
|
+
* 只当页面在重绘,不清浮标(瞬时误清的表现就是"浮标自己消失")。
|
|
1515
|
+
* 松手落在帧外时不会有 mouseup:超过 STALE_PRESS_MS 就当已经松开,别把上报永久卡死。
|
|
1516
|
+
*/
|
|
1517
|
+
function tick() {
|
|
1518
|
+
if (pressed) {
|
|
1519
|
+
if (Date.now() - pressedAt < STALE_PRESS_MS) return
|
|
1520
|
+
pressed = false
|
|
1521
|
+
}
|
|
1522
|
+
var now = read()
|
|
1523
|
+
if (!now) {
|
|
1524
|
+
pollKey = ''
|
|
1525
|
+
nullTicks += 1
|
|
1526
|
+
if (nullTicks >= NULL_TICKS_TO_CLEAR && last) {
|
|
1527
|
+
last = ''
|
|
1528
|
+
send('clear', null)
|
|
1529
|
+
}
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
nullTicks = 0
|
|
1533
|
+
var key = fingerprint(now)
|
|
1534
|
+
if (key !== pollKey) {
|
|
1535
|
+
pollKey = key
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1538
|
+
if (key === last) return
|
|
1539
|
+
last = key
|
|
1540
|
+
send('selection', now)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
window.addEventListener('mousedown', function () {
|
|
1544
|
+
pressed = true
|
|
1545
|
+
pressedAt = Date.now()
|
|
1546
|
+
// 帧内按下 = 用户开始新动作 → 让父页面先把浮标收掉。
|
|
1547
|
+
// 主会话那边 document 的 mousedown 就是"点哪都先收起浮标";帧内的点击父页面收不到,
|
|
1548
|
+
// 不补这一条的话:在网页里点一下(取消选区/点别处)浮标会赖着不走。
|
|
1549
|
+
send('press', null)
|
|
1550
|
+
}, true)
|
|
1551
|
+
// 松手才是"划完了":与主会话一致(那边也是 mouseup 之后才弹浮标)
|
|
1552
|
+
window.addEventListener('mouseup', function () {
|
|
1553
|
+
pressed = false
|
|
1554
|
+
setTimeout(report, 0)
|
|
1555
|
+
}, true)
|
|
1556
|
+
window.addEventListener('keyup', function () { setTimeout(report, 0) }, true)
|
|
1557
|
+
// 焦点离开这一帧(点去别处)= 拖拽状态作废,免得 pressed 卡住
|
|
1558
|
+
window.addEventListener('blur', function () { pressed = false }, true)
|
|
1559
|
+
// 帧内滚动/改尺寸 → 位置变了,重报一次(父页面据此挪浮标)。这两条不许 clear
|
|
1560
|
+
window.addEventListener('scroll', function () {
|
|
1561
|
+
if (pressed) return
|
|
1562
|
+
setTimeout(function () {
|
|
1563
|
+
report(false)
|
|
1564
|
+
}, 60)
|
|
1565
|
+
}, true)
|
|
1566
|
+
window.addEventListener('resize', function () {
|
|
1567
|
+
if (!pressed) report(false)
|
|
1568
|
+
})
|
|
1569
|
+
window.addEventListener('message', function (event) {
|
|
1570
|
+
var data = event && event.data
|
|
1571
|
+
if (!data || data.__dshSel !== 1) return
|
|
1572
|
+
if (data.kind === 'ping') {
|
|
1573
|
+
send('hello', null)
|
|
1574
|
+
report()
|
|
1575
|
+
}
|
|
1576
|
+
if (data.kind === 'rescan') report()
|
|
1577
|
+
})
|
|
1578
|
+
try {
|
|
1579
|
+
document.addEventListener('selectionchange', function () {
|
|
1580
|
+
// 拖拽中不报;键盘扩选(Shift+方向键)没有 mousedown,照旧即时上报
|
|
1581
|
+
if (!pressed) report()
|
|
1582
|
+
})
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
/* 老环境没有 selectionchange:轮询兜着 */
|
|
1585
|
+
}
|
|
1586
|
+
setInterval(tick, 250)
|
|
1587
|
+
setTimeout(report, 60)
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** 注入标记:既用来认"这份 HTML 已经桥过",也是 iframe 元素上的记号。 */
|
|
1591
|
+
var BRIDGE_MARK = 'data-dsh-sel-bridge'
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* 把桥脚本插进一份 HTML(返回新串;没有可插的地方 / 已经插过 → null)。
|
|
1595
|
+
*
|
|
1596
|
+
* 基础预览(srcdoc)那份 HTML 已经过宿主 DOMPurify 清洗:无脚本、无外链,
|
|
1597
|
+
* 且带一条 `script-src 'none'` 的 CSP。我们**只放宽这一条**到 'unsafe-inline'
|
|
1598
|
+
* (好让桥跑起来),`default-src 'none'` / `connect-src 'none'` / `img-src data:`
|
|
1599
|
+
* 等其余限制原样保留 —— 预览页依然联不了网、加载不了外部资源。
|
|
1600
|
+
*/
|
|
1601
|
+
function bridgeIntoHtml(html) {
|
|
1602
|
+
var text = String(html || '')
|
|
1603
|
+
if (!text || text.indexOf(BRIDGE_MARK) >= 0) return null
|
|
1604
|
+
text = text.replace(/script-src\s+'none'/i, "script-src 'unsafe-inline'")
|
|
1605
|
+
var tag = '<scr' + 'ipt ' + BRIDGE_MARK + '>(' + String(bridgeBody) + ')();</scr' + 'ipt>'
|
|
1606
|
+
return insertIntoHtml(text, tag)
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* 插入位置按"尽量靠前、但别破坏文档"排序:
|
|
1611
|
+
* charset(中文标签不能变乱码)→ head → html → 第一个 script 之前 → doctype 之后 → 最前。
|
|
1612
|
+
*/
|
|
1613
|
+
function insertIntoHtml(html, tag) {
|
|
1614
|
+
var anchors = [/<meta[^>]+charset[^>]*>/i, /<head[^>]*>/i, /<html[^>]*>/i]
|
|
1615
|
+
for (var i = 0; i < anchors.length; i += 1) {
|
|
1616
|
+
var hit = anchors[i].exec(html)
|
|
1617
|
+
if (hit) {
|
|
1618
|
+
var cut = hit.index + hit[0].length
|
|
1619
|
+
return html.slice(0, cut) + tag + html.slice(cut)
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
var script = /<script/i.exec(html)
|
|
1623
|
+
if (script) return html.slice(0, script.index) + tag + html.slice(script.index)
|
|
1624
|
+
var doctype = /^\s*<!doctype[^>]*>/i.exec(html)
|
|
1625
|
+
if (doctype) {
|
|
1626
|
+
var end = doctype.index + doctype[0].length
|
|
1627
|
+
return html.slice(0, end) + tag + html.slice(end)
|
|
1628
|
+
}
|
|
1629
|
+
return tag + html
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1279
1632
|
// ────────────────────── 主逻辑 ──────────────────────
|
|
1280
1633
|
|
|
1281
1634
|
function apply(ctx) {
|
|
@@ -1289,6 +1642,11 @@ window.__ModuleLoader__.load({
|
|
|
1289
1642
|
raw: '',
|
|
1290
1643
|
/** 'idle' | 'loading' | 'streaming' | 'done' | 'error'。 */
|
|
1291
1644
|
phase: 'idle',
|
|
1645
|
+
/**
|
|
1646
|
+
* 当前选区是不是来自**侧边栏网页里的桥**(iframe 帧内上报)。
|
|
1647
|
+
* 为 true 时顶层选区塌掉不算"选区没了"——那种塌陷是我们点浮标造成的。
|
|
1648
|
+
*/
|
|
1649
|
+
bridgeActive: false,
|
|
1292
1650
|
/** 计时与统计。 */
|
|
1293
1651
|
startedAt: 0,
|
|
1294
1652
|
elapsed: 0,
|
|
@@ -1585,9 +1943,15 @@ window.__ModuleLoader__.load({
|
|
|
1585
1943
|
if (panelOpen) return
|
|
1586
1944
|
var selection = window.getSelection()
|
|
1587
1945
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
1946
|
+
// 侧边栏网页里的选区**不在本文档**:点浮标会让本文档的选区塌掉
|
|
1947
|
+
// (于是每次点浮标都会先跑到这里),所以只要帧内那条选区还活着就别收浮标。
|
|
1948
|
+
// 帧内清空选区时桥会发 clear,那里才是真正的"该收"信号。
|
|
1949
|
+
if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
|
|
1588
1950
|
hideButton()
|
|
1589
1951
|
return
|
|
1590
1952
|
}
|
|
1953
|
+
// 本文档里出现了真选区:帧内那条让位(两处同时有选区时以本文档为准)
|
|
1954
|
+
state.bridgeActive = false
|
|
1591
1955
|
// 划到自己界面上(胶囊/面板/历史列表)时什么都不做
|
|
1592
1956
|
try {
|
|
1593
1957
|
var ownRange = selection.getRangeAt(0)
|
|
@@ -1614,7 +1978,7 @@ window.__ModuleLoader__.load({
|
|
|
1614
1978
|
hideButton()
|
|
1615
1979
|
return
|
|
1616
1980
|
}
|
|
1617
|
-
state.selection = { text: text.trim(), range: range.cloneRange(), rect: rect }
|
|
1981
|
+
state.selection = { text: text.trim(), range: range.cloneRange(), rect: rect, source: 'document' }
|
|
1618
1982
|
showButton(rect)
|
|
1619
1983
|
}
|
|
1620
1984
|
|
|
@@ -1649,6 +2013,290 @@ window.__ModuleLoader__.load({
|
|
|
1649
2013
|
setButtonPop(false)
|
|
1650
2014
|
}
|
|
1651
2015
|
|
|
2016
|
+
// —— 侧边栏网页(iframe)划词桥的父侧接线 ——
|
|
2017
|
+
|
|
2018
|
+
/**
|
|
2019
|
+
* 我们桥过的帧:frame → 自己建的 blob url('' = srcdoc 帧,没有 blob 要回收)。
|
|
2020
|
+
* 用 Map 而不是数组:既要按帧查自己的 url,也要遍历找回消息来源对应的帧。
|
|
2021
|
+
*/
|
|
2022
|
+
var bridged = new Map()
|
|
2023
|
+
/** 总开关:host 的 bridgeSidebarPreview 说关就关(见下面 ping 分支)。 */
|
|
2024
|
+
var bridgeOn = true
|
|
2025
|
+
/** 扫描节流用的定时器 id。 */
|
|
2026
|
+
var bridgeScanId = 0
|
|
2027
|
+
var bridgeObserver = null
|
|
2028
|
+
|
|
2029
|
+
function ownedUrl(frame) {
|
|
2030
|
+
var url = bridged.get(frame)
|
|
2031
|
+
return url === undefined ? '' : url
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** 消息来源 → 帧(只认我们桥过的帧,别的窗口发来的消息一概不理)。 */
|
|
2035
|
+
function frameBySource(source) {
|
|
2036
|
+
var found = null
|
|
2037
|
+
bridged.forEach(function (url, frame) {
|
|
2038
|
+
if (found) return
|
|
2039
|
+
try {
|
|
2040
|
+
if (frame.contentWindow && frame.contentWindow === source) found = frame
|
|
2041
|
+
} catch (error) {
|
|
2042
|
+
/* 跨源取 contentWindow 可能抛,忽略 */
|
|
2043
|
+
}
|
|
2044
|
+
})
|
|
2045
|
+
return found
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/** 沙箱补一个 allow-scripts(**绝不补 allow-same-origin**):桥要跑起来。 */
|
|
2049
|
+
function ensureScriptsAllowed(frame) {
|
|
2050
|
+
var sandbox = frame.getAttribute('sandbox')
|
|
2051
|
+
if (sandbox === null) return // 没有 sandbox 属性 = 同源帧,不归这条路管
|
|
2052
|
+
if (!/(^|\s)allow-scripts(\s|$)/.test(sandbox)) {
|
|
2053
|
+
frame.setAttribute('sandbox', (sandbox + ' allow-scripts').trim())
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
/** 帧内坐标 → 视口坐标;选区滚出帧的可视区就返回 null(免得浮标飘到主会话上)。 */
|
|
2058
|
+
function mapFrameRect(frame, reported) {
|
|
2059
|
+
if (!reported) return null
|
|
2060
|
+
var box
|
|
2061
|
+
try {
|
|
2062
|
+
box = frame.getBoundingClientRect()
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
return null
|
|
2065
|
+
}
|
|
2066
|
+
if (!box || (!box.width && !box.height)) return null
|
|
2067
|
+
var left = box.left + Number(reported.x || 0)
|
|
2068
|
+
var top = box.top + Number(reported.y || 0)
|
|
2069
|
+
var right = box.left + Number(reported.right || 0)
|
|
2070
|
+
var bottom = box.top + Number(reported.bottom || 0)
|
|
2071
|
+
if (!(right > left)) right = left + Number(reported.w || 0)
|
|
2072
|
+
if (!(bottom > top)) bottom = top + Number(reported.h || 0)
|
|
2073
|
+
if (bottom < box.top + 2 || top > box.bottom - 2) return null
|
|
2074
|
+
top = clamp(top, box.top, box.bottom)
|
|
2075
|
+
bottom = clamp(bottom, box.top, box.bottom)
|
|
2076
|
+
left = clamp(left, box.left, box.right)
|
|
2077
|
+
right = clamp(right, box.left, box.right)
|
|
2078
|
+
return {
|
|
2079
|
+
left: left,
|
|
2080
|
+
top: top,
|
|
2081
|
+
right: right,
|
|
2082
|
+
bottom: bottom,
|
|
2083
|
+
width: Math.max(0, right - left),
|
|
2084
|
+
height: Math.max(0, bottom - top),
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** 面板里的位置标签:帧内认出来的(代码块/表格)优先,否则按帧的来源给。 */
|
|
2089
|
+
function labelForFrame(frame, frameLabel) {
|
|
2090
|
+
if (frameLabel === '代码块' || frameLabel === '表格') return frameLabel
|
|
2091
|
+
if (frame.getAttribute('data-html-preview') !== null) return '侧边栏网页'
|
|
2092
|
+
// 其他插件的网页(例如「图解」):用帧自己的 title 当位置标签,比笼统的"网页内容"有用
|
|
2093
|
+
var title = String(frame.getAttribute('title') || '')
|
|
2094
|
+
.replace(/\s+/g, ' ')
|
|
2095
|
+
.trim()
|
|
2096
|
+
return title ? title.slice(0, 24) : '网页内容'
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* 这一帧能不能桥。
|
|
2101
|
+
*
|
|
2102
|
+
* 只认"文档确实是我们能改写的那两种":`srcdoc` 或 `blob:` —— 远端站点 iframe 与
|
|
2103
|
+
* 路由 URL 的预览器(如 better-sidebar 的 HTML 预览)一概不动,它们的资源改写
|
|
2104
|
+
* 与生命周期归它们自己。
|
|
2105
|
+
*
|
|
2106
|
+
* 沙箱上分两档,因为"补 allow-scripts"是有代价的:
|
|
2107
|
+
* · 宿主自己的 HTML 预览(`data-html-preview`):内容已由宿主 DOMPurify 清洗成
|
|
2108
|
+
* "无脚本、无外链",补 allow-scripts 之后真正会跑的只有桥自己,可以补;
|
|
2109
|
+
* · 其他插件的网页(如「图解」的 `blob:` 帧):**只桥本来就允许脚本的**——
|
|
2110
|
+
* 不给别人的沙箱加权限,免得把一份刻意不跑脚本的文档变成会跑脚本。
|
|
2111
|
+
*/
|
|
2112
|
+
function bridgeableFrame(frame) {
|
|
2113
|
+
if (!frame || frame.nodeType !== 1 || !frame.isConnected) return false
|
|
2114
|
+
if (panel.contains(frame)) return false // 我们自己的小窗(含网页答案预览)不桥
|
|
2115
|
+
var hasSrcdoc = frame.getAttribute('srcdoc') !== null
|
|
2116
|
+
var src = frame.getAttribute('src') || ''
|
|
2117
|
+
if (!hasSrcdoc && !/^blob:/i.test(src)) return false
|
|
2118
|
+
var sandbox = frame.getAttribute('sandbox')
|
|
2119
|
+
if (sandbox === null) return false
|
|
2120
|
+
if (frame.getAttribute('data-html-preview') !== null) return true
|
|
2121
|
+
return /(^|\s)allow-scripts(\s|$)/.test(sandbox)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function bridgeFrame(frame) {
|
|
2125
|
+
if (!bridgeOn || !bridgeableFrame(frame)) return
|
|
2126
|
+
var srcdoc = frame.getAttribute('srcdoc')
|
|
2127
|
+
if (srcdoc !== null && srcdoc !== undefined) {
|
|
2128
|
+
if (srcdoc.indexOf(BRIDGE_MARK) >= 0) return // 已经桥过(我们自己写回去的那份)
|
|
2129
|
+
var next = bridgeIntoHtml(srcdoc)
|
|
2130
|
+
if (!next) return
|
|
2131
|
+
// 顺序有讲究(Chrome 153 实测):**先补沙箱、再改 srcdoc,且必须在同一个任务里**。
|
|
2132
|
+
// 先改 srcdoc 再补沙箱、或者分两个任务改,帧都不会带着 allow-scripts 重新加载
|
|
2133
|
+
// (脚本静默不跑,表现为"注入了但没反应")。
|
|
2134
|
+
ensureScriptsAllowed(frame)
|
|
2135
|
+
frame.setAttribute('data-dsh-sel-bridged', '1')
|
|
2136
|
+
frame.setAttribute('srcdoc', next)
|
|
2137
|
+
bridged.set(frame, '')
|
|
2138
|
+
return
|
|
2139
|
+
}
|
|
2140
|
+
var src = frame.getAttribute('src') || ''
|
|
2141
|
+
// 只动 blob:(宿主与「图解」这类插件都是用它装生成页的)。
|
|
2142
|
+
// 路由 URL / 远端地址一概不碰:那些的资源改写与生命周期归宿主,插手只会弄坏预览。
|
|
2143
|
+
if (!/^blob:/i.test(src)) return
|
|
2144
|
+
if (src === ownedUrl(frame)) return // 已经是我们换上去的那份
|
|
2145
|
+
fetch(src)
|
|
2146
|
+
.then(function (response) {
|
|
2147
|
+
// 原 blob 的 MIME **要跟着搬**:blob: 文档没有"父文档编码"可继承,
|
|
2148
|
+
// 编码只来自这个 content-type(或页面里的 meta)。「图解」建 blob 时写的是
|
|
2149
|
+
// `text/html;charset=utf-8`,我们若退回裸 `text/html`,页面又没写 meta charset 的话,
|
|
2150
|
+
// 整页中文会变成乱码(实测过:`鍥捐В椤?`)。
|
|
2151
|
+
var type = 'text/html'
|
|
2152
|
+
try {
|
|
2153
|
+
var header = response.headers && response.headers.get && response.headers.get('content-type')
|
|
2154
|
+
if (header) type = header
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
/* 取不到就退回 text/html:与原行为一致 */
|
|
2157
|
+
}
|
|
2158
|
+
return response.text().then(function (text) {
|
|
2159
|
+
return { text: text, type: type }
|
|
2160
|
+
})
|
|
2161
|
+
})
|
|
2162
|
+
.then(function (loaded) {
|
|
2163
|
+
if (!bridgeOn || !frame.isConnected) return
|
|
2164
|
+
var bridgedHtml = bridgeIntoHtml(loaded.text)
|
|
2165
|
+
if (!bridgedHtml) return
|
|
2166
|
+
var url = URL.createObjectURL(new Blob([bridgedHtml], { type: loaded.type }))
|
|
2167
|
+
var previous = ownedUrl(frame)
|
|
2168
|
+
bridged.set(frame, url)
|
|
2169
|
+
ensureScriptsAllowed(frame)
|
|
2170
|
+
frame.setAttribute('data-dsh-sel-bridged', '1')
|
|
2171
|
+
frame.setAttribute('src', url)
|
|
2172
|
+
if (previous) {
|
|
2173
|
+
try {
|
|
2174
|
+
URL.revokeObjectURL(previous)
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
/* 回收失败不致命 */
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
})
|
|
2180
|
+
.catch(function () {
|
|
2181
|
+
/* 读不到就放着:原预览照常显示,只是这一帧没有划词桥 */
|
|
2182
|
+
})
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/** 帧没了(关标签页/切源码视图)就顺手回收我们自己建的 blob,别漏。 */
|
|
2186
|
+
function pruneBridged() {
|
|
2187
|
+
var dead = []
|
|
2188
|
+
bridged.forEach(function (url, frame) {
|
|
2189
|
+
if (!frame.isConnected) dead.push(frame)
|
|
2190
|
+
})
|
|
2191
|
+
for (var i = 0; i < dead.length; i += 1) {
|
|
2192
|
+
var url = bridged.get(dead[i])
|
|
2193
|
+
if (url) {
|
|
2194
|
+
try {
|
|
2195
|
+
URL.revokeObjectURL(url)
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
/* 回收失败不致命 */
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
// 帧没了就不会再有 clear 消息了:把它占着的那条选区一起清掉,
|
|
2201
|
+
// 否则 `bridgeActive` 会一直挂着(表现为顶层选区塌掉也不收浮标)。
|
|
2202
|
+
if (state.selection && state.selection.frame === dead[i]) {
|
|
2203
|
+
state.selection = null
|
|
2204
|
+
state.bridgeActive = false
|
|
2205
|
+
hideButton()
|
|
2206
|
+
}
|
|
2207
|
+
bridged.delete(dead[i])
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function scanPreviewFrames() {
|
|
2212
|
+
if (!bridgeOn) return
|
|
2213
|
+
// 无浏览器测试的桩 DOM 没有 querySelectorAll:桥整体静默跳过
|
|
2214
|
+
if (typeof document.querySelectorAll !== 'function') return
|
|
2215
|
+
pruneBridged()
|
|
2216
|
+
// 扫全部 iframe 再按 bridgeableFrame 过滤:宿主的 HTML 预览、以及别的插件
|
|
2217
|
+
// 在侧边栏渲染的生成网页(「图解」那种 blob: 帧)都在这条路上,
|
|
2218
|
+
// 不能只看宿主的 data-html-preview 标记。
|
|
2219
|
+
var frames = document.querySelectorAll('iframe')
|
|
2220
|
+
for (var i = 0; i < frames.length; i += 1) bridgeFrame(frames[i])
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function scheduleBridgeScan() {
|
|
2224
|
+
if (bridgeScanId) return
|
|
2225
|
+
bridgeScanId = later(function () {
|
|
2226
|
+
bridgeScanId = 0
|
|
2227
|
+
scanPreviewFrames()
|
|
2228
|
+
}, 120)
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* 帧内桥报过来的选区:换算坐标 → 复用同一套浮标与面板。
|
|
2233
|
+
*
|
|
2234
|
+
* 只认"我们桥过的帧"发来的消息(`frameBySource`);面板开着时按主会话的老规矩
|
|
2235
|
+
* 不另开一个(要划新词先按 Esc 收起)。
|
|
2236
|
+
*/
|
|
2237
|
+
var offBridgeMessage = listen(window, 'message', function (event) {
|
|
2238
|
+
var data = event && event.data
|
|
2239
|
+
if (!data || data.__dshSel !== 1) return
|
|
2240
|
+
var frame = frameBySource(event.source)
|
|
2241
|
+
if (!frame) return
|
|
2242
|
+
if (data.kind === 'press') {
|
|
2243
|
+
// 帧内按下:与主会话一致——先收浮标。父页面收不到帧内的 mousedown,
|
|
2244
|
+
// 少了这一条,在网页里点一下(取消选区、点别处、点另一个网页)浮标就赖着不走。
|
|
2245
|
+
if (state.selection && state.selection.source === 'iframe' && state.selection.frame !== frame) {
|
|
2246
|
+
// 点的是**另一个**帧:那条选区已经不是用户此刻在看的了,连状态一起清
|
|
2247
|
+
state.selection = null
|
|
2248
|
+
state.bridgeActive = false
|
|
2249
|
+
}
|
|
2250
|
+
hideButton()
|
|
2251
|
+
return
|
|
2252
|
+
}
|
|
2253
|
+
if (data.kind === 'clear') {
|
|
2254
|
+
if (state.selection && state.selection.frame === frame) {
|
|
2255
|
+
state.selection = null
|
|
2256
|
+
state.bridgeActive = false
|
|
2257
|
+
hideButton()
|
|
2258
|
+
}
|
|
2259
|
+
return
|
|
2260
|
+
}
|
|
2261
|
+
if (data.kind !== 'selection' || !data.sel) return
|
|
2262
|
+
if (panelOpen) return
|
|
2263
|
+
if (!frame.isConnected) return
|
|
2264
|
+
var text = String(data.sel.text || '')
|
|
2265
|
+
if (!text.trim() || text.length > MAX_SELECTION) return
|
|
2266
|
+
var rect = mapFrameRect(frame, data.sel.rect)
|
|
2267
|
+
if (!rect) {
|
|
2268
|
+
// 选区滚出帧的可视区:把浮标收掉(帧内再滚回来会重新报)
|
|
2269
|
+
if (state.selection && state.selection.frame === frame) hideButton()
|
|
2270
|
+
return
|
|
2271
|
+
}
|
|
2272
|
+
state.selection = {
|
|
2273
|
+
text: text.trim(),
|
|
2274
|
+
/** 'document'(顶层选区,缺省)| 'iframe'(侧边栏网页,上下文由帧内桥给)。 */
|
|
2275
|
+
source: 'iframe',
|
|
2276
|
+
frame: frame,
|
|
2277
|
+
rect: rect,
|
|
2278
|
+
frameRect: data.sel.rect,
|
|
2279
|
+
context: String(data.sel.context || ''),
|
|
2280
|
+
keyContext: String(data.sel.keyContext || ''),
|
|
2281
|
+
label: labelForFrame(frame, data.sel.label),
|
|
2282
|
+
}
|
|
2283
|
+
state.bridgeActive = true
|
|
2284
|
+
showButton(rect)
|
|
2285
|
+
})
|
|
2286
|
+
|
|
2287
|
+
// 侧边栏里的预览帧是宿主 React 渲染的:新开标签、切预览/源码、文件变了都会换帧,
|
|
2288
|
+
// 所以既要初始扫一遍,也要盯着 DOM(只盯 srcdoc/src 两个属性 + 子节点增删)。
|
|
2289
|
+
if (typeof MutationObserver === 'function') {
|
|
2290
|
+
bridgeObserver = new MutationObserver(scheduleBridgeScan)
|
|
2291
|
+
bridgeObserver.observe(document.body || document.documentElement, {
|
|
2292
|
+
childList: true,
|
|
2293
|
+
subtree: true,
|
|
2294
|
+
attributes: true,
|
|
2295
|
+
attributeFilter: ['srcdoc', 'src'],
|
|
2296
|
+
})
|
|
2297
|
+
}
|
|
2298
|
+
scheduleBridgeScan()
|
|
2299
|
+
|
|
1652
2300
|
// —— 面板 ——
|
|
1653
2301
|
|
|
1654
2302
|
/** 当前会话 id(客户端 sessions 快照;取不到就留空,host 会跳过会话背景)。 */
|
|
@@ -1745,6 +2393,11 @@ window.__ModuleLoader__.load({
|
|
|
1745
2393
|
}
|
|
1746
2394
|
|
|
1747
2395
|
function openForSelection(selection) {
|
|
2396
|
+
// 侧边栏网页里的选区:父页面读不到那个文档,上下文只能由帧内桥就地采好带过来
|
|
2397
|
+
if (selection.source === 'iframe') {
|
|
2398
|
+
openPanelWith(selection.text, selection.context || '', selection.label, selection.rect, selection.keyContext)
|
|
2399
|
+
return
|
|
2400
|
+
}
|
|
1748
2401
|
var contextInfo = collectContext(selection.range ? rangeSelection(selection.range) : window.getSelection())
|
|
1749
2402
|
openPanelWith(selection.text, contextInfo.context, contextInfo.label, selection.rect, contextInfo.keyContext)
|
|
1750
2403
|
}
|
|
@@ -4312,6 +4965,8 @@ window.__ModuleLoader__.load({
|
|
|
4312
4965
|
.then(function (data) {
|
|
4313
4966
|
modelCatalog.current = (data && data.route) || null
|
|
4314
4967
|
modelCatalog.stages = { chat: (data && data.reasoningEffortByStage && data.reasoningEffortByStage.chat) || '' }
|
|
4968
|
+
// 侧边栏网页划词桥的总开关(host 配置 bridgeSidebarPreview,默认开)
|
|
4969
|
+
if (data && data.bridgeSidebarPreview === false) bridgeOn = false
|
|
4315
4970
|
paintModelPill()
|
|
4316
4971
|
return null
|
|
4317
4972
|
})
|
|
@@ -4632,11 +5287,61 @@ window.__ModuleLoader__.load({
|
|
|
4632
5287
|
resizing: function () {
|
|
4633
5288
|
return !!pillObserver
|
|
4634
5289
|
},
|
|
5290
|
+
/**
|
|
5291
|
+
* 自检用:侧边栏网页划词桥的状态。
|
|
5292
|
+
* frames 里的每一项对应一个"桥过的预览帧";own=true 表示那一帧的文档
|
|
5293
|
+
* 是我们自己重发过的 blob(交互式预览走这条)。
|
|
5294
|
+
*/
|
|
5295
|
+
bridge: function () {
|
|
5296
|
+
var frames = []
|
|
5297
|
+
bridged.forEach(function (url, frame) {
|
|
5298
|
+
frames.push({
|
|
5299
|
+
connected: frame.isConnected === true,
|
|
5300
|
+
own: !!url,
|
|
5301
|
+
srcdoc: frame.getAttribute('srcdoc') !== null,
|
|
5302
|
+
sandbox: frame.getAttribute('sandbox'),
|
|
5303
|
+
})
|
|
5304
|
+
})
|
|
5305
|
+
return {
|
|
5306
|
+
on: bridgeOn,
|
|
5307
|
+
frames: frames,
|
|
5308
|
+
active: !!state.bridgeActive,
|
|
5309
|
+
selection: state.selection && state.selection.source === 'iframe' ? state.selection.text : '',
|
|
5310
|
+
}
|
|
5311
|
+
},
|
|
5312
|
+
/** 自检用:扫一遍预览帧(等价于 DOM 变动后的那次自动扫描)。 */
|
|
5313
|
+
bridgeScan: function () {
|
|
5314
|
+
scanPreviewFrames()
|
|
5315
|
+
return window.__dshSelectionExplain.bridge()
|
|
5316
|
+
},
|
|
5317
|
+
/** 自检用:当前选区的来源与标签(source=iframe 表示来自侧边栏网页里的桥)。 */
|
|
5318
|
+
selection: function () {
|
|
5319
|
+
if (!state.selection) return null
|
|
5320
|
+
return {
|
|
5321
|
+
text: state.selection.text,
|
|
5322
|
+
source: state.selection.source || 'document',
|
|
5323
|
+
label: state.selection.label || '',
|
|
5324
|
+
context: state.selection.context || '',
|
|
5325
|
+
keyContext: state.selection.keyContext || '',
|
|
5326
|
+
}
|
|
5327
|
+
},
|
|
5328
|
+
/** 自检用:最近一次请求载荷的关键字段(标签 / 上下文 / 选中文字)。 */
|
|
5329
|
+
payload: function () {
|
|
5330
|
+
if (!state.payload) return null
|
|
5331
|
+
return {
|
|
5332
|
+
text: state.payload.text,
|
|
5333
|
+
label: state.payload.label,
|
|
5334
|
+
context: state.payload.context,
|
|
5335
|
+
kind: state.payload.kind,
|
|
5336
|
+
stage: state.payload.stage,
|
|
5337
|
+
}
|
|
5338
|
+
},
|
|
4635
5339
|
/** 模拟"刷新页面后":内存里那段小窗没了,只剩 host 上的历史。 */
|
|
4636
5340
|
reset: function () {
|
|
4637
5341
|
closePanel()
|
|
4638
5342
|
state.payload = null
|
|
4639
5343
|
state.selection = null
|
|
5344
|
+
state.bridgeActive = false
|
|
4640
5345
|
state.parts = { translation: '', detail: '' }
|
|
4641
5346
|
state.turns = []
|
|
4642
5347
|
state.raw = ''
|
|
@@ -4688,6 +5393,7 @@ window.__ModuleLoader__.load({
|
|
|
4688
5393
|
offPillClick()
|
|
4689
5394
|
offPillKey()
|
|
4690
5395
|
offPreviewHeight()
|
|
5396
|
+
offBridgeMessage()
|
|
4691
5397
|
offCellMd()
|
|
4692
5398
|
offCellWeb()
|
|
4693
5399
|
offPrefKeys()
|
|
@@ -4708,6 +5414,26 @@ window.__ModuleLoader__.load({
|
|
|
4708
5414
|
pillObserver = null
|
|
4709
5415
|
}
|
|
4710
5416
|
if (typeof offSlot === 'function') offSlot()
|
|
5417
|
+
// 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
|
|
5418
|
+
if (bridgeScanId) clearTimeout(bridgeScanId)
|
|
5419
|
+
bridgeScanId = 0
|
|
5420
|
+
if (bridgeObserver) {
|
|
5421
|
+
try {
|
|
5422
|
+
bridgeObserver.disconnect()
|
|
5423
|
+
} catch (error) {
|
|
5424
|
+
/* noop */
|
|
5425
|
+
}
|
|
5426
|
+
bridgeObserver = null
|
|
5427
|
+
}
|
|
5428
|
+
bridged.forEach(function (url) {
|
|
5429
|
+
if (!url) return
|
|
5430
|
+
try {
|
|
5431
|
+
URL.revokeObjectURL(url)
|
|
5432
|
+
} catch (error) {
|
|
5433
|
+
/* noop */
|
|
5434
|
+
}
|
|
5435
|
+
})
|
|
5436
|
+
bridged.clear()
|
|
4711
5437
|
state.turns = []
|
|
4712
5438
|
if (state.request) state.request.abort()
|
|
4713
5439
|
for (var i = 0; i < timers.length; i++) clearTimeout(timers[i])
|