@yfwu2020/dsh-selection-explain 0.1.2 → 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/lib/client.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])