dsh-design-studio 0.8.0 → 0.10.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.en.md +3 -2
- package/README.md +3 -2
- package/cordis.patch.yml +6 -4
- package/lib/browser.js +34 -0
- package/lib/client.js +1993 -126
- package/lib/core.js +26 -12
- package/lib/handoff.js +126 -11
- package/lib/index.js +17 -10
- package/lib/look.js +19 -4
- package/lib/picker.js +799 -66
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/skills/ui-design/SKILL.md +4 -3
- package/skills/ui-design/references/app.md +1 -1
- package/skills/ui-design/references/desktop.md +24 -0
- package/skills/ui-design/references/miniprogram.md +36 -0
package/lib/picker.js
CHANGED
|
@@ -58,6 +58,80 @@
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// 拖着文件进了这一页(页面铺满右侧栏时,外面的右侧栏收不到拖动):告诉右侧栏亮出「松开打开 / 当参考图」的提示,
|
|
62
|
+
// 松手也在这里接住、把文件转给右侧栏。提示只是一层画、不挡事件:页面自己的上传区照样收得到后面的拖动
|
|
63
|
+
// 页面自己里面拖的(拖一下页面上的图、页面自己的拖放)不算:那不是要打开什么
|
|
64
|
+
// 用 drag 不用 dragstart:页面取消了 dragstart(轮播图常这么做)就不会有 drag,也就不会有 dragend 来清掉标记
|
|
65
|
+
let ownDrag = false
|
|
66
|
+
let dropOwn = false
|
|
67
|
+
addEventListener('drag', () => { ownDrag = true }, true)
|
|
68
|
+
addEventListener('dragend', () => { ownDrag = false }, true)
|
|
69
|
+
addEventListener('pointerup', () => { ownDrag = false }, true)
|
|
70
|
+
const hasFiles = (dt) => !!dt && [...(dt.types || [])].includes('Files')
|
|
71
|
+
// 页面自己接这次拖动:上传区在 dragover 上 preventDefault(或者拦住不让冒泡,见下面捕获阶段那个),或者落在 <input type=file> 上
|
|
72
|
+
const pageTakes = (e) => e.defaultPrevented || (typeof e.composedPath === 'function' ? e.composedPath() : [e.target]).some((n) => n && n.tagName === 'INPUT' && String(n.type).toLowerCase() === 'file')
|
|
73
|
+
let lastIn = 0
|
|
74
|
+
let took = false // 刚才那下是页面自己接的:收起提示,松手也归页面
|
|
75
|
+
const seen = new WeakSet() // 冒泡阶段见到过的 dragover(按事件记:页面忙时几个 dragover 会连着处理)
|
|
76
|
+
const pageTook = () => {
|
|
77
|
+
if (took) return
|
|
78
|
+
took = true
|
|
79
|
+
lastIn = 0
|
|
80
|
+
post({ type: 'dragOut' })
|
|
81
|
+
}
|
|
82
|
+
// 只看 dragover:同一个元素上拖第二次时浏览器不一定再给 dragenter;dragenter 时也还不知道页面接不接
|
|
83
|
+
// 挂在 window 冒泡阶段的最后,页面的处理先跑完;页面全局拦了 dragover 防误拖的就当页面接了(那种页面用「浏览…」或地址栏打开)
|
|
84
|
+
const onOver = (e) => {
|
|
85
|
+
seen.add(e)
|
|
86
|
+
const dt = e.dataTransfer
|
|
87
|
+
if (ownDrag || !hasFiles(dt)) return
|
|
88
|
+
if (pageTakes(e)) return pageTook()
|
|
89
|
+
took = false
|
|
90
|
+
e.preventDefault() // 这里能松手:不然浏览器把文件当网页打开(或者根本不给 drop)
|
|
91
|
+
dt.dropEffect = 'copy'
|
|
92
|
+
const now = Date.now()
|
|
93
|
+
if (now - lastIn < 200) return // 拖着时 dragover 一直来,隔一会儿报一次就够
|
|
94
|
+
lastIn = now
|
|
95
|
+
post({ type: 'dragIn', items: [...(dt.items || [])].slice(0, 20).map((x) => ({ kind: String(x.kind), type: String(x.type) })) })
|
|
96
|
+
}
|
|
97
|
+
const onDrop = (e) => {
|
|
98
|
+
const dt = e.dataTransfer
|
|
99
|
+
const own = dropOwn
|
|
100
|
+
dropOwn = false
|
|
101
|
+
if (own || !hasFiles(dt)) return
|
|
102
|
+
if (took || pageTakes(e)) { took = false; return }
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
const files = []
|
|
105
|
+
for (const x of [...(dt.items || [])].slice(0, 20)) {
|
|
106
|
+
if (x.kind !== 'file') continue
|
|
107
|
+
// 文件夹只有 entry 分得出来,得在 drop 里同步取
|
|
108
|
+
let dir = false
|
|
109
|
+
try { const en = typeof x.webkitGetAsEntry === 'function' ? x.webkitGetAsEntry() : null; dir = !!(en && en.isDirectory) } catch { dir = false }
|
|
110
|
+
let f = null
|
|
111
|
+
try { f = x.getAsFile() } catch { f = null }
|
|
112
|
+
if (f) files.push({ f, dir })
|
|
113
|
+
}
|
|
114
|
+
// File 能直接跟着消息过去(桌面版照样拿得到它的真实路径)
|
|
115
|
+
post({ type: 'dropIn', files })
|
|
116
|
+
}
|
|
117
|
+
// 页面在 window 上挂的整页上传常常比这里晚注册(type=module 的脚本、框架挂载后):每次事件在捕获阶段把上面两个摘下来重挂,
|
|
118
|
+
// 排到 window 冒泡监听的最后,页面的处理(不管什么时候挂的)都先跑完,才看它接没接。
|
|
119
|
+
// 重挂放在 document 的捕获阶段:window 捕获阶段的监听(页面的也算)都跑过了才轮到它
|
|
120
|
+
const last = (type, fn) => { removeEventListener(type, fn); addEventListener(type, fn) }
|
|
121
|
+
last('dragover', onOver)
|
|
122
|
+
last('drop', onDrop)
|
|
123
|
+
document.addEventListener('dragover', () => last('dragover', onOver), true)
|
|
124
|
+
document.addEventListener('drop', () => last('drop', onDrop), true)
|
|
125
|
+
addEventListener('dragover', (e) => {
|
|
126
|
+
if (ownDrag || !hasFiles(e.dataTransfer)) return
|
|
127
|
+
// 上传区拦住 dragover 不让冒泡(stopPropagation)时上面那个收不到:这一轮事件发完还没见到它,就是页面接了
|
|
128
|
+
setTimeout(() => { if (!seen.has(e)) pageTook() }, 0)
|
|
129
|
+
}, true)
|
|
130
|
+
addEventListener('drop', () => {
|
|
131
|
+
dropOwn = ownDrag
|
|
132
|
+
ownDrag = false
|
|
133
|
+
}, true)
|
|
134
|
+
|
|
61
135
|
// ── 描述一个元素 ──────────────────────────────────────────────────────────
|
|
62
136
|
|
|
63
137
|
const esc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/[^\w-]/g, '\\$&'))
|
|
@@ -107,7 +181,7 @@
|
|
|
107
181
|
}
|
|
108
182
|
const full = el.outerHTML.replace(/\s+/g, ' ')
|
|
109
183
|
if (full.length <= max) return full
|
|
110
|
-
const open = shallow.outerHTML.replace(/<\/[^>]+>$/, '')
|
|
184
|
+
const open = shallow.outerHTML.replace(/<\/[^>]+>$/, '').replace(/\s+/g, ' ')
|
|
111
185
|
const kids = el.children.length
|
|
112
186
|
const txt = textOf(el)
|
|
113
187
|
const inner = kids ? ` …${kids} 个子元素… ` : clip(txt, 60)
|
|
@@ -1385,17 +1459,27 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1385
1459
|
const URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
|
|
1386
1460
|
|
|
1387
1461
|
/** 把一段 CSS 值里的 url(...) 都换成 data URL */
|
|
1388
|
-
async function inlineUrls(value, base, inline) {
|
|
1462
|
+
async function inlineUrls(value, base, inline, idp = '') {
|
|
1389
1463
|
if (!value || value.indexOf('url(') < 0) return value
|
|
1464
|
+
// 指着这一页里的东西(SVG 的渐变、裁剪、滤镜:url(#area)):留成页内引用,别当文件去下载
|
|
1465
|
+
const page = String(base || '').split('#')[0]
|
|
1466
|
+
const local = new Map()
|
|
1390
1467
|
const jobs = []
|
|
1391
|
-
value.replace(URL_RE, (m, q, u) => {
|
|
1468
|
+
value.replace(URL_RE, (m, q, u) => {
|
|
1469
|
+
if (u.startsWith('#')) local.set(u, '#' + idp + u.slice(1))
|
|
1470
|
+
else {
|
|
1471
|
+
try { const a = new URL(u, base); if (a.hash && a.href.split('#')[0] === page) local.set(u, '#' + idp + a.hash.slice(1)) } catch { /* 怪地址 */ }
|
|
1472
|
+
}
|
|
1473
|
+
if (!local.has(u)) jobs.push(u)
|
|
1474
|
+
return m
|
|
1475
|
+
})
|
|
1392
1476
|
const map = new Map()
|
|
1393
1477
|
await Promise.all(jobs.map(async (u) => {
|
|
1394
1478
|
let abs = u
|
|
1395
1479
|
try { abs = new URL(u, base).href } catch { /* 原样 */ }
|
|
1396
1480
|
map.set(u, await inline(abs))
|
|
1397
1481
|
}))
|
|
1398
|
-
return value.replace(URL_RE, (m, q, u) => (map.get(u) ? `url("${map.get(u)}")` : 'none'))
|
|
1482
|
+
return value.replace(URL_RE, (m, q, u) => (local.has(u) ? `url("${local.get(u)}")` : map.get(u) ? `url("${map.get(u)}")` : 'none'))
|
|
1399
1483
|
}
|
|
1400
1484
|
|
|
1401
1485
|
function styleText(cs, def, out) {
|
|
@@ -1416,7 +1500,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1416
1500
|
let text = false
|
|
1417
1501
|
for (const n of el.childNodes) if (n.nodeType === 3 && n.nodeValue.trim()) { text = true; break }
|
|
1418
1502
|
if (!text) return false
|
|
1419
|
-
const r =
|
|
1503
|
+
const r = el.ownerDocument.createRange()
|
|
1420
1504
|
r.selectNodeContents(el)
|
|
1421
1505
|
const rects = [...r.getClientRects()].filter((x) => x.width && x.height)
|
|
1422
1506
|
if (!rects.length || rects.length > 40) return false
|
|
@@ -1453,6 +1537,24 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1453
1537
|
const H = Math.ceil(full ? Math.max(de.scrollHeight, document.body ? document.body.scrollHeight : 0, innerHeight) : innerHeight)
|
|
1454
1538
|
const box = makeSandbox()
|
|
1455
1539
|
const inline = makeInliner()
|
|
1540
|
+
// 只截看得到的这一屏(不是整页):页面滚过、里面的滚动区滚过、有贴住的(sticky)元素,画成图时都得照现在的样子摆
|
|
1541
|
+
const view = !full
|
|
1542
|
+
const stuck = view ? stickyShift(document) : new Map()
|
|
1543
|
+
// 页面滚过时 body 整体往上挪(transform),body 就成了里面固定元素的定位基准:跟着窗口走的固定元素按 body 的位置换算回去,还贴在窗口原来的地方
|
|
1544
|
+
const lift = view && !!(scrollX || scrollY) && !!document.body
|
|
1545
|
+
let bodyAt = null
|
|
1546
|
+
if (lift) {
|
|
1547
|
+
const br = document.body.getBoundingClientRect()
|
|
1548
|
+
const bcs = getComputedStyle(document.body)
|
|
1549
|
+
bodyAt = { l: br.left + (parseFloat(bcs.borderLeftWidth) || 0), t: br.top + (parseFloat(bcs.borderTopWidth) || 0) }
|
|
1550
|
+
}
|
|
1551
|
+
// 怪异模式下 body.scrollTop 就是页面滚动;内嵌页面的根元素滚过(它自己的页面滚动)也照样往回挪
|
|
1552
|
+
// 内嵌页面画的总是它现在看得到的那块:里面的滚动(整页导出时也算)都补
|
|
1553
|
+
const ownScroll = (el) => el.ownerDocument !== document || (view && el !== de && (el !== document.body || document.compatMode === 'CSS1Compat'))
|
|
1554
|
+
// 资源地址按元素所在的那页算(内嵌页面有自己的地址)
|
|
1555
|
+
const baseOf = (el) => { const b = el.ownerDocument.baseURI || ''; return /^https?:/.test(b) ? b : here() }
|
|
1556
|
+
// 子树里还在找定位基准的绝对 / 固定定位(a / f):滚动区的孩子要是改成相对定位,会把这些绝对定位抢过来(换了基准、还被滚动区裁掉)
|
|
1557
|
+
const openOf = new Map()
|
|
1456
1558
|
const pseudoRules = []
|
|
1457
1559
|
const families = new Set()
|
|
1458
1560
|
const jobs = []
|
|
@@ -1487,10 +1589,14 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1487
1589
|
return p
|
|
1488
1590
|
}
|
|
1489
1591
|
|
|
1490
|
-
|
|
1592
|
+
// ctx:往下传「以哪个滚过的滚动区为定位基准」——a:绝对定位的后代该往回挪多少,f:固定定位的(滚动区有变换时)
|
|
1593
|
+
const NO_SHIFT = { a: [0, 0], f: [0, 0] }
|
|
1594
|
+
const idPre = new Map([[document, '']])
|
|
1595
|
+
function walk(node, ctx = NO_SHIFT) {
|
|
1491
1596
|
if (node.nodeType === 3) return document.createTextNode(node.nodeValue)
|
|
1492
1597
|
if (node.nodeType !== 1) return null
|
|
1493
1598
|
const el = node
|
|
1599
|
+
const idp = idPre.get(el.ownerDocument) || ''
|
|
1494
1600
|
if (SKIP_TAGS.has(el.tagName.toUpperCase())) return null
|
|
1495
1601
|
if (el.tagName === 'STYLE' && el.namespaceURI !== SVGNS) return null
|
|
1496
1602
|
const cs = getComputedStyle(el)
|
|
@@ -1508,7 +1614,14 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1508
1614
|
for (const a of el.attributes) {
|
|
1509
1615
|
if (a.name === 'style' || a.name.startsWith('on') || a.name === 'class' && el.namespaceURI !== SVGNS) continue
|
|
1510
1616
|
if (a.name === 'srcset' || a.name === 'sizes' || a.name === 'loading') continue
|
|
1511
|
-
|
|
1617
|
+
let v = a.value
|
|
1618
|
+
// 内嵌页面的 id 和页内引用加上这一页的前缀:几页里同名的渐变、裁剪不会串
|
|
1619
|
+
if (idp) {
|
|
1620
|
+
if (a.name === 'id') v = idp + v
|
|
1621
|
+
else if (/(^|:)href$/.test(a.name) && v.startsWith('#')) v = '#' + idp + v.slice(1)
|
|
1622
|
+
else if (v.indexOf('url(') >= 0) v = v.replace(/url\(\s*(['"]?)#([^'")]+)\1\s*\)/g, (m, q, id) => `url(#${idp}${id})`)
|
|
1623
|
+
}
|
|
1624
|
+
try { c.setAttributeNS(a.namespaceURI, a.name, v) } catch { /* 怪属性名 */ }
|
|
1512
1625
|
}
|
|
1513
1626
|
}
|
|
1514
1627
|
if (el.namespaceURI === SVGNS && el.getAttribute('class')) c.setAttribute('class', el.getAttribute('class'))
|
|
@@ -1517,17 +1630,31 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1517
1630
|
const def = box.defaults(el)
|
|
1518
1631
|
let st = styleText(cs, def, withUrls)
|
|
1519
1632
|
if (el === de) st += 'overflow:hidden;'
|
|
1633
|
+
// iframe / 视频换成了 div:默认的 300×150、行内排列也得写明(和 iframe 默认一样的样式上面省掉了)
|
|
1634
|
+
if (tag === 'IFRAME' || tag === 'VIDEO' || tag === 'OBJECT' || tag === 'EMBED') {
|
|
1635
|
+
st += `display:${cs.display === 'inline' ? 'inline-block' : cs.display};width:${cs.width};height:${cs.height};box-sizing:${cs.boxSizing};vertical-align:${cs.verticalAlign};overflow:hidden;`
|
|
1636
|
+
}
|
|
1637
|
+
// 贴住了:画成图时没有滚动,按量出来的偏移摆回现在的位置
|
|
1638
|
+
if (stuck.has(el)) { const [dx, dy] = stuck.get(el); st += `position:relative;inset:auto;left:${dx}px;top:${dy}px;` }
|
|
1639
|
+
// 以滚过的滚动区为基准的绝对 / 固定定位:跟着内容滚,往回挪(不管隔了几层)
|
|
1640
|
+
const owe = el.namespaceURI === SVGNS ? null : cs.position === 'absolute' ? ctx.a : cs.position === 'fixed' ? ctx.f : null
|
|
1641
|
+
if (owe && (owe[0] || owe[1])) st += shiftCss(cs, owe[0], owe[1])
|
|
1642
|
+
if (lift && cs.position === 'fixed' && el.namespaceURI !== SVGNS && el.ownerDocument === document && document.body.contains(el) && !fixedHeld(el)) {
|
|
1643
|
+
const x = parseFloat(cs.left)
|
|
1644
|
+
const y = parseFloat(cs.top)
|
|
1645
|
+
if (Number.isFinite(x) && Number.isFinite(y)) st += `left:${x - bodyAt.l}px;top:${y - bodyAt.t}px;right:auto;bottom:auto;`
|
|
1646
|
+
}
|
|
1520
1647
|
// 原来一行放得下的字,画成图时也别折行(宽度是定死的,字体渲染差一丁点就会挤到第二行)
|
|
1521
1648
|
if (el.namespaceURI !== SVGNS && cs.whiteSpace !== 'nowrap' && oneLine(el)) st += 'white-space:nowrap;'
|
|
1522
1649
|
c.setAttribute('style', st)
|
|
1523
1650
|
families.add(cs.fontFamily)
|
|
1524
1651
|
if (withUrls.length) {
|
|
1652
|
+
// 等图片转好再换进去;换的时候读那一刻的 style(滚动区补的偏移是后来加上的,别盖掉)
|
|
1653
|
+
const vals = withUrls.map((p) => cs.getPropertyValue(p))
|
|
1525
1654
|
jobs.push((async () => {
|
|
1655
|
+
const done = await Promise.all(vals.map((v) => inlineUrls(v, baseOf(el), inline, idp)))
|
|
1526
1656
|
let s = c.getAttribute('style')
|
|
1527
|
-
|
|
1528
|
-
const v = cs.getPropertyValue(p)
|
|
1529
|
-
s = s.replace(`${p}:${v};`, `${p}:${await inlineUrls(v, here(), inline)};`)
|
|
1530
|
-
}
|
|
1657
|
+
withUrls.forEach((p, i) => { s = s.replace(`${p}:${vals[i]};`, `${p}:${done[i]};`) })
|
|
1531
1658
|
c.setAttribute('style', s)
|
|
1532
1659
|
})())
|
|
1533
1660
|
}
|
|
@@ -1538,12 +1665,12 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1538
1665
|
}
|
|
1539
1666
|
if (el.namespaceURI === SVGNS && el.localName === 'image') {
|
|
1540
1667
|
const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
|
1541
|
-
if (href) jobs.push(inline(new URL(href,
|
|
1668
|
+
if (href) jobs.push(inline(new URL(href, baseOf(el)).href).then((d) => { if (d) c.setAttribute('href', d) }))
|
|
1542
1669
|
}
|
|
1543
1670
|
if (el.namespaceURI === SVGNS && el.localName === 'use') {
|
|
1544
1671
|
const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href') || ''
|
|
1545
1672
|
let abs = ''
|
|
1546
|
-
try { abs = href && !href.startsWith('#') ? new URL(href,
|
|
1673
|
+
try { abs = href && !href.startsWith('#') ? new URL(href, baseOf(el)).href : '' } catch { /* 怪地址 */ }
|
|
1547
1674
|
if (abs && abs.includes('#')) {
|
|
1548
1675
|
jobs.push(spriteOf(abs).then((id) => {
|
|
1549
1676
|
if (!id) return
|
|
@@ -1558,7 +1685,9 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1558
1685
|
if (tag === 'TEXTAREA') c.textContent = el.value
|
|
1559
1686
|
if (tag === 'SELECT') c.setAttribute('data-v', el.value)
|
|
1560
1687
|
|
|
1561
|
-
// ::before / ::after
|
|
1688
|
+
// ::before / ::after(绝对 / 固定定位的:跟着它的定位基准滚,也算还在往外找基准的)
|
|
1689
|
+
let pa = false
|
|
1690
|
+
let pf = false
|
|
1562
1691
|
if (el.namespaceURI !== SVGNS) {
|
|
1563
1692
|
const pes = (tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder') ? ['::before', '::after', '::placeholder'] : ['::before', '::after']
|
|
1564
1693
|
for (const pe of pes) {
|
|
@@ -1568,18 +1697,82 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1568
1697
|
const cls = 'ddpx' + (++seq)
|
|
1569
1698
|
c.setAttribute('class', ((c.getAttribute('class') || '') + ' ' + cls).trim())
|
|
1570
1699
|
const urls = []
|
|
1571
|
-
|
|
1700
|
+
let body = styleText(ps, box.defaults(el, pe), urls)
|
|
1701
|
+
const pp = ps.position
|
|
1702
|
+
if (pp === 'absolute' || pp === 'fixed') {
|
|
1703
|
+
const mine = pp === 'absolute' ? holds(cs) : holds(cs, true)
|
|
1704
|
+
const owe = mine ? (ownScroll(el) ? [el.scrollLeft, el.scrollTop] : [0, 0]) : pp === 'absolute' ? ctx.a : ctx.f
|
|
1705
|
+
if (owe[0] || owe[1]) body += shiftCss(ps, owe[0], owe[1])
|
|
1706
|
+
if (!mine) { if (pp === 'absolute') pa = true; else pf = true }
|
|
1707
|
+
// 贴着窗口的(比如 body::before 铺的固定背景):页面滚过、body 整体挪了,按 body 的位置换算回去
|
|
1708
|
+
if (pp === 'fixed' && !mine && lift && el.ownerDocument === document && document.body.contains(el) && !fixedHeld(el)) {
|
|
1709
|
+
const x = parseFloat(ps.left)
|
|
1710
|
+
const y = parseFloat(ps.top)
|
|
1711
|
+
if (Number.isFinite(x) && Number.isFinite(y)) body += `left:${x - bodyAt.l}px;top:${y - bodyAt.t}px;right:auto;bottom:auto;`
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1572
1714
|
const i = pseudoRules.length
|
|
1573
1715
|
pseudoRules.push(`.${cls}${pe}{${body}}`)
|
|
1574
|
-
if (urls.length) jobs.push(inlineUrls(pseudoRules[i],
|
|
1716
|
+
if (urls.length) jobs.push(inlineUrls(pseudoRules[i], baseOf(el), inline, idp).then((t) => { pseudoRules[i] = t }))
|
|
1575
1717
|
}
|
|
1576
1718
|
}
|
|
1577
1719
|
|
|
1578
|
-
|
|
1720
|
+
// 下面这几种不往里走:自己是绝对 / 固定定位的也得记上(外层滚动区挪的时候要知道)
|
|
1721
|
+
if ((tag === 'IFRAME' || tag === 'CANVAS' || tag === 'VIDEO' || tag === 'TEXTAREA') && (cs.position === 'absolute' || cs.position === 'fixed')) {
|
|
1722
|
+
openOf.set(el, { a: cs.position === 'absolute', f: cs.position === 'fixed' })
|
|
1723
|
+
}
|
|
1724
|
+
if (tag === 'IFRAME') {
|
|
1725
|
+
// 同源的内嵌页面:把它的页面也复制进来(框住、按它自己的滚动摆);拿不到的(跨域)铺一层浅灰,看得出这里有东西没截到
|
|
1726
|
+
let doc = null
|
|
1727
|
+
try { doc = el.contentDocument } catch { doc = null }
|
|
1728
|
+
let inner = null
|
|
1729
|
+
if (doc && doc.documentElement && depth < 3) {
|
|
1730
|
+
depth++
|
|
1731
|
+
try { inner = walkIn(doc) } finally { depth-- }
|
|
1732
|
+
}
|
|
1733
|
+
// 里面固定定位的元素以这一框为准:框自己没变换的,补一个不动的变换
|
|
1734
|
+
if (inner) {
|
|
1735
|
+
c.setAttribute('style', c.getAttribute('style') + 'overflow:hidden;' + (cs.transform !== 'none' ? '' : 'transform:translate(0,0);'))
|
|
1736
|
+
c.appendChild(inner)
|
|
1737
|
+
} else c.setAttribute('style', c.getAttribute('style') + 'background-color:rgba(127,127,127,.15);')
|
|
1738
|
+
return c
|
|
1739
|
+
}
|
|
1740
|
+
if (tag === 'CANVAS' || tag === 'VIDEO' || tag === 'TEXTAREA') return c
|
|
1741
|
+
// 自己滚过的滚动区:里面的东西按滚动距离往回挪(图里没有滚动)
|
|
1742
|
+
const ox = el.namespaceURI !== SVGNS && ownScroll(el) ? el.scrollLeft : 0
|
|
1743
|
+
const oy = el.namespaceURI !== SVGNS && ownScroll(el) ? el.scrollTop : 0
|
|
1744
|
+
// 跟着内容滚的背景(background-attachment: local)也往回挪
|
|
1745
|
+
if ((ox || oy) && /local/.test(cs.backgroundAttachment)) {
|
|
1746
|
+
const att = cs.backgroundAttachment.split(',').map((x) => x.trim())
|
|
1747
|
+
const pos = cs.backgroundPosition.split(',').map((p, i) => {
|
|
1748
|
+
if (att[i % att.length] !== 'local') return p.trim()
|
|
1749
|
+
const [x = '0%', y = '0%'] = splitTop(p.trim())
|
|
1750
|
+
return `calc(${x} - ${ox}px) calc(${y} - ${oy}px)`
|
|
1751
|
+
})
|
|
1752
|
+
c.setAttribute('style', c.getAttribute('style') + `background-position:${pos.join(', ')};`)
|
|
1753
|
+
}
|
|
1754
|
+
let oa = false
|
|
1755
|
+
let of = false
|
|
1756
|
+
const kids = []
|
|
1757
|
+
// 自己是定位基准的:它的后代按它的滚动挪;不是的,接着用外面传进来的
|
|
1758
|
+
const hF = holds(cs, true)
|
|
1759
|
+
const hA = hF || cs.position !== 'static' // 和 holds(cs) 一样,少算一遍
|
|
1760
|
+
const sub = hA || hF ? { a: hA ? [ox, oy] : ctx.a, f: hF ? [ox, oy] : ctx.f } : ctx
|
|
1579
1761
|
for (const child of el.childNodes) {
|
|
1580
|
-
const k = walk(child)
|
|
1581
|
-
if (k)
|
|
1762
|
+
const k = walk(child, sub)
|
|
1763
|
+
if (k) kids.push([child, k])
|
|
1764
|
+
const o = child.nodeType === 1 && openOf.get(child)
|
|
1765
|
+
if (o) { oa = oa || o.a; of = of || o.f }
|
|
1582
1766
|
}
|
|
1767
|
+
if (ox || oy) scrollBack(kids, cs, ox, oy, stuck, openOf)
|
|
1768
|
+
for (const [, k] of kids) c.appendChild(k)
|
|
1769
|
+
if (hA) oa = false
|
|
1770
|
+
if (hF) of = false
|
|
1771
|
+
if (pa) oa = true
|
|
1772
|
+
if (pf) of = true
|
|
1773
|
+
if (cs.position === 'absolute') oa = true
|
|
1774
|
+
if (cs.position === 'fixed') of = true
|
|
1775
|
+
if (oa || of) openOf.set(el, { a: oa, f: of })
|
|
1583
1776
|
if (tag === 'SELECT') {
|
|
1584
1777
|
const o = el.selectedOptions && el.selectedOptions[0]
|
|
1585
1778
|
c.textContent = ''
|
|
@@ -1589,6 +1782,20 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1589
1782
|
}
|
|
1590
1783
|
return c
|
|
1591
1784
|
}
|
|
1785
|
+
// 内嵌页面的那一份:它自己的根元素也当滚动区(滚过就往回挪),不算外层页面的根
|
|
1786
|
+
let depth = 0
|
|
1787
|
+
const docs = new Set([document])
|
|
1788
|
+
function walkIn(doc) {
|
|
1789
|
+
// 内嵌页面里贴住的元素也按它现在的样子摆
|
|
1790
|
+
for (const [el, v] of stickyShift(doc)) stuck.set(el, v)
|
|
1791
|
+
docs.add(doc)
|
|
1792
|
+
if (!idPre.has(doc)) idPre.set(doc, `ddpf${++seq}-`)
|
|
1793
|
+
const r = doc.documentElement
|
|
1794
|
+
// 以整页为准的绝对定位跟着这一页滚
|
|
1795
|
+
const k = walk(r, { a: [r.scrollLeft, r.scrollTop], f: [0, 0] })
|
|
1796
|
+
if (k) k.setAttribute('style', (k.getAttribute('style') || '') + 'overflow:hidden;')
|
|
1797
|
+
return k
|
|
1798
|
+
}
|
|
1592
1799
|
|
|
1593
1800
|
try {
|
|
1594
1801
|
const top = topDark() // 和复制页面同一刻量(后面等图片、字体时页面可能又变了)
|
|
@@ -1596,19 +1803,23 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1596
1803
|
// 字体:页面样式表里的 @font-face,只带用到的字族
|
|
1597
1804
|
const faces = []
|
|
1598
1805
|
const used = [...families].join(',').toLowerCase()
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1806
|
+
// 内嵌页面的规则是它那个窗口的类,instanceof 认不出来,按 type 认(5 = @font-face)
|
|
1807
|
+
const isFace = (r) => r.type === 5 || r instanceof CSSFontFaceRule
|
|
1808
|
+
for (const d of docs) {
|
|
1809
|
+
for (const sheet of d.styleSheets) {
|
|
1810
|
+
let rules
|
|
1811
|
+
try { rules = sheet.cssRules } catch { continue }
|
|
1812
|
+
const base = d === document ? sheetHref(sheet) : sheet.href || d.baseURI
|
|
1813
|
+
const scan = (list) => {
|
|
1814
|
+
for (const r of list) {
|
|
1815
|
+
if (r.cssRules && !isFace(r)) { try { scan(r.cssRules) } catch { /* 读不了 */ } continue }
|
|
1816
|
+
if (!isFace(r)) continue
|
|
1817
|
+
const fam = (r.style.getPropertyValue('font-family') || '').replace(/['"]/g, '').trim().toLowerCase()
|
|
1818
|
+
if (fam && used.includes(fam)) faces.push(inlineUrls(r.cssText, base, inline))
|
|
1819
|
+
}
|
|
1609
1820
|
}
|
|
1821
|
+
scan(rules)
|
|
1610
1822
|
}
|
|
1611
|
-
scan(rules)
|
|
1612
1823
|
}
|
|
1613
1824
|
await Promise.all(jobs)
|
|
1614
1825
|
const fontCss = (await Promise.all(faces)).join('\n')
|
|
@@ -1618,7 +1829,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1618
1829
|
;(body || root).insertBefore(style, (body || root).firstChild)
|
|
1619
1830
|
if (sprites.childNodes.length) (body || root).appendChild(sprites)
|
|
1620
1831
|
if (!full && (scrollX || scrollY) && body) {
|
|
1621
|
-
|
|
1832
|
+
// 用 translate 属性挪:它在 transform / scale / rotate 外面一层,body 自己缩放旋转过也照样整体挪滚动距离
|
|
1833
|
+
const t0 = String(getComputedStyle(document.body).translate || 'none')
|
|
1834
|
+
const [tx = '0px', ty = '0px', tz = ''] = t0 === 'none' ? [] : splitTop(t0)
|
|
1835
|
+
body.setAttribute('style', body.getAttribute('style') + `translate:calc(${tx} - ${scrollX}px) calc(${ty} - ${scrollY}px)${tz ? ' ' + tz : ''};`)
|
|
1622
1836
|
}
|
|
1623
1837
|
const xml = new XMLSerializer().serializeToString(root)
|
|
1624
1838
|
const svg = `<svg xmlns="${SVGNS}" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><foreignObject x="0" y="0" width="100%" height="100%">${xml}</foreignObject></svg>`
|
|
@@ -1641,13 +1855,159 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1641
1855
|
}
|
|
1642
1856
|
}
|
|
1643
1857
|
|
|
1858
|
+
/** 贴住的(sticky)元素现在被贴偏了多少:量现在的位置,临时取消贴住再量一次,差就是偏移(同一刻改回去,不重画) */
|
|
1859
|
+
function stickyShift(doc) {
|
|
1860
|
+
const out = new Map()
|
|
1861
|
+
const list = []
|
|
1862
|
+
for (const el of doc.querySelectorAll('body *')) {
|
|
1863
|
+
if (getComputedStyle(el).position === 'sticky') list.push(el)
|
|
1864
|
+
if (list.length >= 400) break
|
|
1865
|
+
}
|
|
1866
|
+
if (!list.length) return out
|
|
1867
|
+
const live = list.map((el) => [el.offsetLeft, el.offsetTop])
|
|
1868
|
+
const st = doc.createElement('style')
|
|
1869
|
+
st.textContent = '[data-dsh-unstick]{position:relative!important;inset:auto!important}'
|
|
1870
|
+
doc.documentElement.appendChild(st)
|
|
1871
|
+
try {
|
|
1872
|
+
for (const el of list) el.setAttribute('data-dsh-unstick', '')
|
|
1873
|
+
// 按排版坐标量(offsetLeft / offsetTop 不受缩放、旋转影响;相对最近的定位祖先,外层也贴住了的那份不会重复算)
|
|
1874
|
+
list.forEach((el, i) => {
|
|
1875
|
+
const dx = live[i][0] - el.offsetLeft
|
|
1876
|
+
const dy = live[i][1] - el.offsetTop
|
|
1877
|
+
if (dx || dy) out.set(el, [dx, dy])
|
|
1878
|
+
})
|
|
1879
|
+
} finally {
|
|
1880
|
+
for (const el of list) el.removeAttribute('data-dsh-unstick')
|
|
1881
|
+
st.remove()
|
|
1882
|
+
}
|
|
1883
|
+
return out
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/** 固定元素是不是被某个祖先(transform、filter 之类)接管了位置:接管了就跟着那个祖先走,不用挪 */
|
|
1887
|
+
function fixedHeld(el) {
|
|
1888
|
+
for (let a = el.parentElement; a; a = a.parentElement) if (holds(getComputedStyle(a), true)) return true
|
|
1889
|
+
return false
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* 滚过的滚动区:里面的东西按滚动距离往回挪(图里没有滚动)。kids:[原节点, 复制出来的] 按顺序,挪的时候可能换掉复制出来的那个。
|
|
1894
|
+
* 能不动定位就不动(改成相对定位会打乱上下层、把往外找基准的绝对定位框进来):
|
|
1895
|
+
* 竖着滚的块容器 / 竖排 flex,给第一个在文档流里的块加负的上边距,后面的跟着往上走;横着滚的横排 flex 同理加左边距,块容器给每个块加左边距。
|
|
1896
|
+
* 别的排法(grid 之类)退回一个个用相对定位挪;绝对 / 固定定位的(以滚动区为准的)改它自己的 left / top
|
|
1897
|
+
*/
|
|
1898
|
+
function scrollBack(kids, pcs, ox, oy, stuck, openOf) {
|
|
1899
|
+
const flex = /flex/.test(pcs.display)
|
|
1900
|
+
const grid = /grid/.test(pcs.display)
|
|
1901
|
+
const dir = String(pcs.flexDirection || 'row')
|
|
1902
|
+
const row = flex && dir === 'row'
|
|
1903
|
+
const col = flex && dir === 'column'
|
|
1904
|
+
const rtl = pcs.direction === 'rtl'
|
|
1905
|
+
const add = (k, css) => k.setAttribute('style', (k.getAttribute('style') || '') + css)
|
|
1906
|
+
const info = kids.map(([child, k]) => {
|
|
1907
|
+
if (child.nodeType !== 1) return { child, k, flow: true, text: !!(child.nodeValue || '').trim(), blank: !(child.nodeValue || '').trim() }
|
|
1908
|
+
const ccs = getComputedStyle(child)
|
|
1909
|
+
const out = ccs.position === 'absolute' || ccs.position === 'fixed'
|
|
1910
|
+
return { child, k, ccs, flow: !out, inline: !flex && !grid && /^inline/.test(ccs.display) }
|
|
1911
|
+
})
|
|
1912
|
+
const flow = info.filter((x) => x.flow && !x.blank)
|
|
1913
|
+
const first = flow[0]
|
|
1914
|
+
// 负边距能整列带着走的:普通的块(不浮动、不是 display:contents)
|
|
1915
|
+
const block = (x) => x && !x.text && !x.inline && x.ccs.float === 'none' && x.ccs.display !== 'contents'
|
|
1916
|
+
const floats = flow.some((x) => x.ccs && x.ccs.float !== 'none')
|
|
1917
|
+
// flex 主轴不是从头排的(居中、两端对齐……):加了负边距会重新排,退回一个个挪
|
|
1918
|
+
const startish = (v) => /^(normal|flex-start|start|left|stretch)$/.test(String(v || 'normal'))
|
|
1919
|
+
let fy = oy
|
|
1920
|
+
let fx = ox
|
|
1921
|
+
let spacer = null
|
|
1922
|
+
if (oy && block(first) && !floats && !flex && !grid) {
|
|
1923
|
+
// 块容器:前面垫一个高 0、上边距为负的块(自己是 BFC,边距不和后面的叠),后面的照原来的边距(包括往里叠的)往下排
|
|
1924
|
+
spacer = document.createElementNS(XHTML, 'div')
|
|
1925
|
+
spacer.setAttribute('style', `display:flow-root;height:0;margin:-${oy}px 0 0;padding:0;border:0`)
|
|
1926
|
+
fy = 0
|
|
1927
|
+
} else if (oy && block(first) && col && startish(pcs.justifyContent)) {
|
|
1928
|
+
// flex 里边距不叠:直接给第一个加
|
|
1929
|
+
add(first.k, `margin-top:${(parseFloat(first.ccs.marginTop) || 0) - oy}px;`)
|
|
1930
|
+
fy = 0
|
|
1931
|
+
}
|
|
1932
|
+
if (ox && !rtl && block(first) && row && startish(pcs.justifyContent)) {
|
|
1933
|
+
add(first.k, `margin-left:${(parseFloat(first.ccs.marginLeft) || 0) - ox}px;`)
|
|
1934
|
+
fx = 0
|
|
1935
|
+
} else if (ox && !rtl && !flex && !grid && !floats && flow.length && flow.every(block)) {
|
|
1936
|
+
// 块是一个个摆的,横着各挪各的(宽度已经按原来的定死,不会被负边距撑宽)
|
|
1937
|
+
for (const x of flow) add(x.k, `margin-left:${(parseFloat(x.ccs.marginLeft) || 0) - ox}px;`)
|
|
1938
|
+
fx = 0
|
|
1939
|
+
}
|
|
1940
|
+
// 绝对 / 固定定位的在 walk 里按定位基准挪过了;剩下没挪成的一个个挪
|
|
1941
|
+
for (const x of info) {
|
|
1942
|
+
if (x.blank || !x.flow) continue
|
|
1943
|
+
if (fx || fy) x.k = scrolledKid(x.k, x.child, pcs, fx, fy, stuck, openOf.get(x.child))
|
|
1944
|
+
}
|
|
1945
|
+
info.forEach((x, i) => { kids[i][1] = x.k })
|
|
1946
|
+
if (spacer) kids.splice(kids.findIndex(([c]) => c === first.child), 0, [null, spacer])
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** 按空格拆一个 CSS 值(括号里的空格不拆:calc()、clamp() 整个算一段) */
|
|
1950
|
+
function splitTop(v) {
|
|
1951
|
+
const out = []
|
|
1952
|
+
let cur = ''
|
|
1953
|
+
let depth = 0
|
|
1954
|
+
for (const ch of String(v)) {
|
|
1955
|
+
if (ch === '(') depth++
|
|
1956
|
+
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
1957
|
+
if (/\s/.test(ch) && !depth) { if (cur) out.push(cur); cur = '' } else cur += ch
|
|
1958
|
+
}
|
|
1959
|
+
if (cur) out.push(cur)
|
|
1960
|
+
return out
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** 定位元素往回挪 (dx, dy):只动挪了的那个方向;从右往左排的横向按 right 挪 */
|
|
1964
|
+
function shiftCss(cs, dx, dy) {
|
|
1965
|
+
let css = ''
|
|
1966
|
+
if (dy) { const y = parseFloat(cs.top); if (Number.isFinite(y)) css += `top:${y - dy}px;bottom:auto;` }
|
|
1967
|
+
if (dx) {
|
|
1968
|
+
if (cs.direction === 'rtl') { const r = parseFloat(cs.right); if (Number.isFinite(r)) css += `right:${r + dx}px;left:auto;` } else { const x = parseFloat(cs.left); if (Number.isFinite(x)) css += `left:${x - dx}px;right:auto;` }
|
|
1969
|
+
}
|
|
1970
|
+
return css
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* 滚过的滚动区里的一个孩子,用相对定位往回挪。
|
|
1975
|
+
* 不跟着滚的不挪:固定定位(滚动区没接管它)、包含块在滚动区外面的绝对定位。
|
|
1976
|
+
* 本来没定位、里面又有还在往外找基准的绝对定位的(比如以外层为准的弹层):改成相对定位会把它们框进来,这一块宁可不挪
|
|
1977
|
+
*/
|
|
1978
|
+
function scrolledKid(k, child, pcs, ox, oy, stuck, open) {
|
|
1979
|
+
if (child.nodeType === 3) {
|
|
1980
|
+
if (!child.nodeValue.trim()) return k
|
|
1981
|
+
const s = document.createElementNS(XHTML, 'span')
|
|
1982
|
+
s.setAttribute('style', `position:relative;left:${-ox}px;top:${-oy}px;`)
|
|
1983
|
+
s.appendChild(k)
|
|
1984
|
+
return s
|
|
1985
|
+
}
|
|
1986
|
+
if (child.nodeType !== 1) return k
|
|
1987
|
+
const ccs = getComputedStyle(child)
|
|
1988
|
+
const pos = ccs.position
|
|
1989
|
+
if ((pos === 'fixed' && !holds(pcs, true)) || (pos === 'absolute' && !holds(pcs))) return k
|
|
1990
|
+
if (pos === 'static' && open && open.a) return k
|
|
1991
|
+
let x = 0
|
|
1992
|
+
let y = 0
|
|
1993
|
+
if (stuck.has(child)) [x, y] = stuck.get(child)
|
|
1994
|
+
else if (pos !== 'static' && pos !== 'sticky') { x = parseFloat(ccs.left) || 0; y = parseFloat(ccs.top) || 0 }
|
|
1995
|
+
const keep = pos === 'absolute' || pos === 'fixed'
|
|
1996
|
+
// 从右往左排的:右边的偏移才算数
|
|
1997
|
+
const hx = ccs.direction === 'rtl' && !stuck.has(child) && pos !== 'static' && pos !== 'sticky'
|
|
1998
|
+
? `right:${(parseFloat(ccs.right) || 0) + ox}px;left:auto;`
|
|
1999
|
+
: `left:${x - ox}px;right:auto;`
|
|
2000
|
+
k.setAttribute('style', (k.getAttribute('style') || '') + `position:${keep ? pos : 'relative'};${ox ? hx : ''}${oy ? `top:${y - oy}px;bottom:auto;` : ''}`)
|
|
2001
|
+
return k
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1644
2004
|
async function onCapture(d) {
|
|
1645
2005
|
try {
|
|
1646
2006
|
const r = await capture(d)
|
|
1647
2007
|
// 整页图从文档顶上画起,链接加上滚动距离;只截视口时就是视口里的位置
|
|
1648
2008
|
const links = linksOf().map((l) => (d.full ? { ...l, x: l.x + scrollX, y: l.y + scrollY } : l))
|
|
1649
2009
|
.filter((l) => l.x < r.w && l.y < r.h && l.x + l.w > 0 && l.y + l.h > 0)
|
|
1650
|
-
post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title, links, top: r.top })
|
|
2010
|
+
post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title, links, top: r.top, href: here() })
|
|
1651
2011
|
} catch (e) {
|
|
1652
2012
|
post({ type: 'captured', id: d.id, error: String((e && e.message) || e) })
|
|
1653
2013
|
}
|
|
@@ -1659,6 +2019,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1659
2019
|
// contrast 对比度不够 · tap 点击区太小 · tiny 字太小 · img 图片没加载出来 · fonts 字号太杂
|
|
1660
2020
|
// covered 被固定栏压住(滚到底也看不全) · tight 离固定栏太近 · jserr 页面脚本报错 · missing 样式表 / 脚本 / 字体没载出来
|
|
1661
2021
|
// capsule 小程序右上角的胶囊(微信画的,页面盖不住)挡住了字或按钮
|
|
2022
|
+
// island 灵动岛挡住了字或按钮 · homebar 底栏里的字、图标伸进底部横条区 · tabbar 标签栏超过 5 项 · navbar 小程序这屏没有导航栏
|
|
2023
|
+
// winctl 桌面软件的窗口按钮(Mac 红绿灯 / Windows 右上角三个)挡住了字或按钮
|
|
1662
2024
|
// 有意为之的不报:省略号(text-overflow: ellipsis)、行数截断(line-clamp)、可以滚动的区域(轮播)、很淡的装饰大字。
|
|
1663
2025
|
|
|
1664
2026
|
const CHECK_PER = 8 // 每类最多报几条
|
|
@@ -1667,6 +2029,13 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1667
2029
|
const CLICKABLE = 'a[href],button,[role="button"],[role="tab"],[role="link"],input:not([type="hidden"]),select,textarea,summary,[onclick],[data-href]'
|
|
1668
2030
|
// 小程序右上角的胶囊:离屏幕顶 48(状态栏 44 + 4)、离右边 7、87×32(和右侧栏外框画的一样)
|
|
1669
2031
|
const CAP = { top: 48, right: 7, w: 87, h: 32 }
|
|
2032
|
+
// 预览手机的灵动岛:离顶 11、118×34、居中(和外框 .ddp-island 一样);矮的手机(高不到宽的 1.9 倍)没有,也没有底部横条
|
|
2033
|
+
const ISLAND = { top: 11, w: 118, h: 34 }
|
|
2034
|
+
const HOMEBAR = 34 // 底部横条区(home indicator)的高
|
|
2035
|
+
const NAVBAR = { top: 44, h: 44 } // 小程序导航栏:状态栏 44 下面那 44
|
|
2036
|
+
// 桌面软件的窗口按钮(和右侧栏外框画的一样):Mac 红绿灯在左上 x 20、y 20 起,三个 12px 圆点隔 8;Windows 最小化 / 最大化 / 关闭在右上角,各 46×32
|
|
2037
|
+
const LIGHTS = { x: 20, y: 20, d: 12, gap: 8 }
|
|
2038
|
+
const WINBTN = { w: 46, h: 32 }
|
|
1670
2039
|
let checkMarks = []
|
|
1671
2040
|
|
|
1672
2041
|
const isOurs = (el) => SIG_SKIP.has(el.tagName)
|
|
@@ -1941,15 +2310,17 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1941
2310
|
/** 滚完等两帧、再等新露出来的图和字体(页面的滚动监听、懒加载的图有机会跟上),再截图 */
|
|
1942
2311
|
const afterScroll = (v) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(v)))).then((v2) => settle().then(() => v2, () => v2))
|
|
1943
2312
|
|
|
1944
|
-
function barsAt(edge) {
|
|
2313
|
+
function barsAt(edge, inflow = false) {
|
|
1945
2314
|
const out = []
|
|
1946
2315
|
const vw = innerWidth, vh = innerHeight
|
|
2316
|
+
// inflow:页面本身不滚(flex / grid 撑满一屏、中间内容区自己滚)时,贴着屏幕边的普通元素也算栏
|
|
2317
|
+
const shell = inflow && (document.scrollingElement || document.documentElement).scrollHeight <= vh + 2
|
|
1947
2318
|
const all = document.body.getElementsByTagName('*')
|
|
1948
2319
|
for (let i = 0; i < all.length; i++) {
|
|
1949
2320
|
const el = all[i]
|
|
1950
2321
|
if (isOurs(el)) continue
|
|
1951
2322
|
const pos = getComputedStyle(el).position
|
|
1952
|
-
if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute') continue
|
|
2323
|
+
if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute' && !shell) continue
|
|
1953
2324
|
if (out.some((b) => b.el.contains(el))) continue // 栏里面的东西
|
|
1954
2325
|
const r = el.getBoundingClientRect()
|
|
1955
2326
|
if (r.width < vw * 0.5 || r.height < 16 || r.height > vh * BAR_MAX) continue
|
|
@@ -2299,7 +2670,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2299
2670
|
|
|
2300
2671
|
// 4. 对比度(WCAG:正文 4.5,大字 3)。取底色那段装一次「量画着什么」的临时样式(见 withProbe),量完撤掉
|
|
2301
2672
|
const sizes = new Map()
|
|
2302
|
-
const tinyMin = kind === 'app' || kind === 'miniprogram' ? 10 : kind === 'tablet' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
|
|
2673
|
+
const tinyMin = kind === 'app' || kind === 'miniprogram' ? 10 : kind === 'tablet' || kind === 'desktop' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
|
|
2303
2674
|
let pool = null
|
|
2304
2675
|
const poolOf = () => pool || (pool = paintPool()) // 有字不在屏幕里才建
|
|
2305
2676
|
withProbe(() => { for (const a of atoms) {
|
|
@@ -2325,7 +2696,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2325
2696
|
} })
|
|
2326
2697
|
|
|
2327
2698
|
// 5. 点击区太小(App / 小程序 / 平板 44,网页 24;文字中间的链接不算)
|
|
2328
|
-
const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
|
|
2699
|
+
const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' || kind === 'desktop' ? 24 : 0
|
|
2329
2700
|
if (minTap) {
|
|
2330
2701
|
const small = []
|
|
2331
2702
|
for (const el of body.querySelectorAll(CLICKABLE)) {
|
|
@@ -2419,6 +2790,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2419
2790
|
|
|
2420
2791
|
// 13. 小程序:右上角胶囊挡住的字和按钮。不固定的按没滚时的位置算(滚下去本来就会从胶囊下面过)
|
|
2421
2792
|
if (kind === 'miniprogram') capsuleCheck(add, atoms, vw)
|
|
2793
|
+
// 14. 手机:灵动岛挡住的、伸进底部横条区的,标签栏项数;小程序每屏要有导航栏
|
|
2794
|
+
if (kind === 'app' || kind === 'miniprogram') phoneCheck(add, atoms, vw, kind)
|
|
2795
|
+
// 15. 桌面软件:窗口按钮挡住的字和按钮
|
|
2796
|
+
if (kind === 'desktop') winCheck(add, atoms, vw)
|
|
2422
2797
|
|
|
2423
2798
|
issues.sort((x, y) => y.sev - x.sev)
|
|
2424
2799
|
const se0 = se()
|
|
@@ -2432,29 +2807,45 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2432
2807
|
* 元素在「各层都没滚」时露出来的那块(视口坐标):窗口、内部滚动区滚过的量加回去,被外层裁掉的去掉。
|
|
2433
2808
|
* 滚动量、边框都按那层实际的缩放(transform、zoom)换成视口像素;裁剪按 padding box、只裁 overflow 不是 visible 的那个方向。
|
|
2434
2809
|
* 绝对 / 固定定位的只受包含块和更外面的管;固定 / 吸顶的那层按现在看到的位置算,它外面的滚动不管、裁剪照算。全被裁掉返回 null。
|
|
2435
|
-
* now:按现在的滚动位置算(只去掉裁掉的部分)。upto
|
|
2810
|
+
* now:按现在的滚动位置算(只去掉裁掉的部分)。upto:只算到包着它的那层为止(更外面的裁剪不管)。
|
|
2811
|
+
* pos:量的是 el 的伪元素,它自己的定位方式(绝对定位的从 el 起只受包含块和更外面的裁,跳过中间不包它的层)
|
|
2436
2812
|
*/
|
|
2437
|
-
function restBox(el, now = false, upto = null) {
|
|
2813
|
+
function restBox(el, now = false, upto = null, self = false, pos = '') {
|
|
2438
2814
|
const chain = []
|
|
2439
|
-
let mode = ''
|
|
2440
|
-
let pinned =
|
|
2815
|
+
let mode = pos === 'absolute' || pos === 'fixed' ? pos : ''
|
|
2816
|
+
let pinned = pos === 'fixed'
|
|
2441
2817
|
for (let e = el; e && e !== document.documentElement; e = e.parentElement) {
|
|
2442
2818
|
if (upto && e !== el && e.contains(upto)) break
|
|
2443
2819
|
const cs = getComputedStyle(e)
|
|
2444
|
-
if (e !== el) {
|
|
2445
|
-
|
|
2820
|
+
if (e !== el || self) { // self:量的是 el 里面的字,el 自己的 overflow 也裁它
|
|
2821
|
+
// clip-path 不管包含块,里面的都裁(形状在边框框以内,按边框框算个外沿)
|
|
2822
|
+
const cp = cs.clipPath && cs.clipPath !== 'none' ? e.getBoundingClientRect() : null
|
|
2823
|
+
const cpClip = cp && { l: cp.left, r: cp.right, t: cp.top, b: cp.bottom }
|
|
2824
|
+
if ((mode === 'absolute' && !holds(cs, false)) || (mode === 'fixed' && !holds(cs, true))) {
|
|
2825
|
+
if (cpClip) chain.push({ sx: 0, sy: 0, clip: cpClip })
|
|
2826
|
+
continue
|
|
2827
|
+
}
|
|
2446
2828
|
const r = e.getBoundingClientRect()
|
|
2447
2829
|
const kx = e.offsetWidth ? r.width / e.offsetWidth : 1
|
|
2448
2830
|
const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
|
|
2449
|
-
const
|
|
2450
|
-
const
|
|
2831
|
+
const box = cs.display !== 'inline' && cs.display !== 'contents' // overflow 对行内元素不起作用(clientWidth 是 0)
|
|
2832
|
+
const cx = box && e !== document.body && cs.overflowX !== 'visible'
|
|
2833
|
+
const cy = box && e !== document.body && cs.overflowY !== 'visible'
|
|
2834
|
+
// overflow: clip 可以往外放出 overflow-clip-margin 那么多(写了 border-box 从边框外沿算、content-box 从内容区算)
|
|
2835
|
+
const ocm = String(cs.overflowClipMargin || '')
|
|
2836
|
+
const len = (/(-?[\d.]+)px/.exec(ocm) || [])[1]
|
|
2837
|
+
const grow = (pad, bd) => (parseFloat(len) || 0) + (/border-box/.test(ocm) ? bd : /content-box/.test(ocm) ? -pad : 0)
|
|
2838
|
+
const mx = cs.overflowX === 'clip' ? grow(parseFloat(cs.paddingLeft) || 0, e.clientLeft) * kx : 0
|
|
2839
|
+
const my = cs.overflowY === 'clip' ? grow(parseFloat(cs.paddingTop) || 0, e.clientTop) * ky : 0
|
|
2840
|
+
let clip = cx || cy ? {
|
|
2841
|
+
l: cx ? r.left + e.clientLeft * kx - mx : -Infinity, r: cx ? r.left + (e.clientLeft + e.clientWidth) * kx + mx : Infinity,
|
|
2842
|
+
t: cy ? r.top + e.clientTop * ky - my : -Infinity, b: cy ? r.top + (e.clientTop + e.clientHeight) * ky + my : Infinity,
|
|
2843
|
+
} : null
|
|
2844
|
+
if (cpClip) clip = clip ? { l: Math.max(clip.l, cpClip.l), r: Math.min(clip.r, cpClip.r), t: Math.max(clip.t, cpClip.t), b: Math.min(clip.b, cpClip.b) } : cpClip
|
|
2451
2845
|
chain.push({
|
|
2452
2846
|
sx: pinned || now ? 0 : e.scrollLeft * kx,
|
|
2453
2847
|
sy: pinned || now ? 0 : e.scrollTop * ky,
|
|
2454
|
-
clip
|
|
2455
|
-
l: cx ? r.left + e.clientLeft * kx : -Infinity, r: cx ? r.left + (e.clientLeft + e.clientWidth) * kx : Infinity,
|
|
2456
|
-
t: cy ? r.top + e.clientTop * ky : -Infinity, b: cy ? r.top + (e.clientTop + e.clientHeight) * ky : Infinity,
|
|
2457
|
-
} : null,
|
|
2848
|
+
clip,
|
|
2458
2849
|
})
|
|
2459
2850
|
}
|
|
2460
2851
|
if (cs.position === 'fixed' || cs.position === 'sticky') pinned = true
|
|
@@ -2476,30 +2867,340 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2476
2867
|
}
|
|
2477
2868
|
}
|
|
2478
2869
|
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
const ow = Math.min(r.r, cap.r) - Math.max(r.l, cap.l)
|
|
2485
|
-
const oh = Math.min(r.b, cap.b) - Math.max(r.t, cap.t)
|
|
2486
|
-
return ow > 2 && oh > 2 ? Math.round(Math.min(ow, r.r - cap.l)) : 0
|
|
2487
|
-
}
|
|
2870
|
+
/**
|
|
2871
|
+
* 盖在页面上面、页面盖不住的一块(胶囊、灵动岛):可点的东西和字横竖都伸进去 2px 以上就报。
|
|
2872
|
+
* 按「各层都没滚」时露出来的那块量(不固定的滚下去本来就会从下面过);px(o, ow, oh) 算报多少
|
|
2873
|
+
*/
|
|
2874
|
+
function zoneCheck(add, atoms, zone, type, px, extra) {
|
|
2488
2875
|
const hit = new Set()
|
|
2489
|
-
const
|
|
2876
|
+
const into = (o) => {
|
|
2877
|
+
if (!o) return 0
|
|
2878
|
+
const ow = Math.min(o.r, zone.r) - Math.max(o.l, zone.l)
|
|
2879
|
+
const oh = Math.min(o.b, zone.b) - Math.max(o.t, zone.t)
|
|
2880
|
+
return ow > 2 && oh > 2 ? Math.round(px(o, ow, oh)) : 0
|
|
2881
|
+
}
|
|
2882
|
+
const check = (el, rects, self) => {
|
|
2490
2883
|
if (hiddenChain(el) || [...hit].some((h) => h.contains(el) || el.contains(h))) return
|
|
2491
|
-
const rest = restBox(el)
|
|
2492
|
-
const
|
|
2493
|
-
if (!
|
|
2884
|
+
const rest = restBox(el, false, null, self)
|
|
2885
|
+
const n = Math.max(0, ...rects.map((r) => into(rest(r))))
|
|
2886
|
+
if (!n) return
|
|
2494
2887
|
hit.add(el)
|
|
2495
|
-
add(
|
|
2888
|
+
add(type, el, { px: n, ...extra }, 2)
|
|
2496
2889
|
}
|
|
2497
2890
|
for (const el of document.body.querySelectorAll(CLICKABLE)) {
|
|
2498
2891
|
if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
2499
2892
|
const r = el.getBoundingClientRect()
|
|
2500
2893
|
if (r.width >= 1 && r.height >= 1) check(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])
|
|
2501
2894
|
}
|
|
2502
|
-
for (const a of atoms) check(a.el, a.rects)
|
|
2895
|
+
for (const a of atoms) check(a.el, a.rects, true) // 字被它自己 overflow 裁掉的那截看不见,不算
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function capsuleCheck(add, atoms, vw) {
|
|
2899
|
+
const cap = { l: vw - CAP.right - CAP.w, r: vw - CAP.right, t: CAP.top, b: CAP.top + CAP.h }
|
|
2900
|
+
// 报伸进去多宽(从胶囊左边算)
|
|
2901
|
+
zoneCheck(add, atoms, cap, 'capsule', (o, ow) => Math.min(ow, o.r - cap.l), { x: Math.round(cap.l) })
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/** 页面 meta 说的是不是 Windows 软件(<meta name="dsh-design" content="desktop windows">);不写是 Mac */
|
|
2905
|
+
function deskOs() {
|
|
2906
|
+
const m = document.querySelector('meta[name="dsh-design"]')
|
|
2907
|
+
const words = String(m ? m.getAttribute('content') || '' : '').toLowerCase().split(/[\s,;]+/)
|
|
2908
|
+
return words.some((w) => /^(windows|win|win10|win11)$/.test(w)) ? 'win' : 'mac'
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/** 桌面软件:Mac 左上角红绿灯(连同四周 4px),Windows 右上角三个按钮,盖在页面上面,伸进去的字和按钮报 winctl */
|
|
2912
|
+
function winCheck(add, atoms, vw) {
|
|
2913
|
+
const os = deskOs()
|
|
2914
|
+
if (os === 'win') {
|
|
2915
|
+
const z = { l: vw - WINBTN.w * 3, r: vw, t: 0, b: WINBTN.h }
|
|
2916
|
+
zoneCheck(add, atoms, z, 'winctl', (o, ow) => Math.min(ow, o.r - z.l), { os })
|
|
2917
|
+
} else {
|
|
2918
|
+
const z = { l: LIGHTS.x - 4, r: LIGHTS.x + LIGHTS.d * 3 + LIGHTS.gap * 2 + 4, t: LIGHTS.y - 4, b: LIGHTS.y + LIGHTS.d + 4 }
|
|
2919
|
+
zoneCheck(add, atoms, z, 'winctl', (o, ow) => Math.min(ow, z.r - o.l), { os })
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
/**
|
|
2924
|
+
* 伪元素在屏幕上的框(量不到它自己,按算出来的样式推):
|
|
2925
|
+
* - 绝对 / 固定定位:算出来的 left / top / 宽高摆进包含块;
|
|
2926
|
+
* - 普通流:挨着前后的子元素(字、元素)摆,flex 按排列方向和对齐,行内的跟所在那行居中;
|
|
2927
|
+
* 最后套上它自己的 translate / rotate / scale / transform(绕 transform-origin)
|
|
2928
|
+
*/
|
|
2929
|
+
const pxv = (v) => (/px$/.test(v) ? parseFloat(v) : NaN)
|
|
2930
|
+
/** 伪元素的边框框宽高(算出来的宽高按 box-sizing 加上内边距和边框);算不出来的是 NaN */
|
|
2931
|
+
function pseudoSize(ps) {
|
|
2932
|
+
const extra = (a, b) => (ps.boxSizing === 'border-box' ? 0 : (parseFloat(ps['padding' + a]) || 0) + (parseFloat(ps['padding' + b]) || 0) + (parseFloat(ps['border' + a + 'Width']) || 0) + (parseFloat(ps['border' + b + 'Width']) || 0))
|
|
2933
|
+
return { w: pxv(ps.width) + extra('Left', 'Right'), h: pxv(ps.height) + extra('Top', 'Bottom') }
|
|
2934
|
+
}
|
|
2935
|
+
/** 行内的伪元素没有宽高:按行高(normal 按字号 1.2 倍) */
|
|
2936
|
+
const lineOf = (ps) => { const v = pxv(ps.lineHeight); return isNaN(v) ? (parseFloat(ps.fontSize) || 16) * 1.2 : v }
|
|
2937
|
+
/** 块级的显示方式:上下外边距在块布局里会合并 */
|
|
2938
|
+
const BLOCKISH = /^(block|flex|grid|table|list-item|flow-root)$/
|
|
2939
|
+
/** 替换元素:行内的也有上下外边距 */
|
|
2940
|
+
const REPLACED = /^(IMG|SVG|svg|VIDEO|CANVAS|IFRAME|EMBED|OBJECT|INPUT|SELECT|TEXTAREA|PICTURE)$/
|
|
2941
|
+
|
|
2942
|
+
function pseudoBox(e, which, ps) {
|
|
2943
|
+
const { w } = pseudoSize(ps)
|
|
2944
|
+
let { h } = pseudoSize(ps)
|
|
2945
|
+
let kx = 1, ky = 1, l, t, W
|
|
2946
|
+
if (ps.position === 'absolute' || ps.position === 'fixed') {
|
|
2947
|
+
let cb = null
|
|
2948
|
+
for (let a = e; a && a !== document.documentElement; a = a.parentElement) if (holds(getComputedStyle(a), ps.position === 'fixed')) { cb = a; break }
|
|
2949
|
+
const r = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 }
|
|
2950
|
+
if (cb && cb.offsetWidth) kx = r.width / cb.offsetWidth
|
|
2951
|
+
if (cb && cb.offsetHeight) ky = r.height / cb.offsetHeight
|
|
2952
|
+
const x = pxv(ps.left), y = pxv(ps.top)
|
|
2953
|
+
if ([x, y, w, h].some(isNaN)) return null
|
|
2954
|
+
l = r.left + ((cb ? cb.clientLeft : 0) + x + (parseFloat(ps.marginLeft) || 0)) * kx
|
|
2955
|
+
t = r.top + ((cb ? cb.clientTop : 0) + y + (parseFloat(ps.marginTop) || 0)) * ky
|
|
2956
|
+
W = w
|
|
2957
|
+
} else {
|
|
2958
|
+
const r = e.getBoundingClientRect()
|
|
2959
|
+
if (e.offsetWidth) kx = r.width / e.offsetWidth
|
|
2960
|
+
if (e.offsetHeight) ky = r.height / e.offsetHeight
|
|
2961
|
+
const cs = getComputedStyle(e)
|
|
2962
|
+
const side = (k) => (parseFloat(cs['padding' + k]) || 0) + (parseFloat(cs['border' + k + 'Width']) || 0)
|
|
2963
|
+
const c = { l: r.left + side('Left') * kx, t: r.top + side('Top') * ky, r: r.right - side('Right') * kx, b: r.bottom - side('Bottom') * ky }
|
|
2964
|
+
if (isNaN(h)) h = lineOf(ps)
|
|
2965
|
+
const H = h * ky
|
|
2966
|
+
// 普通流里挨着的子元素和字(第一行 / 最后一行的框)
|
|
2967
|
+
// 字量出来的是字形的框,按行高撑成整行(line-height: 40px 这种,行比字高)
|
|
2968
|
+
const lh = pxv(cs.lineHeight) * ky
|
|
2969
|
+
const line = (q) => (isNaN(lh) || lh <= q.bottom - q.top ? q : { top: (q.top + q.bottom - lh) / 2, bottom: (q.top + q.bottom + lh) / 2 })
|
|
2970
|
+
const near = []
|
|
2971
|
+
for (const n of e.childNodes) {
|
|
2972
|
+
if (n.nodeType === 3 && n.nodeValue.trim()) {
|
|
2973
|
+
const rg = document.createRange()
|
|
2974
|
+
rg.selectNodeContents(n)
|
|
2975
|
+
const rs = rg.getClientRects()
|
|
2976
|
+
if (rs.length) { const f = line(rs[0]), z = line(rs[rs.length - 1]); near.push({ first: f, last: z, mT: 0, mB: 0, block: false, top: f.top, bottom: z.bottom }) }
|
|
2977
|
+
} else if (n.nodeType === 1 && !isOurs(n)) {
|
|
2978
|
+
const s2 = getComputedStyle(n)
|
|
2979
|
+
const rs = s2.display !== 'none' && s2.position !== 'absolute' && s2.position !== 'fixed' ? n.getClientRects() : []
|
|
2980
|
+
// top / bottom 是带外边距的边;普通行内元素(span 这种)上下外边距不起作用,框按它的行高撑成整行
|
|
2981
|
+
const inl = s2.display === 'inline' && !REPLACED.test(n.tagName)
|
|
2982
|
+
const mT = inl ? 0 : (parseFloat(s2.marginTop) || 0) * ky, mB = inl ? 0 : (parseFloat(s2.marginBottom) || 0) * ky
|
|
2983
|
+
const ln = (q) => {
|
|
2984
|
+
const v = Math.max(pxv(s2.lineHeight) || 0, pxv(cs.lineHeight) || 0) * ky // 那一行至少有父元素的行高
|
|
2985
|
+
return !inl || !v || v <= q.bottom - q.top ? q : { top: (q.top + q.bottom - v) / 2, bottom: (q.top + q.bottom + v) / 2 }
|
|
2986
|
+
}
|
|
2987
|
+
if (rs.length) { const f = ln(rs[0]), z = ln(rs[rs.length - 1]); near.push({ first: f, last: z, mT, mB, block: BLOCKISH.test(s2.display), top: f.top - mT, bottom: z.bottom + mB }) }
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
const flex = /flex/.test(cs.display), grid = /grid/.test(cs.display)
|
|
2991
|
+
const rev = flex && /reverse/.test(cs.flexDirection)
|
|
2992
|
+
if (rev && /column/.test(cs.flexDirection)) near.reverse() // 按屏幕上从上到下
|
|
2993
|
+
const before = (which === '::before') !== rev
|
|
2994
|
+
const mt = (parseFloat(ps.marginTop) || 0) * ky, mb = (parseFloat(ps.marginBottom) || 0) * ky
|
|
2995
|
+
if (!flex && !grid && /inline/.test(ps.display)) {
|
|
2996
|
+
const ln = near.length ? (before ? near[0].first : near[near.length - 1].last) : null
|
|
2997
|
+
t = ln ? (ln.top + ln.bottom - H) / 2 : c.t
|
|
2998
|
+
} else if (!flex || /column/.test(cs.flexDirection)) {
|
|
2999
|
+
const gap = flex || grid ? (parseFloat(cs.rowGap) || 0) * ky : 0
|
|
3000
|
+
const j = flex ? cs.justifyContent : ''
|
|
3001
|
+
// 另一个伪元素也在普通流里排着(::before 图标 + 字 + ::after 箭头):一起算剩余空间
|
|
3002
|
+
const os = getComputedStyle(e, which === '::before' ? '::after' : '::before')
|
|
3003
|
+
const other = os.content !== 'none' && os.content !== 'normal' && os.display !== 'none' && os.position !== 'absolute' && os.position !== 'fixed'
|
|
3004
|
+
const osz = pseudoSize(os).h
|
|
3005
|
+
const oh = other ? ((isNaN(osz) ? lineOf(os) : osz) + (parseFloat(os.marginTop) || 0) + (parseFloat(os.marginBottom) || 0)) * ky : 0
|
|
3006
|
+
// 剩下的空按项数分(space-*),放不下(没剩)就不分
|
|
3007
|
+
const n = near.length + 1 + (other ? 1 : 0)
|
|
3008
|
+
const free = c.b - c.t - near.reduce((m, q) => m + q.bottom - q.top, 0) - H - mt - mb - oh - gap * (n - 1)
|
|
3009
|
+
const between = free <= 0 || !flex ? 0 : /space-between/.test(j) ? free / (n - 1) : /space-around/.test(j) ? free / n : /space-evenly/.test(j) ? free / (n + 1) : 0
|
|
3010
|
+
// 块布局里两个块级盒相邻,上下外边距合并成一个(正的取大、负的取小再相加);flex / grid、行内块不合并
|
|
3011
|
+
const fold = (a, b, q) => (flex || grid || !q.block || !BLOCKISH.test(ps.display) ? a + b : Math.max(a, b, 0) + Math.min(a, b, 0))
|
|
3012
|
+
// 排到内容区的尾巴上:flex-start / normal(只有一项或放不下时的 space-between 也按它)在 column-reverse 里也是尾巴,start / end 不跟着反
|
|
3013
|
+
const atEnd = /flex-end/.test(j) ? !rev : /(^|\s)end$/.test(j) ? true : /flex-start|normal|stretch|space-between/.test(j) ? rev : false
|
|
3014
|
+
if (near.length) {
|
|
3015
|
+
// 挨着第一项前面 / 最后一项后面
|
|
3016
|
+
const q = before ? near[0] : near[near.length - 1]
|
|
3017
|
+
t = before ? q.first.top - fold(q.mT, mb, q) - gap - between - H : q.last.bottom + fold(q.mB, mt, q) + gap + between
|
|
3018
|
+
} else if (other && flex) {
|
|
3019
|
+
// 只有前后两个伪元素:从内容区起点按 justify-content 排
|
|
3020
|
+
const lead = /center/.test(j) ? free / 2 : free <= 0 ? (/space-around|space-evenly/.test(j) ? free / 2 : atEnd ? free : 0)
|
|
3021
|
+
: /space-around/.test(j) ? free / 4 : /space-evenly/.test(j) ? free / 3 : /space-between/.test(j) ? 0 : atEnd ? free : 0
|
|
3022
|
+
t = c.t + lead + mt + (before ? 0 : oh + gap + between)
|
|
3023
|
+
} else t = /center|space-around|space-evenly/.test(j) ? (c.t + mt + c.b - mb - H) / 2 : atEnd ? c.b - mb - H : c.t + mt
|
|
3024
|
+
} else {
|
|
3025
|
+
const a = ps.alignSelf && ps.alignSelf !== 'auto' ? ps.alignSelf : cs.alignItems
|
|
3026
|
+
t = /center/.test(a) ? (c.t + c.b - H) / 2 : /end/.test(a) ? c.b - H : c.t + mt
|
|
3027
|
+
}
|
|
3028
|
+
l = c.l
|
|
3029
|
+
W = isNaN(w) ? Math.max(1, (c.r - c.l) / kx) : w
|
|
3030
|
+
if (ps.position === 'relative') { l += (pxv(ps.left) || 0) * kx; t += (pxv(ps.top) || 0) * ky }
|
|
3031
|
+
}
|
|
3032
|
+
// 自己的变换:先 translate、rotate、scale,再 transform,都绕 transform-origin
|
|
3033
|
+
let m = new DOMMatrix()
|
|
3034
|
+
const set = (v) => v && v !== 'none'
|
|
3035
|
+
const len = (v, full) => (/%$/.test(v) ? parseFloat(v) / 100 * full : parseFloat(v) || 0)
|
|
3036
|
+
try {
|
|
3037
|
+
if (set(ps.translate)) { const [x, y = '0px'] = ps.translate.split(' '); m = m.translate(len(x, W), len(y, h)) }
|
|
3038
|
+
if (set(ps.rotate) && /^-?[\d.]+deg$/.test(ps.rotate)) m = m.rotate(parseFloat(ps.rotate))
|
|
3039
|
+
if (set(ps.scale)) { const [sx, sy = sx] = ps.scale.split(' ').map(parseFloat); m = m.scale(sx, sy) }
|
|
3040
|
+
if (set(ps.transform)) m = m.multiply(new DOMMatrix(ps.transform))
|
|
3041
|
+
} catch { /* 解析不了的变换就当没有 */ }
|
|
3042
|
+
const [ox, oy] = String(ps.transformOrigin).split(' ').map((v) => parseFloat(v) || 0)
|
|
3043
|
+
m = new DOMMatrix().translate(ox, oy).multiply(m).translate(-ox, -oy)
|
|
3044
|
+
const pts = [[0, 0], [W, 0], [0, h], [W, h]].map(([x, y]) => m.transformPoint(new DOMPoint(x, y)))
|
|
3045
|
+
const xs = pts.map((p) => l + p.x * kx), ys = pts.map((p) => t + p.y * ky)
|
|
3046
|
+
return { l: Math.min(...xs), t: Math.min(...ys), r: Math.max(...xs), b: Math.max(...ys) }
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
/** 一个元素里的图标(img、最外层的 svg):不看框,只看画出来的东西 */
|
|
3050
|
+
function iconRects(root, pseudo = false) {
|
|
3051
|
+
const out = []
|
|
3052
|
+
for (const e of root.querySelectorAll('img,svg')) {
|
|
3053
|
+
if ((e.parentElement && e.parentElement.closest('svg')) || isOurs(e) || e.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
3054
|
+
const r = e.getBoundingClientRect()
|
|
3055
|
+
if (r.width >= 1 && r.height >= 1 && !hiddenChain(e)) out.push({ el: e, r: { l: r.left, t: r.top, r: r.right, b: r.bottom } })
|
|
3056
|
+
}
|
|
3057
|
+
if (!pseudo) return out
|
|
3058
|
+
// 图标字体、背景图标:::before / ::after 里放了字或 url() 图(空白、渐变遮罩这种装饰不算)。量不到伪元素的框,按它算出来的位置和大小推
|
|
3059
|
+
for (const e of [root, ...root.querySelectorAll('*')]) {
|
|
3060
|
+
if (isOurs(e) || e.closest('svg,dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
3061
|
+
for (const which of ['::before', '::after']) {
|
|
3062
|
+
const ps = getComputedStyle(e, which)
|
|
3063
|
+
if (ps.display === 'none' || ps.content === 'none' || ps.content === 'normal' || ps.visibility === 'hidden' || parseFloat(ps.opacity) === 0) continue
|
|
3064
|
+
const glyph = /^["'][\s\S]*\S[\s\S]*["']$/.test(ps.content.replace(/\\[0-9a-f]{1,6}\s?|\\./gi, 'x')) || /url\(/.test(ps.content + ps.backgroundImage + ps.webkitMaskImage + ps.maskImage)
|
|
3065
|
+
if (!glyph || hiddenChain(e)) continue
|
|
3066
|
+
// 无头体检时浏览器给了真实框(同一滚动位置)就用它;全问到了还没框的是没排出来;没给的(实时预览、宿主太多没问到)按样式推
|
|
3067
|
+
const at = scrollX + ',' + scrollY
|
|
3068
|
+
const fresh = window.__dshPseudoAt === at
|
|
3069
|
+
const got = fresh && e.__dshPseudo && e.__dshPseudo.at === at ? e.__dshPseudo : null
|
|
3070
|
+
const o = got ? got[which] || null : fresh && window.__dshPseudoAll ? null : pseudoBox(e, which, ps)
|
|
3071
|
+
// 伪元素是宿主的子盒:宿主自己的 overflow 也裁它;绝对 / 固定定位的按它自己的包含块裁(pos 交给 restBox)
|
|
3072
|
+
if (o && o.r - o.l >= 1 && o.b - o.t >= 1) out.push({ el: e, r: o, pos: ps.position })
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
return out
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* 底栏里横排、等宽、铺满大半条的一排(标签栏的几项):找最多的那排,返回这几项。
|
|
3080
|
+
* 绝对 / 固定定位的子元素(当前项下面的小横线这种)不算项;能横着滑、或者伸出栏外一大截的一排(轮播、商品列表)不算
|
|
3081
|
+
*/
|
|
3082
|
+
const slides = (el, top) => {
|
|
3083
|
+
for (let e = el; e; e = e === top ? null : e.parentElement) {
|
|
3084
|
+
if (/auto|scroll/.test(getComputedStyle(e).overflowX) && e.scrollWidth > e.clientWidth + 1) return true
|
|
3085
|
+
}
|
|
3086
|
+
return false
|
|
3087
|
+
}
|
|
3088
|
+
function tabCount(bar) {
|
|
3089
|
+
let best = []
|
|
3090
|
+
const walk = (el, depth) => {
|
|
3091
|
+
const ks = []
|
|
3092
|
+
for (const k of el.children) {
|
|
3093
|
+
if (isOurs(k)) continue
|
|
3094
|
+
const cs = getComputedStyle(k)
|
|
3095
|
+
if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') continue
|
|
3096
|
+
const r = k.getBoundingClientRect()
|
|
3097
|
+
if (r.width >= 1 && r.height >= 1) ks.push({ k, r })
|
|
3098
|
+
}
|
|
3099
|
+
if (ks.length > best.length) {
|
|
3100
|
+
const ws = ks.map((x) => x.r.width)
|
|
3101
|
+
const avg = ws.reduce((m, w) => m + w, 0) / ws.length
|
|
3102
|
+
const same = Math.max(...ws) - Math.min(...ws) <= Math.max(4, avg * 0.12)
|
|
3103
|
+
const row = Math.max(...ks.map((x) => x.r.top)) - Math.min(...ks.map((x) => x.r.top)) <= 4
|
|
3104
|
+
const fits = !slides(el, bar.el) && ks.every((x) => x.r.left >= bar.r.left - x.r.width / 4 && x.r.right <= bar.r.right + x.r.width / 4)
|
|
3105
|
+
if (ks.length >= 2 && same && row && fits && avg * ks.length >= bar.r.width * 0.7) best = ks.map((x) => x.k)
|
|
3106
|
+
}
|
|
3107
|
+
if (depth < 4) for (const k of el.children) if (!isOurs(k)) walk(k, depth + 1)
|
|
3108
|
+
}
|
|
3109
|
+
walk(bar.el, 0)
|
|
3110
|
+
return best
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
/**
|
|
3114
|
+
* 这排是切换页面的标签栏(不是一排操作按钮),按这个次序定:
|
|
3115
|
+
* - 这一排到栏之间(以及栏外面)写明了是工具栏(role=toolbar、class 带 toolbar / tools):不是;
|
|
3116
|
+
* - 写明了是导航(role=tab / tablist、项链到别的页面(#/home 这种路由也算,光一个 # 不算)、标了 aria-current、栏 / 这一排 / 项的 class 或 id 带 tab、nav):是;
|
|
3117
|
+
* - 一半以上是操作(裁剪、旋转、分享……):不是;一半以上是页面名(首页、分类、我的……):是;
|
|
3118
|
+
* - 全是 <button>:不是;
|
|
3119
|
+
* - 在 <nav> 里、项是占位链接(href="#")、项标了选中(active、on……)、只有一项颜色不同(字或图标):是
|
|
3120
|
+
* 认字时只看文字(字符图标 ✂、↻ 这种去掉)
|
|
3121
|
+
*/
|
|
3122
|
+
const ON = /(^|[-_\s])(on|active|current|cur|selected)($|[-_\s])/i
|
|
3123
|
+
const PAGE_WORDS = new Set('首页 主页 分类 发现 消息 购物车 我的 我 个人 个人中心 通讯录 联系人 动态 社区 推荐 关注 订单 会员 商城 广场 朋友 视频 直播 学习 课程 工作台 应用 服务 钱包 资讯 附近 活动 收藏 设置 home discover explore messages message inbox chat chats cart profile me mine account settings feed library favorites orders shop store community friends contacts notifications activity wallet categories category deals news videos live learn courses'.split(' '))
|
|
3124
|
+
const TOOL_WORDS = new Set('裁剪 剪裁 旋转 翻转 滤镜 调色 调节 贴纸 文字 文本 画笔 涂鸦 马赛克 边框 美颜 特效 配乐 音乐 变速 分割 删除 复制 粘贴 撤销 重做 分享 点赞 评论 转发 下载 保存 编辑 加粗 斜体 下划线 对齐 插入 表格 链接 拍照 相册 亮度 对比度 饱和度 美化 模板 背景 橡皮 播放 暂停 静音 全屏 crop rotate flip filter filters adjust sticker stickers text draw brush erase eraser undo redo share like comment save edit delete copy paste bold italic underline align insert download trim split speed effects music play pause mute fullscreen'.split(' '))
|
|
3125
|
+
const classOf = (el) => (el.getAttribute('class') || '') + ' ' + el.id
|
|
3126
|
+
const labelOf = (k) => String(k.innerText || k.textContent || '').toLowerCase().replace(/[^\p{L}]+/gu, '')
|
|
3127
|
+
function navLike(bar, items) {
|
|
3128
|
+
const row = items[0].parentElement
|
|
3129
|
+
const boxes = [] // 这一排一直到栏(再往外由 closest 看)
|
|
3130
|
+
for (let e = row; e; e = e === bar.el ? null : e.parentElement) boxes.push(e)
|
|
3131
|
+
if (boxes.some((e) => e.matches('[role=toolbar]') || /(^|[-_\s])tool(bar|s)?($|[-_\s])/i.test(classOf(e))) || bar.el.closest('[role=toolbar]')) return false
|
|
3132
|
+
const page = (k) => k.matches('a[href]') && !/^(#?|javascript:.*)$/i.test(k.getAttribute('href').trim())
|
|
3133
|
+
if (items.some((k) => page(k) || k.matches('[role=tab],[aria-current]:not([aria-current=false])')) || boxes.some((e) => e.matches('[role=tablist]'))) return true
|
|
3134
|
+
if ([...boxes, ...items].some((e) => /tab(?!le)|(^|[-_\s])nav/i.test(classOf(e)))) return true
|
|
3135
|
+
const labels = items.map(labelOf)
|
|
3136
|
+
if (labels.filter((x) => TOOL_WORDS.has(x)).length * 2 >= items.length) return false
|
|
3137
|
+
if (labels.filter((x) => PAGE_WORDS.has(x)).length * 2 >= items.length) return true
|
|
3138
|
+
if (items.every((k) => k.matches('button,[role=button]'))) return false
|
|
3139
|
+
if (bar.el.closest('nav,[role=navigation]') || bar.el.querySelector('nav,[role=navigation]') || items.some((k) => k.matches('a[href]'))) return true
|
|
3140
|
+
if (items.some((k) => k.matches('[aria-selected=true]') || ON.test(classOf(k)) || [...k.querySelectorAll('*')].some((e) => ON.test(classOf(e))))) return true
|
|
3141
|
+
// 只有一项颜色不同:字的颜色,或者图标(svg 的 color / fill、图标字体的颜色)
|
|
3142
|
+
const tint = (k) => {
|
|
3143
|
+
const t = [k, ...k.querySelectorAll('*')].find((e) => ownText(e)) || k
|
|
3144
|
+
const ic = k.querySelector('svg,i,[class*=icon]')
|
|
3145
|
+
const ics = ic ? getComputedStyle(ic) : null
|
|
3146
|
+
return getComputedStyle(t).color + '|' + (ics ? ics.color + ics.fill : '')
|
|
3147
|
+
}
|
|
3148
|
+
const tints = items.map(tint)
|
|
3149
|
+
const odd = tints.filter((c) => tints.filter((x) => x === c).length === 1)
|
|
3150
|
+
return odd.length === 1 && new Set(tints).size === 2
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
/**
|
|
3154
|
+
* 手机(App、小程序):
|
|
3155
|
+
* - 灵动岛挡住的字和按钮(外框画在页面上面,挡住的看不见);
|
|
3156
|
+
* - 底栏里的字、图标伸进底部横条区(让出 34px,栏的底色可以铺下去);
|
|
3157
|
+
* - 标签栏超过 5 项(小程序最多 5 项,微信直接报错);
|
|
3158
|
+
* - 小程序:导航栏那条(44–88)什么都没有。
|
|
3159
|
+
* 矮的手机(高不到宽的 1.9 倍)没有灵动岛、横条,只看后两条
|
|
3160
|
+
*/
|
|
3161
|
+
function phoneCheck(add, atoms, vw, kind) {
|
|
3162
|
+
const vh = innerHeight
|
|
3163
|
+
const tall = vh / vw >= 1.9
|
|
3164
|
+
if (tall) {
|
|
3165
|
+
const isl = { l: vw / 2 - ISLAND.w / 2, r: vw / 2 + ISLAND.w / 2, t: ISLAND.top, b: ISLAND.top + ISLAND.h }
|
|
3166
|
+
zoneCheck(add, atoms, isl, 'island', (o, ow, oh) => oh, {})
|
|
3167
|
+
}
|
|
3168
|
+
const line = vh - HOMEBAR
|
|
3169
|
+
for (const bar of barsAt('bottom', true)) {
|
|
3170
|
+
if (tall) {
|
|
3171
|
+
let worst = null
|
|
3172
|
+
// 只量看得见的那截:被自己(省略号、多行截断)或外层(轮播)裁掉的、在屏幕外的不算
|
|
3173
|
+
const look = (el, r, self, pos) => {
|
|
3174
|
+
const o = restBox(el, true, null, self, pos)(r)
|
|
3175
|
+
if (!o || o.r <= 0 || o.l >= vw || o.t >= vh || o.b <= 0) return
|
|
3176
|
+
const d = Math.min(o.b, vh) - line
|
|
3177
|
+
if (d > 6 && (!worst || d > worst.d)) worst = { el, d }
|
|
3178
|
+
}
|
|
3179
|
+
for (const a of atoms) if (bar.el.contains(a.el) && !hiddenChain(a.el)) for (const r of a.rects) look(a.el, r, true)
|
|
3180
|
+
for (const ic of iconRects(bar.el, true)) look(ic.el, ic.r, !!ic.pos, ic.pos)
|
|
3181
|
+
// 伸进几像素看不出来,不报;伸进 12px 以上算严重
|
|
3182
|
+
if (worst) add('homebar', worst.el, { px: Math.round(worst.d), bar: nameOf(bar.el) }, worst.d >= 12 ? 2 : 1)
|
|
3183
|
+
}
|
|
3184
|
+
const items = tabCount(bar)
|
|
3185
|
+
if (items.length > 5 && navLike(bar, items)) add('tabbar', bar.el, { n: items.length }, kind === 'miniprogram' ? 2 : 1)
|
|
3186
|
+
}
|
|
3187
|
+
if (kind !== 'miniprogram') return
|
|
3188
|
+
// 导航栏那条:没滚时有字、图标或按钮的竖直中线落在里面就算有
|
|
3189
|
+
const band = { t: NAVBAR.top, b: NAVBAR.top + NAVBAR.h }
|
|
3190
|
+
const inBand = (o) => o && (o.t + o.b) / 2 >= band.t && (o.t + o.b) / 2 <= band.b
|
|
3191
|
+
const seen = (el, rects, self) => {
|
|
3192
|
+
if (hiddenChain(el)) return false
|
|
3193
|
+
const rest = restBox(el, false, null, self)
|
|
3194
|
+
return rects.some((r) => inBand(rest(r)))
|
|
3195
|
+
}
|
|
3196
|
+
for (const a of atoms) if (seen(a.el, a.rects, true)) return
|
|
3197
|
+
for (const el of document.body.querySelectorAll(CLICKABLE)) {
|
|
3198
|
+
if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
3199
|
+
const r = el.getBoundingClientRect()
|
|
3200
|
+
if (r.width >= 1 && r.height >= 1 && seen(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])) return
|
|
3201
|
+
}
|
|
3202
|
+
for (const ic of iconRects(document.body)) if (seen(ic.el, [ic.r])) return
|
|
3203
|
+
add('navbar', null, { t: band.t, b: band.b }, 1)
|
|
2503
3204
|
}
|
|
2504
3205
|
|
|
2505
3206
|
/**
|
|
@@ -2563,8 +3264,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2563
3264
|
const m = document.querySelector('meta[name="dsh-capsule"]')
|
|
2564
3265
|
const say = m ? String(m.getAttribute('content') || '').trim().toLowerCase() : ''
|
|
2565
3266
|
if (say === 'white' || say === 'black') return say === 'white'
|
|
2566
|
-
|
|
2567
|
-
const
|
|
3267
|
+
// Windows 桌面软件看右上角三个按钮那块,别的看胶囊那块
|
|
3268
|
+
const win = deskOs() === 'win'
|
|
3269
|
+
const x = win ? innerWidth - WINBTN.w * 1.5 : innerWidth - CAP.right - CAP.w / 2
|
|
3270
|
+
const y = win ? WINBTN.h / 2 : CAP.top + CAP.h / 2
|
|
2568
3271
|
const hits = paintedAt(x, y).filter((e) => !isOurs(e) && !(e instanceof SVGElement)).reverse() // 从下往上
|
|
2569
3272
|
const { base, onCanvas } = canvasBase()
|
|
2570
3273
|
const layers = []
|
|
@@ -2635,7 +3338,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2635
3338
|
function holds(cs, fixed) {
|
|
2636
3339
|
return (!fixed && cs.position !== 'static') || moved(cs) || cs.perspective !== 'none' || cs.filter !== 'none' ||
|
|
2637
3340
|
(!!cs.backdropFilter && cs.backdropFilter !== 'none') || /paint|layout|strict|content/.test(cs.contain) ||
|
|
2638
|
-
/transform|scale|translate|rotate|perspective|filter/.test(cs.willChange)
|
|
3341
|
+
/transform|scale|translate|rotate|perspective|filter|contain/.test(cs.willChange)
|
|
2639
3342
|
}
|
|
2640
3343
|
/** 变换过的:transform,或单独写的 scale / translate / rotate */
|
|
2641
3344
|
function moved(cs) {
|
|
@@ -2805,6 +3508,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2805
3508
|
return { white }
|
|
2806
3509
|
}
|
|
2807
3510
|
|
|
3511
|
+
/** 截图时把桌面软件的窗口按钮画上去(和右侧栏外框一样):solid 实的、ghost 半透明,别的值撤掉 */
|
|
3512
|
+
let winEl = null
|
|
3513
|
+
function showWinCtl(how) {
|
|
3514
|
+
if (winEl) { winEl.remove(); winEl = null }
|
|
3515
|
+
if (how !== 'solid' && how !== 'ghost') return null
|
|
3516
|
+
const os = deskOs()
|
|
3517
|
+
const h = document.createElement('dsh-design-fx')
|
|
3518
|
+
let svg
|
|
3519
|
+
if (os === 'win') {
|
|
3520
|
+
const W = WINBTN.w, H = WINBTN.h
|
|
3521
|
+
const c = topDark() ? '#fff' : '#1f1f1f'
|
|
3522
|
+
h.style.cssText = `position:fixed;top:0;right:0;width:${W * 3}px;height:${H}px`
|
|
3523
|
+
svg = `<svg width="${W * 3}" height="${H}" viewBox="0 0 ${W * 3} ${H}" style="display:block" fill="none" stroke="${c}" stroke-width="1">` +
|
|
3524
|
+
`<path d="M${W / 2 - 5} ${H / 2 + 0.5}h10"/><rect x="${W * 1.5 - 4.5}" y="${H / 2 - 4.5}" width="9" height="9" rx="1"/>` +
|
|
3525
|
+
`<path d="M${W * 2.5 - 5} ${H / 2 - 5}l10 10M${W * 2.5 + 5} ${H / 2 - 5}l-10 10"/></svg>`
|
|
3526
|
+
} else {
|
|
3527
|
+
const { x, y, d, gap } = LIGHTS
|
|
3528
|
+
const w = d * 3 + gap * 2
|
|
3529
|
+
h.style.cssText = `position:fixed;top:${y}px;left:${x}px;width:${w}px;height:${d}px`
|
|
3530
|
+
svg = `<svg width="${w}" height="${d}" viewBox="0 0 ${w} ${d}" style="display:block">` +
|
|
3531
|
+
['#FF5F57', '#FEBC2E', '#28C840'].map((c, i) => `<circle cx="${d / 2 + i * (d + gap)}" cy="${d / 2}" r="${d / 2 - 0.25}" fill="${c}" stroke="rgba(0,0,0,.12)" stroke-width=".5"/>`).join('') + '</svg>'
|
|
3532
|
+
}
|
|
3533
|
+
h.style.cssText += `;z-index:2147483646;pointer-events:none;opacity:${how === 'ghost' ? 0.5 : 1}`
|
|
3534
|
+
h.attachShadow({ mode: 'closed' }).innerHTML = svg
|
|
3535
|
+
document.documentElement.appendChild(h)
|
|
3536
|
+
winEl = h
|
|
3537
|
+
return { os }
|
|
3538
|
+
}
|
|
3539
|
+
|
|
2808
3540
|
/** 量之前等字体、图片载完(最多等 2 秒):刚载入的页面字体没到时字宽不对,断行、截断都会量错 */
|
|
2809
3541
|
async function settle() {
|
|
2810
3542
|
const wait = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))])
|
|
@@ -3012,6 +3744,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
3012
3744
|
},
|
|
3013
3745
|
dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
|
|
3014
3746
|
capsule: (how) => showCapsule(how),
|
|
3747
|
+
winctl: (how) => showWinCtl(how),
|
|
3015
3748
|
/** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
|
|
3016
3749
|
tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
|
|
3017
3750
|
settle: () => settle().then(() => true),
|