dsh-design-studio 0.7.1 → 0.9.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/picker.js CHANGED
@@ -707,6 +707,8 @@
707
707
  dark: hasDark(),
708
708
  changes: pending ? { count: pending.count, whole: pending.whole } : null,
709
709
  })
710
+ postTop()
711
+ if (document.readyState !== 'complete') addEventListener('load', postTop, { once: true }) // 顶栏的底色可能等样式表 / 字体载完才定
710
712
  if (shown) reveal()
711
713
  }
712
714
 
@@ -1429,7 +1431,18 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1429
1431
  return !c || c === 'transparent' || /^rgba\(.*,\s*0\)$/.test(c) ? '' : c
1430
1432
  }
1431
1433
  const de = document.documentElement
1432
- return bg(de) || bg(document.body) || (/dark/.test(getComputedStyle(de).colorScheme) ? '#121212' : '#fff')
1434
+ return bg(de) || bg(document.body) || (schemeDark() ? '#121212' : '#fff')
1435
+ }
1436
+
1437
+ /**
1438
+ * 页面实际用的是深色配色吗(决定没写底色时画布是白的还是深的):color-scheme(没写看 <meta name="color-scheme">)只有 dark,
1439
+ * 或 light、dark 都写了而系统偏好深色
1440
+ */
1441
+ function schemeDark() {
1442
+ let s = String(getComputedStyle(document.documentElement).colorScheme || 'normal')
1443
+ if (s === 'normal') { const m = document.querySelector('meta[name="color-scheme"]'); s = m ? String(m.getAttribute('content') || '') : '' }
1444
+ if (!/dark/.test(s)) return false
1445
+ return !/light/.test(s) || matchMedia('(prefers-color-scheme: dark)').matches
1433
1446
  }
1434
1447
 
1435
1448
  async function capture(opts) {
@@ -1578,6 +1591,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1578
1591
  }
1579
1592
 
1580
1593
  try {
1594
+ const top = topDark() // 和复制页面同一刻量(后面等图片、字体时页面可能又变了)
1581
1595
  const root = walk(de)
1582
1596
  // 字体:页面样式表里的 @font-face,只带用到的字族
1583
1597
  const faces = []
@@ -1621,7 +1635,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1621
1635
  ctx.fillRect(0, 0, canvas.width, canvas.height)
1622
1636
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
1623
1637
  const png = await new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob 失败'))), 'image/png'))
1624
- return { png, w: W, h: H, scale }
1638
+ return { png, w: W, h: H, scale, top }
1625
1639
  } finally {
1626
1640
  box.remove()
1627
1641
  }
@@ -1633,7 +1647,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1633
1647
  // 整页图从文档顶上画起,链接加上滚动距离;只截视口时就是视口里的位置
1634
1648
  const links = linksOf().map((l) => (d.full ? { ...l, x: l.x + scrollX, y: l.y + scrollY } : l))
1635
1649
  .filter((l) => l.x < r.w && l.y < r.h && l.x + l.w > 0 && l.y + l.h > 0)
1636
- post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title, links })
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 })
1637
1651
  } catch (e) {
1638
1652
  post({ type: 'captured', id: d.id, error: String((e && e.message) || e) })
1639
1653
  }
@@ -1644,12 +1658,24 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1644
1658
  // hscroll 超出屏幕(会横向滚动 / 被切掉) · clipped 文字被截断 · overflow 文字超出框 · overlap 文字叠在一起
1645
1659
  // contrast 对比度不够 · tap 点击区太小 · tiny 字太小 · img 图片没加载出来 · fonts 字号太杂
1646
1660
  // covered 被固定栏压住(滚到底也看不全) · tight 离固定栏太近 · jserr 页面脚本报错 · missing 样式表 / 脚本 / 字体没载出来
1661
+ // capsule 小程序右上角的胶囊(微信画的,页面盖不住)挡住了字或按钮
1662
+ // island 灵动岛挡住了字或按钮 · homebar 底栏里的字、图标伸进底部横条区 · tabbar 标签栏超过 5 项 · navbar 小程序这屏没有导航栏
1663
+ // winctl 桌面软件的窗口按钮(Mac 红绿灯 / Windows 右上角三个)挡住了字或按钮
1647
1664
  // 有意为之的不报:省略号(text-overflow: ellipsis)、行数截断(line-clamp)、可以滚动的区域(轮播)、很淡的装饰大字。
1648
1665
 
1649
1666
  const CHECK_PER = 8 // 每类最多报几条
1650
1667
  const CHECK_MAX = 40
1651
1668
  const CHECK_ATOMS = 600 // 最多看多少个带字的元素
1652
1669
  const CLICKABLE = 'a[href],button,[role="button"],[role="tab"],[role="link"],input:not([type="hidden"]),select,textarea,summary,[onclick],[data-href]'
1670
+ // 小程序右上角的胶囊:离屏幕顶 48(状态栏 44 + 4)、离右边 7、87×32(和右侧栏外框画的一样)
1671
+ const CAP = { top: 48, right: 7, w: 87, h: 32 }
1672
+ // 预览手机的灵动岛:离顶 11、118×34、居中(和外框 .ddp-island 一样);矮的手机(高不到宽的 1.9 倍)没有,也没有底部横条
1673
+ const ISLAND = { top: 11, w: 118, h: 34 }
1674
+ const HOMEBAR = 34 // 底部横条区(home indicator)的高
1675
+ const NAVBAR = { top: 44, h: 44 } // 小程序导航栏:状态栏 44 下面那 44
1676
+ // 桌面软件的窗口按钮(和右侧栏外框画的一样):Mac 红绿灯在左上 x 20、y 20 起,三个 12px 圆点隔 8;Windows 最小化 / 最大化 / 关闭在右上角,各 46×32
1677
+ const LIGHTS = { x: 20, y: 20, d: 12, gap: 8 }
1678
+ const WINBTN = { w: 46, h: 32 }
1653
1679
  let checkMarks = []
1654
1680
 
1655
1681
  const isOurs = (el) => SIG_SKIP.has(el.tagName)
@@ -1672,13 +1698,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1672
1698
  return o
1673
1699
  }
1674
1700
 
1675
- /** 'rgb(1, 2, 3)' / 'rgba(1, 2, 3, .5)' / 'color(srgb …)' → [r, g, b, a];别的写法(oklch 之类浏览器已经换算过)认不出就 null */
1701
+ /**
1702
+ * 'rgb(1, 2, 3)' / 'rgba(1, 2, 3, .5)' / 'color(srgb …)' → [r, g, b, a]。
1703
+ * oklch()、oklab()、lab()、lch()、hwb()、color(display-p3 …) 这些(算好的值保留原写法,Tailwind 4 默认就是 oklch):交给 canvas 换成 sRGB。认不出就 null
1704
+ */
1705
+ const colorMemo = new Map()
1706
+ let colorCtx = null
1676
1707
  function rgba(s) {
1677
1708
  const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s)
1678
1709
  if (m) return [+m[1], +m[2], +m[3], m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]]
1679
1710
  const c = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/.exec(s)
1680
1711
  if (c) return [c[1] * 255, c[2] * 255, c[3] * 255, c[4] == null ? 1 : c[4].endsWith('%') ? parseFloat(c[4]) / 100 : +c[4]]
1681
- return null
1712
+ if (typeof s !== 'string' || !/^(?:oklch|oklab|lab|lch|hwb|hsla?|color)\(/.test(s)) return null
1713
+ if (colorMemo.has(s)) return colorMemo.get(s)
1714
+ let out = null
1715
+ try {
1716
+ if (!colorCtx) { const cv = document.createElement('canvas'); cv.width = cv.height = 1; colorCtx = cv.getContext('2d', { willReadFrequently: true }) }
1717
+ const g = colorCtx
1718
+ g.fillStyle = '#010203'
1719
+ g.fillStyle = s
1720
+ if (g.fillStyle !== '#010203') {
1721
+ g.clearRect(0, 0, 1, 1)
1722
+ g.fillRect(0, 0, 1, 1)
1723
+ const d = g.getImageData(0, 0, 1, 1).data
1724
+ out = [d[0], d[1], d[2], d[3] / 255]
1725
+ }
1726
+ } catch { out = null }
1727
+ if (colorMemo.size > 500) colorMemo.clear()
1728
+ colorMemo.set(s, out)
1729
+ return out
1682
1730
  }
1683
1731
  const over = (top, under) => {
1684
1732
  const a = top[3]
@@ -1693,37 +1741,158 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1693
1741
 
1694
1742
  const MEDIA = new Set(['IMG', 'VIDEO', 'CANVAS', 'IFRAME', 'PICTURE', 'OBJECT', 'EMBED'])
1695
1743
  /**
1696
- * 字背后的底色:从字往下一层层叠背景色,遇到不透明的为止;中间有背景图、渐变、图片视频就说不准,返回 null。
1697
- * 字在屏幕里时按实际叠放顺序找(字下面压着的可能不是祖先,比如首屏背景视频);不在屏幕里就顺着祖先找
1744
+ * 字背后的底色,和字叠上去以后的颜色:从字往下一层层叠背景色,遇到不透明(且不在半透明的组里)的为止;
1745
+ * 透明度按组算(见 compose),字的透明度也一样。中间有背景图、渐变、图片视频就说不准,返回 null。
1746
+ * 字在屏幕里时按实际叠放顺序找(字下面压着的可能不是祖先,比如首屏背景视频、不可点的装饰底);
1747
+ * 不在屏幕里、或被滚动区裁掉的按位置找(paintedBelow)。pool:页面上画了底的元素(见 paintPool)
1698
1748
  */
1699
- function backdrop(el, pt) {
1749
+ function backdrop(el, pt, fg, pool) {
1700
1750
  let chain = null
1701
- if (pt && pt.x >= 0 && pt.y >= 0 && pt.x < innerWidth && pt.y < innerHeight) {
1702
- const stack = document.elementsFromPoint(pt.x, pt.y)
1751
+ if (pt.x >= 0 && pt.y >= 0 && pt.x < innerWidth && pt.y < innerHeight) {
1752
+ const stack = paintedAt(pt.x, pt.y) // 不可点的背景层(装饰底图、卡片底色)也算
1703
1753
  const i = stack.indexOf(el)
1704
1754
  if (i >= 0) chain = stack.slice(i)
1705
1755
  }
1706
- if (!chain) { chain = []; for (let e = el; e && e.nodeType === 1; e = e.parentElement) chain.push(e) }
1756
+ if (!chain) chain = paintedBelow(el, pt.x, pt.y, pool())
1757
+ if (!chain) return null
1758
+ const { base, explicit, onCanvas } = canvasBase()
1707
1759
  const layers = []
1708
- let base = null
1760
+ let floor = false
1709
1761
  for (const e of chain) {
1710
1762
  if (isOurs(e)) continue
1763
+ if (e !== el && !e.contains(el) && opacityChain(e) <= 0.01) continue // 整个透明的装饰层(淡入前、藏起来的)什么都没画
1711
1764
  if (MEDIA.has(e.tagName) || (e instanceof SVGElement && e.tagName.toLowerCase() !== 'svg')) return null
1712
1765
  const cs = getComputedStyle(e)
1713
1766
  if (cs.backgroundImage && cs.backgroundImage !== 'none') return null
1714
1767
  const c = rgba(cs.backgroundColor)
1715
1768
  if (!c) return null
1716
- if (c[3] > 0) {
1717
- if (c[3] >= 0.999) { base = c; break }
1718
- layers.push(c)
1769
+ if (onCanvas(e) || c[3] <= 0) continue
1770
+ layers.push([e, c])
1771
+ if (c[3] >= 0.999 && opacityChain(e) >= 0.999) { floor = true; break }
1772
+ }
1773
+ if (!floor && darkOn && !explicit) return null // 深色模式下画布底色不一定是白的
1774
+ layers.reverse()
1775
+ return { color: compose(layers, base), fg: fg ? compose([...layers, [el, fg]], base) : null }
1776
+ }
1777
+
1778
+ /**
1779
+ * 页面上画了东西、看得见的元素(底色、背景图、图片视频、SVG 图形)和它们现在的框,按位置找背景用。
1780
+ * 按最近的固定 / 吸顶那层(自己或祖先,没有是 null)分组,组里按竖直位置分段放(rows,每段 POOL_ROW px)
1781
+ */
1782
+ const POOL_ROW = 256
1783
+ function paintPool() {
1784
+ const groups = new Map()
1785
+ const pins = new Map()
1786
+ const held = new Map() // 祖先里有给固定定位当包含块的(transform 之类):里面的 fixed 其实跟着它走
1787
+ const heldIn = (e) => {
1788
+ if (!e || e.nodeType !== 1) return false
1789
+ if (!held.has(e)) held.set(e, holds(getComputedStyle(e), true) || heldIn(e.parentElement))
1790
+ return held.get(e)
1791
+ }
1792
+ const pinOf = (e) => {
1793
+ if (!e || e.nodeType !== 1) return null
1794
+ if (pins.has(e)) return pins.get(e)
1795
+ const pos = getComputedStyle(e).position
1796
+ const v = pos === 'sticky' || (pos === 'fixed' && !heldIn(e.parentElement)) ? e : pinOf(e.parentElement)
1797
+ pins.set(e, v)
1798
+ return v
1799
+ }
1800
+ const all = [document.documentElement, ...(document.body ? [document.body, ...document.body.getElementsByTagName('*')] : [])]
1801
+ for (const e of all) {
1802
+ if (isOurs(e)) continue
1803
+ const svg = e instanceof SVGElement && e.tagName.toLowerCase() !== 'svg'
1804
+ const cs = getComputedStyle(e)
1805
+ if (cs.visibility !== 'visible') continue
1806
+ const c = rgba(cs.backgroundColor)
1807
+ if (!svg && !MEDIA.has(e.tagName) && c && c[3] <= 0 && cs.backgroundImage === 'none') continue
1808
+ const r = e.getBoundingClientRect()
1809
+ if (r.width < 1 || r.height < 1) continue
1810
+ const pin = pinOf(e)
1811
+ if (!groups.has(pin)) groups.set(pin, { rows: new Map(), all: [] })
1812
+ const g = groups.get(pin)
1813
+ g.all.push([e, r])
1814
+ for (let k = Math.floor(r.top / POOL_ROW), n = Math.floor(r.bottom / POOL_ROW); k <= n; k++) {
1815
+ if (!g.rows.has(k)) g.rows.set(k, [])
1816
+ g.rows.get(k).push([e, r])
1719
1817
  }
1720
1818
  }
1721
- if (!base) {
1722
- if (darkOn) return null // 深色模式下画布底色不一定是白的
1723
- base = [255, 255, 255, 1]
1819
+ // 固定 / 吸顶的那些对这个字来说看得见吗:整体透明的不算(量过记着);被它自己那一支(不包着字的那几层)裁光的不算,
1820
+ // 和字共用的滚动区暂时裁掉的照算(字滚进来时它也跟着进来)
1821
+ const gone = new Map()
1822
+ const clear = (e) => {
1823
+ if (!gone.has(e)) gone.set(e, opacityChain(e) <= 0.01)
1824
+ return gone.get(e)
1825
+ }
1826
+ const shown = (e, r, el) => !clear(e) && !!restBox(e, true, el)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
1827
+ return { groups, pinOf, shown, clear }
1828
+ }
1829
+
1830
+ /** 底色不透明、没有背景图、不在半透明组里:叠到它就到底了 */
1831
+ function solidFloor(e) {
1832
+ if (MEDIA.has(e.tagName) || e instanceof SVGElement) return false
1833
+ const cs = getComputedStyle(e)
1834
+ const c = rgba(cs.backgroundColor)
1835
+ return !!c && c[3] >= 0.999 && cs.backgroundImage === 'none' && opacityChain(e) >= 0.999
1836
+ }
1837
+
1838
+ /**
1839
+ * 字所在这一点(视口坐标,可以在屏幕外、被滚动区裁掉)下面画着的,从字往下排:框盖住这一点、画在字下面的(祖先,或按层叠顺序在它下面的);
1840
+ * 只算它自己那一支的裁剪。字在能滚的区域(main、横向轮播)里没滚进来时,对那个区域外面的东西(和区域自己的底)来说,
1841
+ * 字滚进来以后在区域露出来的那块里——按就近滚进来算位置(由里往外一层层挪进去)。
1842
+ * 和字不在同一个固定 / 吸顶层里的跟着窗口走,字滚进来以后它在哪说不准:横向盖得住、画在字下面、又不在到底那层(字自己的底也算)下面的,
1843
+ * 返回 null(不查)。不滚页面(滚了会打断页面自己的滚动、带着外层画布一起滚)
1844
+ */
1845
+ function paintedBelow(el, x, y, pool) {
1846
+ const mine = new Set([null]) // 和字一起动的:不固定的,和字所在的各层固定 / 吸顶
1847
+ for (let p = pool.pinOf(el); p; p = pool.pinOf(p.parentElement)) mine.add(p)
1848
+ // 字外面会裁的各层(由里往外)和字一层层挪进去以后的位置:pts[j] = 挪进了前 j 层
1849
+ const boxes = []
1850
+ const pts = [{ x, y }]
1851
+ for (let e = el.parentElement; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
1852
+ const cs = getComputedStyle(e)
1853
+ const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible'
1854
+ if (!cx && !cy) continue
1855
+ const r = e.getBoundingClientRect()
1856
+ const kx = e.offsetWidth ? r.width / e.offsetWidth : 1 // 这层实际的缩放(transform、zoom),和 restBox 一样换成视口像素
1857
+ const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
1858
+ const l = r.left + e.clientLeft * kx, t = r.top + e.clientTop * ky
1859
+ const q = pts[pts.length - 1]
1860
+ boxes.push(e)
1861
+ pts.push({ x: cx ? Math.min(Math.max(q.x, l + 1), l + e.clientWidth * kx - 1) : q.x, y: cy ? Math.min(Math.max(q.y, t + 1), t + e.clientHeight * ky - 1) : q.y })
1862
+ }
1863
+ // c 看到的字在哪:c 外面那几层(c 自己能滚的话也算,它的底不跟着内容走)挪进去以后
1864
+ const ptFor = (c) => { let j = 0; while (j < boxes.length && (boxes[j] === c || !boxes[j].contains(c))) j++; return pts[j] }
1865
+ const near = new Set()
1866
+ for (const pin of mine) {
1867
+ const g = pool.groups.get(pin)
1868
+ if (!g) continue
1869
+ for (const k of new Set(pts.map((p) => Math.floor(p.y / POOL_ROW)))) for (const it of g.rows.get(k) || []) near.add(it)
1870
+ }
1871
+ const below = []
1872
+ for (const [c, r] of near) {
1873
+ if (c === el || el.contains(c)) continue
1874
+ const p = ptFor(c)
1875
+ if (p.x < r.left || p.x > r.right || p.y < r.top || p.y > r.bottom) continue
1876
+ if (!paintsOver(el, c)) continue // 盖在字上面的不算
1877
+ if (!c.contains(el)) {
1878
+ const vis = restBox(c, true, el)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
1879
+ if (!vis || p.x < vis.l || p.x > vis.r || p.y < vis.t || p.y > vis.b) continue
1880
+ }
1881
+ below.push(c)
1882
+ }
1883
+ below.sort((a, b) => (paintsOver(a, b) ? -1 : paintsOver(b, a) ? 1 : 0))
1884
+ const { onCanvas } = canvasBase()
1885
+ const floor = [el, ...below].find((c) => !onCanvas(c) && solidFloor(c))
1886
+ const out = pts[pts.length - 1].x // 各层都挪进去以后的横向位置
1887
+ for (const [pin, g] of pool.groups) {
1888
+ if (mine.has(pin)) continue
1889
+ for (const [c, r] of g.all) {
1890
+ if (out < r.left || out > r.right || pool.clear(c) || el.contains(c)) continue
1891
+ if (floor && paintsOver(floor, c)) continue // 在到底那层下面(同一层的字都一样,记着)
1892
+ if (paintsOver(el, c) && pool.shown(c, r, el)) return null
1893
+ }
1724
1894
  }
1725
- for (let i = layers.length - 1; i >= 0; i--) base = over(layers[i], base)
1726
- return { color: base }
1895
+ return [el, ...below]
1727
1896
  }
1728
1897
 
1729
1898
  /** 自己那几段文字在屏幕上的矩形(每行一块) */
@@ -1781,15 +1950,17 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1781
1950
  /** 滚完等两帧、再等新露出来的图和字体(页面的滚动监听、懒加载的图有机会跟上),再截图 */
1782
1951
  const afterScroll = (v) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(v)))).then((v2) => settle().then(() => v2, () => v2))
1783
1952
 
1784
- function barsAt(edge) {
1953
+ function barsAt(edge, inflow = false) {
1785
1954
  const out = []
1786
1955
  const vw = innerWidth, vh = innerHeight
1956
+ // inflow:页面本身不滚(flex / grid 撑满一屏、中间内容区自己滚)时,贴着屏幕边的普通元素也算栏
1957
+ const shell = inflow && (document.scrollingElement || document.documentElement).scrollHeight <= vh + 2
1787
1958
  const all = document.body.getElementsByTagName('*')
1788
1959
  for (let i = 0; i < all.length; i++) {
1789
1960
  const el = all[i]
1790
1961
  if (isOurs(el)) continue
1791
1962
  const pos = getComputedStyle(el).position
1792
- if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute') continue
1963
+ if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute' && !shell) continue
1793
1964
  if (out.some((b) => b.el.contains(el))) continue // 栏里面的东西
1794
1965
  const r = el.getBoundingClientRect()
1795
1966
  if (r.width < vw * 0.5 || r.height < 16 || r.height > vh * BAR_MAX) continue
@@ -2137,10 +2308,12 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2137
2308
  }
2138
2309
  }
2139
2310
 
2140
- // 4. 对比度(WCAG:正文 4.5,大字 3)
2311
+ // 4. 对比度(WCAG:正文 4.5,大字 3)。取底色那段装一次「量画着什么」的临时样式(见 withProbe),量完撤掉
2141
2312
  const sizes = new Map()
2142
- const tinyMin = kind === 'app' ? 10 : kind === 'tablet' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
2143
- for (const a of atoms) {
2313
+ const tinyMin = kind === 'app' || kind === 'miniprogram' ? 10 : kind === 'tablet' || kind === 'desktop' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
2314
+ let pool = null
2315
+ const poolOf = () => pool || (pool = paintPool()) // 有字不在屏幕里才建
2316
+ withProbe(() => { for (const a of atoms) {
2144
2317
  const { el, cs } = a
2145
2318
  const px = parseFloat(cs.fontSize) || 0
2146
2319
  sizes.set(Math.round(px * 2) / 2, (sizes.get(Math.round(px * 2) / 2) || 0) + 1)
@@ -2153,17 +2326,17 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2153
2326
  const op = opacityChain(el)
2154
2327
  // 半透明还在变的(淡入动画、跟着滚动渐显的)量的是中间状态,不算
2155
2328
  if (op < 0.999 && (!inView(el) || animating(el))) continue
2156
- const bd = backdrop(el, { x: (r0.l + r0.r) / 2, y: (r0.t + r0.b) / 2 })
2157
- if (!bd) continue
2158
- fg = over([fg[0], fg[1], fg[2], fg[3] * op], bd.color)
2329
+ const bd = backdrop(el, { x: (r0.l + r0.r) / 2, y: (r0.t + r0.b) / 2 }, fg, poolOf)
2330
+ if (!bd || !bd.fg) continue
2331
+ fg = bd.fg
2159
2332
  const cr = ratio(fg, bd.color)
2160
2333
  const bold = +cs.fontWeight >= 700
2161
2334
  const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5
2162
2335
  if (cr < need - 0.05) add('contrast', el, { ratio: Math.round(cr * 100) / 100, need, fg: hex(fg), bg: hex(bd.color) }, cr < need * 0.75 ? 2 : 1)
2163
- }
2336
+ } })
2164
2337
 
2165
- // 5. 点击区太小(App / 平板 44,网页 24;文字中间的链接不算)
2166
- const minTap = kind === 'app' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
2338
+ // 5. 点击区太小(App / 小程序 / 平板 44,网页 24;文字中间的链接不算)
2339
+ const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' || kind === 'desktop' ? 24 : 0
2167
2340
  if (minTap) {
2168
2341
  const small = []
2169
2342
  for (const el of body.querySelectorAll(CLICKABLE)) {
@@ -2255,6 +2428,13 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2255
2428
  for (const f of document.fonts || []) if (f.status === 'error') add('missing', null, { what: 'font', src: String(f.family || '').replace(/['"]/g, '') }, 1)
2256
2429
  } catch { /* 老浏览器 */ }
2257
2430
 
2431
+ // 13. 小程序:右上角胶囊挡住的字和按钮。不固定的按没滚时的位置算(滚下去本来就会从胶囊下面过)
2432
+ if (kind === 'miniprogram') capsuleCheck(add, atoms, vw)
2433
+ // 14. 手机:灵动岛挡住的、伸进底部横条区的,标签栏项数;小程序每屏要有导航栏
2434
+ if (kind === 'app' || kind === 'miniprogram') phoneCheck(add, atoms, vw, kind)
2435
+ // 15. 桌面软件:窗口按钮挡住的字和按钮
2436
+ if (kind === 'desktop') winCheck(add, atoms, vw)
2437
+
2258
2438
  issues.sort((x, y) => y.sev - x.sev)
2259
2439
  const se0 = se()
2260
2440
  return {
@@ -2263,6 +2443,740 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2263
2443
  }
2264
2444
  }
2265
2445
 
2446
+ /**
2447
+ * 元素在「各层都没滚」时露出来的那块(视口坐标):窗口、内部滚动区滚过的量加回去,被外层裁掉的去掉。
2448
+ * 滚动量、边框都按那层实际的缩放(transform、zoom)换成视口像素;裁剪按 padding box、只裁 overflow 不是 visible 的那个方向。
2449
+ * 绝对 / 固定定位的只受包含块和更外面的管;固定 / 吸顶的那层按现在看到的位置算,它外面的滚动不管、裁剪照算。全被裁掉返回 null。
2450
+ * now:按现在的滚动位置算(只去掉裁掉的部分)。upto:只算到包着它的那层为止(更外面的裁剪不管)。
2451
+ * pos:量的是 el 的伪元素,它自己的定位方式(绝对定位的从 el 起只受包含块和更外面的裁,跳过中间不包它的层)
2452
+ */
2453
+ function restBox(el, now = false, upto = null, self = false, pos = '') {
2454
+ const chain = []
2455
+ let mode = pos === 'absolute' || pos === 'fixed' ? pos : ''
2456
+ let pinned = pos === 'fixed'
2457
+ for (let e = el; e && e !== document.documentElement; e = e.parentElement) {
2458
+ if (upto && e !== el && e.contains(upto)) break
2459
+ const cs = getComputedStyle(e)
2460
+ if (e !== el || self) { // self:量的是 el 里面的字,el 自己的 overflow 也裁它
2461
+ // clip-path 不管包含块,里面的都裁(形状在边框框以内,按边框框算个外沿)
2462
+ const cp = cs.clipPath && cs.clipPath !== 'none' ? e.getBoundingClientRect() : null
2463
+ const cpClip = cp && { l: cp.left, r: cp.right, t: cp.top, b: cp.bottom }
2464
+ if ((mode === 'absolute' && !holds(cs, false)) || (mode === 'fixed' && !holds(cs, true))) {
2465
+ if (cpClip) chain.push({ sx: 0, sy: 0, clip: cpClip })
2466
+ continue
2467
+ }
2468
+ const r = e.getBoundingClientRect()
2469
+ const kx = e.offsetWidth ? r.width / e.offsetWidth : 1
2470
+ const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
2471
+ const box = cs.display !== 'inline' && cs.display !== 'contents' // overflow 对行内元素不起作用(clientWidth 是 0)
2472
+ const cx = box && e !== document.body && cs.overflowX !== 'visible'
2473
+ const cy = box && e !== document.body && cs.overflowY !== 'visible'
2474
+ // overflow: clip 可以往外放出 overflow-clip-margin 那么多(写了 border-box 从边框外沿算、content-box 从内容区算)
2475
+ const ocm = String(cs.overflowClipMargin || '')
2476
+ const len = (/(-?[\d.]+)px/.exec(ocm) || [])[1]
2477
+ const grow = (pad, bd) => (parseFloat(len) || 0) + (/border-box/.test(ocm) ? bd : /content-box/.test(ocm) ? -pad : 0)
2478
+ const mx = cs.overflowX === 'clip' ? grow(parseFloat(cs.paddingLeft) || 0, e.clientLeft) * kx : 0
2479
+ const my = cs.overflowY === 'clip' ? grow(parseFloat(cs.paddingTop) || 0, e.clientTop) * ky : 0
2480
+ let clip = cx || cy ? {
2481
+ l: cx ? r.left + e.clientLeft * kx - mx : -Infinity, r: cx ? r.left + (e.clientLeft + e.clientWidth) * kx + mx : Infinity,
2482
+ t: cy ? r.top + e.clientTop * ky - my : -Infinity, b: cy ? r.top + (e.clientTop + e.clientHeight) * ky + my : Infinity,
2483
+ } : null
2484
+ 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
2485
+ chain.push({
2486
+ sx: pinned || now ? 0 : e.scrollLeft * kx,
2487
+ sy: pinned || now ? 0 : e.scrollTop * ky,
2488
+ clip,
2489
+ })
2490
+ }
2491
+ if (cs.position === 'fixed' || cs.position === 'sticky') pinned = true
2492
+ mode = cs.position
2493
+ }
2494
+ let dx = pinned || now ? 0 : scrollX
2495
+ let dy = pinned || now ? 0 : scrollY
2496
+ let box = { l: -Infinity, r: Infinity, t: -Infinity, b: Infinity }
2497
+ // 从外往里:每层的位置跟着它外面各层滚过的量走,它自己滚过的量只挪它里面的
2498
+ for (let i = chain.length - 1; i >= 0; i--) {
2499
+ const c = chain[i]
2500
+ if (c.clip) box = { l: Math.max(box.l, c.clip.l + dx), r: Math.min(box.r, c.clip.r + dx), t: Math.max(box.t, c.clip.t + dy), b: Math.min(box.b, c.clip.b + dy) }
2501
+ dx += c.sx
2502
+ dy += c.sy
2503
+ }
2504
+ return (r) => {
2505
+ const o = { l: Math.max(box.l, r.l + dx), r: Math.min(box.r, r.r + dx), t: Math.max(box.t, r.t + dy), b: Math.min(box.b, r.b + dy) }
2506
+ return o.r - o.l > 0 && o.b - o.t > 0 ? o : null
2507
+ }
2508
+ }
2509
+
2510
+ /**
2511
+ * 盖在页面上面、页面盖不住的一块(胶囊、灵动岛):可点的东西和字横竖都伸进去 2px 以上就报。
2512
+ * 按「各层都没滚」时露出来的那块量(不固定的滚下去本来就会从下面过);px(o, ow, oh) 算报多少
2513
+ */
2514
+ function zoneCheck(add, atoms, zone, type, px, extra) {
2515
+ const hit = new Set()
2516
+ const into = (o) => {
2517
+ if (!o) return 0
2518
+ const ow = Math.min(o.r, zone.r) - Math.max(o.l, zone.l)
2519
+ const oh = Math.min(o.b, zone.b) - Math.max(o.t, zone.t)
2520
+ return ow > 2 && oh > 2 ? Math.round(px(o, ow, oh)) : 0
2521
+ }
2522
+ const check = (el, rects, self) => {
2523
+ if (hiddenChain(el) || [...hit].some((h) => h.contains(el) || el.contains(h))) return
2524
+ const rest = restBox(el, false, null, self)
2525
+ const n = Math.max(0, ...rects.map((r) => into(rest(r))))
2526
+ if (!n) return
2527
+ hit.add(el)
2528
+ add(type, el, { px: n, ...extra }, 2)
2529
+ }
2530
+ for (const el of document.body.querySelectorAll(CLICKABLE)) {
2531
+ if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
2532
+ const r = el.getBoundingClientRect()
2533
+ if (r.width >= 1 && r.height >= 1) check(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])
2534
+ }
2535
+ for (const a of atoms) check(a.el, a.rects, true) // 字被它自己 overflow 裁掉的那截看不见,不算
2536
+ }
2537
+
2538
+ function capsuleCheck(add, atoms, vw) {
2539
+ const cap = { l: vw - CAP.right - CAP.w, r: vw - CAP.right, t: CAP.top, b: CAP.top + CAP.h }
2540
+ // 报伸进去多宽(从胶囊左边算)
2541
+ zoneCheck(add, atoms, cap, 'capsule', (o, ow) => Math.min(ow, o.r - cap.l), { x: Math.round(cap.l) })
2542
+ }
2543
+
2544
+ /** 页面 meta 说的是不是 Windows 软件(<meta name="dsh-design" content="desktop windows">);不写是 Mac */
2545
+ function deskOs() {
2546
+ const m = document.querySelector('meta[name="dsh-design"]')
2547
+ const words = String(m ? m.getAttribute('content') || '' : '').toLowerCase().split(/[\s,;]+/)
2548
+ return words.some((w) => /^(windows|win|win10|win11)$/.test(w)) ? 'win' : 'mac'
2549
+ }
2550
+
2551
+ /** 桌面软件:Mac 左上角红绿灯(连同四周 4px),Windows 右上角三个按钮,盖在页面上面,伸进去的字和按钮报 winctl */
2552
+ function winCheck(add, atoms, vw) {
2553
+ const os = deskOs()
2554
+ if (os === 'win') {
2555
+ const z = { l: vw - WINBTN.w * 3, r: vw, t: 0, b: WINBTN.h }
2556
+ zoneCheck(add, atoms, z, 'winctl', (o, ow) => Math.min(ow, o.r - z.l), { os })
2557
+ } else {
2558
+ 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 }
2559
+ zoneCheck(add, atoms, z, 'winctl', (o, ow) => Math.min(ow, z.r - o.l), { os })
2560
+ }
2561
+ }
2562
+
2563
+ /**
2564
+ * 伪元素在屏幕上的框(量不到它自己,按算出来的样式推):
2565
+ * - 绝对 / 固定定位:算出来的 left / top / 宽高摆进包含块;
2566
+ * - 普通流:挨着前后的子元素(字、元素)摆,flex 按排列方向和对齐,行内的跟所在那行居中;
2567
+ * 最后套上它自己的 translate / rotate / scale / transform(绕 transform-origin)
2568
+ */
2569
+ const pxv = (v) => (/px$/.test(v) ? parseFloat(v) : NaN)
2570
+ /** 伪元素的边框框宽高(算出来的宽高按 box-sizing 加上内边距和边框);算不出来的是 NaN */
2571
+ function pseudoSize(ps) {
2572
+ 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))
2573
+ return { w: pxv(ps.width) + extra('Left', 'Right'), h: pxv(ps.height) + extra('Top', 'Bottom') }
2574
+ }
2575
+ /** 行内的伪元素没有宽高:按行高(normal 按字号 1.2 倍) */
2576
+ const lineOf = (ps) => { const v = pxv(ps.lineHeight); return isNaN(v) ? (parseFloat(ps.fontSize) || 16) * 1.2 : v }
2577
+ /** 块级的显示方式:上下外边距在块布局里会合并 */
2578
+ const BLOCKISH = /^(block|flex|grid|table|list-item|flow-root)$/
2579
+ /** 替换元素:行内的也有上下外边距 */
2580
+ const REPLACED = /^(IMG|SVG|svg|VIDEO|CANVAS|IFRAME|EMBED|OBJECT|INPUT|SELECT|TEXTAREA|PICTURE)$/
2581
+
2582
+ function pseudoBox(e, which, ps) {
2583
+ const { w } = pseudoSize(ps)
2584
+ let { h } = pseudoSize(ps)
2585
+ let kx = 1, ky = 1, l, t, W
2586
+ if (ps.position === 'absolute' || ps.position === 'fixed') {
2587
+ let cb = null
2588
+ for (let a = e; a && a !== document.documentElement; a = a.parentElement) if (holds(getComputedStyle(a), ps.position === 'fixed')) { cb = a; break }
2589
+ const r = cb ? cb.getBoundingClientRect() : { left: 0, top: 0 }
2590
+ if (cb && cb.offsetWidth) kx = r.width / cb.offsetWidth
2591
+ if (cb && cb.offsetHeight) ky = r.height / cb.offsetHeight
2592
+ const x = pxv(ps.left), y = pxv(ps.top)
2593
+ if ([x, y, w, h].some(isNaN)) return null
2594
+ l = r.left + ((cb ? cb.clientLeft : 0) + x + (parseFloat(ps.marginLeft) || 0)) * kx
2595
+ t = r.top + ((cb ? cb.clientTop : 0) + y + (parseFloat(ps.marginTop) || 0)) * ky
2596
+ W = w
2597
+ } else {
2598
+ const r = e.getBoundingClientRect()
2599
+ if (e.offsetWidth) kx = r.width / e.offsetWidth
2600
+ if (e.offsetHeight) ky = r.height / e.offsetHeight
2601
+ const cs = getComputedStyle(e)
2602
+ const side = (k) => (parseFloat(cs['padding' + k]) || 0) + (parseFloat(cs['border' + k + 'Width']) || 0)
2603
+ 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 }
2604
+ if (isNaN(h)) h = lineOf(ps)
2605
+ const H = h * ky
2606
+ // 普通流里挨着的子元素和字(第一行 / 最后一行的框)
2607
+ // 字量出来的是字形的框,按行高撑成整行(line-height: 40px 这种,行比字高)
2608
+ const lh = pxv(cs.lineHeight) * ky
2609
+ 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 })
2610
+ const near = []
2611
+ for (const n of e.childNodes) {
2612
+ if (n.nodeType === 3 && n.nodeValue.trim()) {
2613
+ const rg = document.createRange()
2614
+ rg.selectNodeContents(n)
2615
+ const rs = rg.getClientRects()
2616
+ 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 }) }
2617
+ } else if (n.nodeType === 1 && !isOurs(n)) {
2618
+ const s2 = getComputedStyle(n)
2619
+ const rs = s2.display !== 'none' && s2.position !== 'absolute' && s2.position !== 'fixed' ? n.getClientRects() : []
2620
+ // top / bottom 是带外边距的边;普通行内元素(span 这种)上下外边距不起作用,框按它的行高撑成整行
2621
+ const inl = s2.display === 'inline' && !REPLACED.test(n.tagName)
2622
+ const mT = inl ? 0 : (parseFloat(s2.marginTop) || 0) * ky, mB = inl ? 0 : (parseFloat(s2.marginBottom) || 0) * ky
2623
+ const ln = (q) => {
2624
+ const v = Math.max(pxv(s2.lineHeight) || 0, pxv(cs.lineHeight) || 0) * ky // 那一行至少有父元素的行高
2625
+ return !inl || !v || v <= q.bottom - q.top ? q : { top: (q.top + q.bottom - v) / 2, bottom: (q.top + q.bottom + v) / 2 }
2626
+ }
2627
+ 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 }) }
2628
+ }
2629
+ }
2630
+ const flex = /flex/.test(cs.display), grid = /grid/.test(cs.display)
2631
+ const rev = flex && /reverse/.test(cs.flexDirection)
2632
+ if (rev && /column/.test(cs.flexDirection)) near.reverse() // 按屏幕上从上到下
2633
+ const before = (which === '::before') !== rev
2634
+ const mt = (parseFloat(ps.marginTop) || 0) * ky, mb = (parseFloat(ps.marginBottom) || 0) * ky
2635
+ if (!flex && !grid && /inline/.test(ps.display)) {
2636
+ const ln = near.length ? (before ? near[0].first : near[near.length - 1].last) : null
2637
+ t = ln ? (ln.top + ln.bottom - H) / 2 : c.t
2638
+ } else if (!flex || /column/.test(cs.flexDirection)) {
2639
+ const gap = flex || grid ? (parseFloat(cs.rowGap) || 0) * ky : 0
2640
+ const j = flex ? cs.justifyContent : ''
2641
+ // 另一个伪元素也在普通流里排着(::before 图标 + 字 + ::after 箭头):一起算剩余空间
2642
+ const os = getComputedStyle(e, which === '::before' ? '::after' : '::before')
2643
+ const other = os.content !== 'none' && os.content !== 'normal' && os.display !== 'none' && os.position !== 'absolute' && os.position !== 'fixed'
2644
+ const osz = pseudoSize(os).h
2645
+ const oh = other ? ((isNaN(osz) ? lineOf(os) : osz) + (parseFloat(os.marginTop) || 0) + (parseFloat(os.marginBottom) || 0)) * ky : 0
2646
+ // 剩下的空按项数分(space-*),放不下(没剩)就不分
2647
+ const n = near.length + 1 + (other ? 1 : 0)
2648
+ const free = c.b - c.t - near.reduce((m, q) => m + q.bottom - q.top, 0) - H - mt - mb - oh - gap * (n - 1)
2649
+ 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
2650
+ // 块布局里两个块级盒相邻,上下外边距合并成一个(正的取大、负的取小再相加);flex / grid、行内块不合并
2651
+ 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))
2652
+ // 排到内容区的尾巴上:flex-start / normal(只有一项或放不下时的 space-between 也按它)在 column-reverse 里也是尾巴,start / end 不跟着反
2653
+ const atEnd = /flex-end/.test(j) ? !rev : /(^|\s)end$/.test(j) ? true : /flex-start|normal|stretch|space-between/.test(j) ? rev : false
2654
+ if (near.length) {
2655
+ // 挨着第一项前面 / 最后一项后面
2656
+ const q = before ? near[0] : near[near.length - 1]
2657
+ t = before ? q.first.top - fold(q.mT, mb, q) - gap - between - H : q.last.bottom + fold(q.mB, mt, q) + gap + between
2658
+ } else if (other && flex) {
2659
+ // 只有前后两个伪元素:从内容区起点按 justify-content 排
2660
+ const lead = /center/.test(j) ? free / 2 : free <= 0 ? (/space-around|space-evenly/.test(j) ? free / 2 : atEnd ? free : 0)
2661
+ : /space-around/.test(j) ? free / 4 : /space-evenly/.test(j) ? free / 3 : /space-between/.test(j) ? 0 : atEnd ? free : 0
2662
+ t = c.t + lead + mt + (before ? 0 : oh + gap + between)
2663
+ } 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
2664
+ } else {
2665
+ const a = ps.alignSelf && ps.alignSelf !== 'auto' ? ps.alignSelf : cs.alignItems
2666
+ t = /center/.test(a) ? (c.t + c.b - H) / 2 : /end/.test(a) ? c.b - H : c.t + mt
2667
+ }
2668
+ l = c.l
2669
+ W = isNaN(w) ? Math.max(1, (c.r - c.l) / kx) : w
2670
+ if (ps.position === 'relative') { l += (pxv(ps.left) || 0) * kx; t += (pxv(ps.top) || 0) * ky }
2671
+ }
2672
+ // 自己的变换:先 translate、rotate、scale,再 transform,都绕 transform-origin
2673
+ let m = new DOMMatrix()
2674
+ const set = (v) => v && v !== 'none'
2675
+ const len = (v, full) => (/%$/.test(v) ? parseFloat(v) / 100 * full : parseFloat(v) || 0)
2676
+ try {
2677
+ if (set(ps.translate)) { const [x, y = '0px'] = ps.translate.split(' '); m = m.translate(len(x, W), len(y, h)) }
2678
+ if (set(ps.rotate) && /^-?[\d.]+deg$/.test(ps.rotate)) m = m.rotate(parseFloat(ps.rotate))
2679
+ if (set(ps.scale)) { const [sx, sy = sx] = ps.scale.split(' ').map(parseFloat); m = m.scale(sx, sy) }
2680
+ if (set(ps.transform)) m = m.multiply(new DOMMatrix(ps.transform))
2681
+ } catch { /* 解析不了的变换就当没有 */ }
2682
+ const [ox, oy] = String(ps.transformOrigin).split(' ').map((v) => parseFloat(v) || 0)
2683
+ m = new DOMMatrix().translate(ox, oy).multiply(m).translate(-ox, -oy)
2684
+ const pts = [[0, 0], [W, 0], [0, h], [W, h]].map(([x, y]) => m.transformPoint(new DOMPoint(x, y)))
2685
+ const xs = pts.map((p) => l + p.x * kx), ys = pts.map((p) => t + p.y * ky)
2686
+ return { l: Math.min(...xs), t: Math.min(...ys), r: Math.max(...xs), b: Math.max(...ys) }
2687
+ }
2688
+
2689
+ /** 一个元素里的图标(img、最外层的 svg):不看框,只看画出来的东西 */
2690
+ function iconRects(root, pseudo = false) {
2691
+ const out = []
2692
+ for (const e of root.querySelectorAll('img,svg')) {
2693
+ if ((e.parentElement && e.parentElement.closest('svg')) || isOurs(e) || e.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
2694
+ const r = e.getBoundingClientRect()
2695
+ 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 } })
2696
+ }
2697
+ if (!pseudo) return out
2698
+ // 图标字体、背景图标:::before / ::after 里放了字或 url() 图(空白、渐变遮罩这种装饰不算)。量不到伪元素的框,按它算出来的位置和大小推
2699
+ for (const e of [root, ...root.querySelectorAll('*')]) {
2700
+ if (isOurs(e) || e.closest('svg,dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
2701
+ for (const which of ['::before', '::after']) {
2702
+ const ps = getComputedStyle(e, which)
2703
+ if (ps.display === 'none' || ps.content === 'none' || ps.content === 'normal' || ps.visibility === 'hidden' || parseFloat(ps.opacity) === 0) continue
2704
+ 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)
2705
+ if (!glyph || hiddenChain(e)) continue
2706
+ // 无头体检时浏览器给了真实框(同一滚动位置)就用它;全问到了还没框的是没排出来;没给的(实时预览、宿主太多没问到)按样式推
2707
+ const at = scrollX + ',' + scrollY
2708
+ const fresh = window.__dshPseudoAt === at
2709
+ const got = fresh && e.__dshPseudo && e.__dshPseudo.at === at ? e.__dshPseudo : null
2710
+ const o = got ? got[which] || null : fresh && window.__dshPseudoAll ? null : pseudoBox(e, which, ps)
2711
+ // 伪元素是宿主的子盒:宿主自己的 overflow 也裁它;绝对 / 固定定位的按它自己的包含块裁(pos 交给 restBox)
2712
+ if (o && o.r - o.l >= 1 && o.b - o.t >= 1) out.push({ el: e, r: o, pos: ps.position })
2713
+ }
2714
+ }
2715
+ return out
2716
+ }
2717
+
2718
+ /**
2719
+ * 底栏里横排、等宽、铺满大半条的一排(标签栏的几项):找最多的那排,返回这几项。
2720
+ * 绝对 / 固定定位的子元素(当前项下面的小横线这种)不算项;能横着滑、或者伸出栏外一大截的一排(轮播、商品列表)不算
2721
+ */
2722
+ const slides = (el, top) => {
2723
+ for (let e = el; e; e = e === top ? null : e.parentElement) {
2724
+ if (/auto|scroll/.test(getComputedStyle(e).overflowX) && e.scrollWidth > e.clientWidth + 1) return true
2725
+ }
2726
+ return false
2727
+ }
2728
+ function tabCount(bar) {
2729
+ let best = []
2730
+ const walk = (el, depth) => {
2731
+ const ks = []
2732
+ for (const k of el.children) {
2733
+ if (isOurs(k)) continue
2734
+ const cs = getComputedStyle(k)
2735
+ if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') continue
2736
+ const r = k.getBoundingClientRect()
2737
+ if (r.width >= 1 && r.height >= 1) ks.push({ k, r })
2738
+ }
2739
+ if (ks.length > best.length) {
2740
+ const ws = ks.map((x) => x.r.width)
2741
+ const avg = ws.reduce((m, w) => m + w, 0) / ws.length
2742
+ const same = Math.max(...ws) - Math.min(...ws) <= Math.max(4, avg * 0.12)
2743
+ const row = Math.max(...ks.map((x) => x.r.top)) - Math.min(...ks.map((x) => x.r.top)) <= 4
2744
+ 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)
2745
+ if (ks.length >= 2 && same && row && fits && avg * ks.length >= bar.r.width * 0.7) best = ks.map((x) => x.k)
2746
+ }
2747
+ if (depth < 4) for (const k of el.children) if (!isOurs(k)) walk(k, depth + 1)
2748
+ }
2749
+ walk(bar.el, 0)
2750
+ return best
2751
+ }
2752
+
2753
+ /**
2754
+ * 这排是切换页面的标签栏(不是一排操作按钮),按这个次序定:
2755
+ * - 这一排到栏之间(以及栏外面)写明了是工具栏(role=toolbar、class 带 toolbar / tools):不是;
2756
+ * - 写明了是导航(role=tab / tablist、项链到别的页面(#/home 这种路由也算,光一个 # 不算)、标了 aria-current、栏 / 这一排 / 项的 class 或 id 带 tab、nav):是;
2757
+ * - 一半以上是操作(裁剪、旋转、分享……):不是;一半以上是页面名(首页、分类、我的……):是;
2758
+ * - 全是 <button>:不是;
2759
+ * - 在 <nav> 里、项是占位链接(href="#")、项标了选中(active、on……)、只有一项颜色不同(字或图标):是
2760
+ * 认字时只看文字(字符图标 ✂、↻ 这种去掉)
2761
+ */
2762
+ const ON = /(^|[-_\s])(on|active|current|cur|selected)($|[-_\s])/i
2763
+ 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(' '))
2764
+ 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(' '))
2765
+ const classOf = (el) => (el.getAttribute('class') || '') + ' ' + el.id
2766
+ const labelOf = (k) => String(k.innerText || k.textContent || '').toLowerCase().replace(/[^\p{L}]+/gu, '')
2767
+ function navLike(bar, items) {
2768
+ const row = items[0].parentElement
2769
+ const boxes = [] // 这一排一直到栏(再往外由 closest 看)
2770
+ for (let e = row; e; e = e === bar.el ? null : e.parentElement) boxes.push(e)
2771
+ if (boxes.some((e) => e.matches('[role=toolbar]') || /(^|[-_\s])tool(bar|s)?($|[-_\s])/i.test(classOf(e))) || bar.el.closest('[role=toolbar]')) return false
2772
+ const page = (k) => k.matches('a[href]') && !/^(#?|javascript:.*)$/i.test(k.getAttribute('href').trim())
2773
+ 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
2774
+ if ([...boxes, ...items].some((e) => /tab(?!le)|(^|[-_\s])nav/i.test(classOf(e)))) return true
2775
+ const labels = items.map(labelOf)
2776
+ if (labels.filter((x) => TOOL_WORDS.has(x)).length * 2 >= items.length) return false
2777
+ if (labels.filter((x) => PAGE_WORDS.has(x)).length * 2 >= items.length) return true
2778
+ if (items.every((k) => k.matches('button,[role=button]'))) return false
2779
+ if (bar.el.closest('nav,[role=navigation]') || bar.el.querySelector('nav,[role=navigation]') || items.some((k) => k.matches('a[href]'))) return true
2780
+ if (items.some((k) => k.matches('[aria-selected=true]') || ON.test(classOf(k)) || [...k.querySelectorAll('*')].some((e) => ON.test(classOf(e))))) return true
2781
+ // 只有一项颜色不同:字的颜色,或者图标(svg 的 color / fill、图标字体的颜色)
2782
+ const tint = (k) => {
2783
+ const t = [k, ...k.querySelectorAll('*')].find((e) => ownText(e)) || k
2784
+ const ic = k.querySelector('svg,i,[class*=icon]')
2785
+ const ics = ic ? getComputedStyle(ic) : null
2786
+ return getComputedStyle(t).color + '|' + (ics ? ics.color + ics.fill : '')
2787
+ }
2788
+ const tints = items.map(tint)
2789
+ const odd = tints.filter((c) => tints.filter((x) => x === c).length === 1)
2790
+ return odd.length === 1 && new Set(tints).size === 2
2791
+ }
2792
+
2793
+ /**
2794
+ * 手机(App、小程序):
2795
+ * - 灵动岛挡住的字和按钮(外框画在页面上面,挡住的看不见);
2796
+ * - 底栏里的字、图标伸进底部横条区(让出 34px,栏的底色可以铺下去);
2797
+ * - 标签栏超过 5 项(小程序最多 5 项,微信直接报错);
2798
+ * - 小程序:导航栏那条(44–88)什么都没有。
2799
+ * 矮的手机(高不到宽的 1.9 倍)没有灵动岛、横条,只看后两条
2800
+ */
2801
+ function phoneCheck(add, atoms, vw, kind) {
2802
+ const vh = innerHeight
2803
+ const tall = vh / vw >= 1.9
2804
+ if (tall) {
2805
+ const isl = { l: vw / 2 - ISLAND.w / 2, r: vw / 2 + ISLAND.w / 2, t: ISLAND.top, b: ISLAND.top + ISLAND.h }
2806
+ zoneCheck(add, atoms, isl, 'island', (o, ow, oh) => oh, {})
2807
+ }
2808
+ const line = vh - HOMEBAR
2809
+ for (const bar of barsAt('bottom', true)) {
2810
+ if (tall) {
2811
+ let worst = null
2812
+ // 只量看得见的那截:被自己(省略号、多行截断)或外层(轮播)裁掉的、在屏幕外的不算
2813
+ const look = (el, r, self, pos) => {
2814
+ const o = restBox(el, true, null, self, pos)(r)
2815
+ if (!o || o.r <= 0 || o.l >= vw || o.t >= vh || o.b <= 0) return
2816
+ const d = Math.min(o.b, vh) - line
2817
+ if (d > 6 && (!worst || d > worst.d)) worst = { el, d }
2818
+ }
2819
+ 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)
2820
+ for (const ic of iconRects(bar.el, true)) look(ic.el, ic.r, !!ic.pos, ic.pos)
2821
+ // 伸进几像素看不出来,不报;伸进 12px 以上算严重
2822
+ if (worst) add('homebar', worst.el, { px: Math.round(worst.d), bar: nameOf(bar.el) }, worst.d >= 12 ? 2 : 1)
2823
+ }
2824
+ const items = tabCount(bar)
2825
+ if (items.length > 5 && navLike(bar, items)) add('tabbar', bar.el, { n: items.length }, kind === 'miniprogram' ? 2 : 1)
2826
+ }
2827
+ if (kind !== 'miniprogram') return
2828
+ // 导航栏那条:没滚时有字、图标或按钮的竖直中线落在里面就算有
2829
+ const band = { t: NAVBAR.top, b: NAVBAR.top + NAVBAR.h }
2830
+ const inBand = (o) => o && (o.t + o.b) / 2 >= band.t && (o.t + o.b) / 2 <= band.b
2831
+ const seen = (el, rects, self) => {
2832
+ if (hiddenChain(el)) return false
2833
+ const rest = restBox(el, false, null, self)
2834
+ return rects.some((r) => inBand(rest(r)))
2835
+ }
2836
+ for (const a of atoms) if (seen(a.el, a.rects, true)) return
2837
+ for (const el of document.body.querySelectorAll(CLICKABLE)) {
2838
+ if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
2839
+ const r = el.getBoundingClientRect()
2840
+ if (r.width >= 1 && r.height >= 1 && seen(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])) return
2841
+ }
2842
+ for (const ic of iconRects(document.body)) if (seen(ic.el, [ic.r])) return
2843
+ add('navbar', null, { t: band.t, b: band.b }, 1)
2844
+ }
2845
+
2846
+ /**
2847
+ * 最底下的画布:html(没有就 body)的底色铺满整个窗口。html 有底色或背景图时画布用 html 的、body 的背景照常算;
2848
+ * 都没有时 body 的底色传到画布上。onCanvas(e):它自己的底色就是画布,叠的时候别再算一遍。explicit:页面自己写了底色
2849
+ */
2850
+ function canvasBase() {
2851
+ if (probe && probe.canvas) return probe.canvas
2852
+ const de = document.documentElement
2853
+ const dcs = getComputedStyle(de)
2854
+ const htmlBg = rgba(dcs.backgroundColor)
2855
+ const htmlHas = (!!htmlBg && htmlBg[3] > 0) || dcs.backgroundImage !== 'none'
2856
+ const bodyBg = document.body ? rgba(getComputedStyle(document.body).backgroundColor) : null
2857
+ const blank = schemeDark() ? [18, 18, 18, 1] : [255, 255, 255, 1]
2858
+ const can = htmlHas ? htmlBg : bodyBg
2859
+ const explicit = !!can && can[3] > 0
2860
+ const out = { base: explicit ? (can[3] < 1 ? over(can, blank) : can) : blank, explicit, onCanvas: (e) => e === de || (e === document.body && !htmlHas) }
2861
+ if (probe) probe.canvas = out
2862
+ return out
2863
+ }
2864
+
2865
+ /**
2866
+ * 从下往上叠 [元素, 颜色] 这些层,透明度和浏览器一样按组算:opacity < 1 的元素连同它里面的先合成(从全透明起),再整体变淡叠下去。
2867
+ * 元素的祖先不在层里(子元素伸出了它的框)也照算。base 是最底下的颜色(不透明),返回叠完的 [r, g, b, 1]
2868
+ */
2869
+ function compose(layers, base) {
2870
+ const pre = (c) => [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]]
2871
+ const ov = (t, u) => [t[0] + u[0] * (1 - t[3]), t[1] + u[1] * (1 - t[3]), t[2] + u[2] * (1 - t[3]), t[3] + u[3] * (1 - t[3])]
2872
+ const stack = [{ owner: null, o: 1, c: [0, 0, 0, 0] }]
2873
+ const close = () => { const g = stack.pop(); const up = stack[stack.length - 1]; up.c = ov(g.c.map((v) => v * g.o), up.c) }
2874
+ const memo = new Map()
2875
+ const groupsOf = (e) => {
2876
+ if (memo.has(e)) return memo.get(e)
2877
+ const a = []
2878
+ for (let p = e; p && p.nodeType === 1; p = p.parentElement) { const o = +getComputedStyle(p).opacity; if (o < 1) a.unshift([p, o]) }
2879
+ memo.set(e, a)
2880
+ return a
2881
+ }
2882
+ for (const [e, col] of layers) {
2883
+ const want = groupsOf(e)
2884
+ let k = 0
2885
+ while (k < want.length && k + 1 < stack.length && stack[k + 1].owner === want[k][0]) k++
2886
+ while (stack.length > k + 1) close()
2887
+ for (let i = k; i < want.length; i++) stack.push({ owner: want[i][0], o: want[i][1], c: [0, 0, 0, 0] })
2888
+ if (col && col[3] > 0) { const g = stack[stack.length - 1]; g.c = ov(pre(col), g.c) }
2889
+ }
2890
+ while (stack.length > 1) close()
2891
+ const c = ov(stack[0].c, base)
2892
+ return [c[0], c[1], c[2], 1]
2893
+ }
2894
+
2895
+ /**
2896
+ * 顶栏右边(胶囊那块)是不是深色:是的话外框画白色胶囊(和微信 navigationBarTextStyle: white 一样)。
2897
+ * 页面写了 <meta name="dsh-capsule" content="white|black"> 就照它;不然从下往上叠胶囊中心那点的各层:
2898
+ * 底色、渐变(取各色标的平均)、图片 / 视频 / canvas(按 object-fit / object-position 画到小画布上取那块的平均,跨域取不到就跳过)。
2899
+ * 透明度和浏览器一样按组算:opacity < 1 的元素连同里面的先合成,再整体变淡。装饰 SVG 跳过。
2900
+ * 导出带外框的图也用这个结果(截图复制页面那一刻量的),两边一致
2901
+ */
2902
+ function topDark() {
2903
+ try {
2904
+ const m = document.querySelector('meta[name="dsh-capsule"]')
2905
+ const say = m ? String(m.getAttribute('content') || '').trim().toLowerCase() : ''
2906
+ if (say === 'white' || say === 'black') return say === 'white'
2907
+ // Windows 桌面软件看右上角三个按钮那块,别的看胶囊那块
2908
+ const win = deskOs() === 'win'
2909
+ const x = win ? innerWidth - WINBTN.w * 1.5 : innerWidth - CAP.right - CAP.w / 2
2910
+ const y = win ? WINBTN.h / 2 : CAP.top + CAP.h / 2
2911
+ const hits = paintedAt(x, y).filter((e) => !isOurs(e) && !(e instanceof SVGElement)).reverse() // 从下往上
2912
+ const { base, onCanvas } = canvasBase()
2913
+ const layers = []
2914
+ for (const e of hits) {
2915
+ const cs = getComputedStyle(e)
2916
+ for (const l of [onCanvas(e) ? null : rgba(cs.backgroundColor), gradTone(cs.backgroundImage), MEDIA.has(e.tagName) ? mediaTone(e, cs) : null]) if (l) layers.push([e, l])
2917
+ }
2918
+ const c = compose(layers, base)
2919
+ return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] < 140
2920
+ } catch { return false }
2921
+ }
2922
+ /**
2923
+ * 量「这一点上画着什么」的那段时间:临时加一张样式表(放在层里,盖得过页面没分层的 !important)把 pointer-events 全设成 auto,
2924
+ * 命中测试就不会跳过不可点的背景层;量完撤掉——不改页面的 DOM,页面的 MutationObserver 不会知道。
2925
+ * 还盖不过的(行内 !important、页面更早的层里写的)先列出来,paintedAt 按位置补。fn 得是同步的;嵌套调用共用一次
2926
+ */
2927
+ let probe = null
2928
+ function withProbe(fn) {
2929
+ if (probe) return fn()
2930
+ const was = [...document.adoptedStyleSheets]
2931
+ let sheet = null
2932
+ try {
2933
+ sheet = new CSSStyleSheet()
2934
+ sheet.replaceSync('@layer dsh-design-probe{*{pointer-events:auto!important}}')
2935
+ document.adoptedStyleSheets = [...was, sheet]
2936
+ } catch { sheet = null }
2937
+ try {
2938
+ const extra = []
2939
+ for (const e of [document.documentElement, document.body, ...(document.body ? document.body.querySelectorAll('*') : [])]) {
2940
+ if (!e || isOurs(e) || e instanceof SVGElement) continue
2941
+ const cs = getComputedStyle(e)
2942
+ if (cs.pointerEvents === 'none' && cs.visibility === 'visible') extra.push(e)
2943
+ }
2944
+ probe = { extra, layers: new Map(), paths: new Map(), pairs: new Map(), canvas: null } // 后几个是量的这段时间里的缓存(页面不会变)
2945
+ return fn()
2946
+ } finally {
2947
+ probe = null
2948
+ if (sheet) document.adoptedStyleSheets = was
2949
+ }
2950
+ }
2951
+ /** 这一点上画着的元素(从上往下):不可点的背景层也算;命中测试漏掉的按位置找出来、估个上下顺序插进去(被外层裁掉的不算) */
2952
+ function paintedAt(x, y) {
2953
+ return withProbe(() => {
2954
+ const list = document.elementsFromPoint(x, y)
2955
+ for (const e of probe.extra) {
2956
+ if (list.includes(e)) continue
2957
+ const r = e.getBoundingClientRect()
2958
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
2959
+ const vis = restBox(e, true)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
2960
+ if (!vis || x < vis.l || x > vis.r || y < vis.t || y > vis.b) continue
2961
+ const i = list.findIndex((h) => paintsOver(e, h))
2962
+ list.splice(i < 0 ? list.length : i, 0, e)
2963
+ }
2964
+ return list
2965
+ })
2966
+ }
2967
+ /** 会建层叠上下文的元素:z-index 只在它里面比,里面的东西整块跟着它排 */
2968
+ function stackRoot(e, cs) {
2969
+ if (e === document.documentElement) return true
2970
+ const zi = cs.zIndex !== 'auto'
2971
+ const p = e.parentElement
2972
+ return (zi && (cs.position !== 'static' || (!!p && /flex|grid/.test(getComputedStyle(p).display)))) || cs.position === 'fixed' || cs.position === 'sticky' ||
2973
+ +cs.opacity < 1 || moved(cs) || cs.filter !== 'none' || cs.perspective !== 'none' || cs.isolation === 'isolate' || cs.mixBlendMode !== 'normal' ||
2974
+ (!!cs.clipPath && cs.clipPath !== 'none') || (!!cs.maskImage && cs.maskImage !== 'none') || (!!cs.backdropFilter && cs.backdropFilter !== 'none') ||
2975
+ /paint|layout|strict|content/.test(cs.contain) || /transform|scale|translate|rotate|opacity|filter|perspective|z-index/.test(cs.willChange)
2976
+ }
2977
+ /** 能给绝对定位(fixed:给固定定位)当包含块的:定位了的,或 transform / perspective / filter / contain / will-change 这些 */
2978
+ function holds(cs, fixed) {
2979
+ return (!fixed && cs.position !== 'static') || moved(cs) || cs.perspective !== 'none' || cs.filter !== 'none' ||
2980
+ (!!cs.backdropFilter && cs.backdropFilter !== 'none') || /paint|layout|strict|content/.test(cs.contain) ||
2981
+ /transform|scale|translate|rotate|perspective|filter|contain/.test(cs.willChange)
2982
+ }
2983
+ /** 变换过的:transform,或单独写的 scale / translate / rotate */
2984
+ function moved(cs) {
2985
+ return cs.transform !== 'none' || (!!cs.scale && cs.scale !== 'none') || (!!cs.translate && cs.translate !== 'none') || (!!cs.rotate && cs.rotate !== 'none')
2986
+ }
2987
+ /**
2988
+ * a 画在 b 上面吗(命中测试漏掉的那几个用来估位置):祖先在下、后代在上(负 z-index 跑到不建上下文的祖先底下的除外);不相干的找两者共同的层叠上下文,
2989
+ * 各取在它里面代表自己的那个(往下第一个自己建上下文的,没有就是路上离自己最近的定位了的),比层级(负 z < 普通流 < 定位 / z 0 < 正 z),一样按文档顺序
2990
+ */
2991
+ function paintsOver(a, b) {
2992
+ const memo = probe && probe.pairs
2993
+ let row = memo && memo.get(a)
2994
+ if (row && row.has(b)) return row.get(b)
2995
+ const v = paintsOverNow(a, b)
2996
+ if (memo) { if (!row) memo.set(a, (row = new Map())); row.set(b, v) }
2997
+ return v
2998
+ }
2999
+ /** 叠放用到的几样(建不建层叠上下文、z-index、定位),量的这段时间里记着 */
3000
+ function layerOf(e) {
3001
+ const m = probe && probe.layers
3002
+ let v = m && m.get(e)
3003
+ if (v) return v
3004
+ const cs = getComputedStyle(e)
3005
+ v = { root: stackRoot(e, cs), z: parseInt(cs.zIndex, 10), pos: cs.position }
3006
+ if (m) m.set(e, v)
3007
+ return v
3008
+ }
3009
+ /** 从 html 到它的那串祖先(含自己),量的这段时间里记着 */
3010
+ function pathOf(e) {
3011
+ const m = probe && probe.paths
3012
+ let v = m && m.get(e)
3013
+ if (v) return v
3014
+ v = e.parentElement ? [...pathOf(e.parentElement), e] : [e]
3015
+ if (m) m.set(e, v)
3016
+ return v
3017
+ }
3018
+ function paintsOverNow(a, b) {
3019
+ // 后代 d 画在祖先 anc 底下:anc 自己不建层叠上下文,而 d 所在的、紧挨着 anc 的那个上下文是负 z-index 的(整块画到外面那层的最底下)
3020
+ const under = (anc, d) => {
3021
+ if (layerOf(anc).root) return false
3022
+ let top = null
3023
+ for (let x = d; x && x !== anc; x = x.parentElement) if (layerOf(x).root) top = x
3024
+ return !!top && layerOf(top).z < 0
3025
+ }
3026
+ if (a.contains(b)) return under(a, b)
3027
+ if (b.contains(a)) return !under(b, a)
3028
+ const pa = pathOf(a), pb = pathOf(b)
3029
+ let common = 0
3030
+ for (let i = 0; i < pa.length && pa[i] === pb[i]; i++) if (layerOf(pa[i]).root) common = i
3031
+ const rep = (path) => {
3032
+ let pos = null
3033
+ for (let k = common + 1; k < path.length; k++) {
3034
+ const v = layerOf(path[k])
3035
+ if (v.root) return { el: path[k], lv: Number.isFinite(v.z) && v.z !== 0 ? (v.z < 0 ? v.z - 1 : v.z + 1) : 1 }
3036
+ if (v.pos !== 'static') pos = path[k] // 定位了没建上下文的:各自按文档顺序排,取离自己最近的那个
3037
+ }
3038
+ return pos ? { el: pos, lv: 1 } : { el: path[path.length - 1], lv: 0 }
3039
+ }
3040
+ const ra = rep(pa), rb = rep(pb)
3041
+ if (ra.lv !== rb.lv) return ra.lv > rb.lv
3042
+ const [x, y] = ra.el === rb.el ? [a, b] : [ra.el, rb.el]
3043
+ return !!(y.compareDocumentPosition(x) & Node.DOCUMENT_POSITION_FOLLOWING)
3044
+ }
3045
+ /** 渐变 → 各色标的平均(rgb() 以外的写法也认,见 rgba);没有渐变 / 只有图片地址 → null */
3046
+ function gradTone(bi) {
3047
+ if (!bi || bi === 'none' || !/gradient\(/.test(bi)) return null
3048
+ const cs = (bi.match(/(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)/g) || []).map(rgba).filter(Boolean)
3049
+ if (!cs.length) return null
3050
+ const a = cs.reduce((s, c) => s + c[3], 0) / cs.length
3051
+ if (a <= 0) return [0, 0, 0, 0]
3052
+ const ch = (k) => cs.reduce((s, c) => s + c[k] * c[3], 0) / (a * cs.length)
3053
+ return [ch(0), ch(1), ch(2), a]
3054
+ }
3055
+ /** 图片 / 视频 / canvas 在胶囊那块的平均色:按内容框、object-fit、object-position 摆,裁在内容框里;没载完、跨域取不到 → null */
3056
+ function mediaTone(e, cs) {
3057
+ try {
3058
+ if (!/^(IMG|VIDEO|CANVAS)$/.test(e.tagName)) return null
3059
+ const nw = e.naturalWidth || e.videoWidth || e.width
3060
+ const nh = e.naturalHeight || e.videoHeight || e.height
3061
+ if (!nw || !nh) return null
3062
+ const r = e.getBoundingClientRect()
3063
+ const k = e.offsetWidth ? r.width / e.offsetWidth : 1
3064
+ const n = (v) => parseFloat(v) || 0
3065
+ const cl = r.left + (n(cs.borderLeftWidth) + n(cs.paddingLeft)) * k
3066
+ const ct = r.top + (n(cs.borderTopWidth) + n(cs.paddingTop)) * k
3067
+ const cw = r.right - (n(cs.borderRightWidth) + n(cs.paddingRight)) * k - cl
3068
+ const ch = r.bottom - (n(cs.borderBottomWidth) + n(cs.paddingBottom)) * k - ct
3069
+ if (cw <= 0 || ch <= 0) return null
3070
+ const fit = cs.objectFit || 'fill'
3071
+ let w = cw, h = ch
3072
+ if (fit !== 'fill') {
3073
+ let z = fit === 'cover' ? Math.max(cw / nw, ch / nh) : fit === 'none' ? k : Math.min(cw / nw, ch / nh)
3074
+ if (fit === 'scale-down') z = Math.min(k, z)
3075
+ w = nw * z
3076
+ h = nh * z
3077
+ }
3078
+ // object-position 算好的值是「横 竖」两项,每项是百分比、像素或 calc(百分比 ± 像素)(right 0 bottom 20px → 100% calc(100% - 20px));认不出的按居中
3079
+ const toks = []
3080
+ let depth = 0, cur = ''
3081
+ for (const ch of String(cs.objectPosition || '')) {
3082
+ if (ch === '(') depth++
3083
+ else if (ch === ')') depth--
3084
+ if (/\s/.test(ch) && !depth) { if (cur) toks.push(cur); cur = '' } else cur += ch
3085
+ }
3086
+ if (cur) toks.push(cur)
3087
+ const [px, py] = toks.length === 2 ? toks : []
3088
+ const WORD = { left: 0, top: 0, center: 50, right: 100, bottom: 100 }
3089
+ const at = (v, free) => {
3090
+ if (!v) return free / 2
3091
+ if (v in WORD) return (free * WORD[v]) / 100
3092
+ let pct = 0, pix = 0, ok = false
3093
+ for (const m of v.matchAll(/([+-])?\s*(\d*\.?\d+)(%|px)/g)) { const n = (m[1] === '-' ? -1 : 1) * parseFloat(m[2]); if (m[3] === '%') pct += n; else pix += n; ok = true }
3094
+ const r = (free * pct) / 100 + pix * k
3095
+ return ok && Number.isFinite(r) ? r : free / 2
3096
+ }
3097
+ const ox = cl + at(px, cw - w)
3098
+ const oy = ct + at(py, ch - h)
3099
+ const c = document.createElement('canvas')
3100
+ c.width = 22
3101
+ c.height = 8
3102
+ const g = c.getContext('2d')
3103
+ const capL = innerWidth - CAP.right - CAP.w
3104
+ const sx = c.width / CAP.w
3105
+ const sy = c.height / CAP.h
3106
+ g.beginPath()
3107
+ g.rect((cl - capL) * sx, (ct - CAP.top) * sy, cw * sx, ch * sy)
3108
+ g.clip()
3109
+ g.drawImage(e, (ox - capL) * sx, (oy - CAP.top) * sy, w * sx, h * sy)
3110
+ const d = g.getImageData(0, 0, c.width, c.height).data
3111
+ let R = 0, G = 0, B = 0, A = 0
3112
+ for (let i = 0; i < d.length; i += 4) { const a = d[i + 3] / 255; R += d[i] * a; G += d[i + 1] * a; B += d[i + 2] * a; A += a }
3113
+ return A > 0 ? [R / A, G / A, B / A, A / (d.length / 4)] : [0, 0, 0, 0]
3114
+ } catch { return null }
3115
+ }
3116
+ let lastTop = null
3117
+ const postTop = () => { const dk = topDark(); if (dk !== lastTop) { lastTop = dk; post({ type: 'top', dark: dk }) } }
3118
+ // 顶栏的颜色会变:切主题 / 调色的过渡放完、滚动后顶栏变实色、点了换状态、窗口换尺寸(媒体查询)、图片换了——过一下再量。
3119
+ // 节流:一直滚也每 120ms 量一次;这段时间里又有动静就再补量一次(页面在 rAF 里换样式的也赶得上)
3120
+ let topTimer = 0
3121
+ let topAgain = false
3122
+ const queueTop = () => {
3123
+ if (topTimer) { topAgain = true; return }
3124
+ const run = () => { postTop(); if (topAgain) { topAgain = false; topTimer = setTimeout(run, 120) } else topTimer = 0 }
3125
+ topTimer = setTimeout(run, 120)
3126
+ }
3127
+ // 图片的 load 传不到 window(load 事件的路径到 document 为止),所以挂在 document 上;resize 只在 window 上
3128
+ for (const ev of ['transitionend', 'animationend', 'scroll', 'click', 'load', 'error', 'loadeddata']) document.addEventListener(ev, queueTop, { capture: true, passive: true })
3129
+ addEventListener('resize', queueTop, { passive: true })
3130
+ try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', queueTop) } catch { /* 老浏览器 */ } // 系统换了深浅色
3131
+
3132
+ /** 无头浏览器截图用:右上角画上微信的胶囊(真机上它盖在页面上面)。ghost 半透明,透出下面被挡住的东西;别的值撤掉 */
3133
+ let capEl = null
3134
+ function showCapsule(how) {
3135
+ if (capEl) { capEl.remove(); capEl = null }
3136
+ if (how !== 'solid' && how !== 'ghost') return null
3137
+ const white = topDark()
3138
+ const fg = white ? '#fff' : '#000'
3139
+ const soft = white ? 'rgba(255,255,255,.25)' : 'rgba(0,0,0,.15)'
3140
+ const h = document.createElement('dsh-design-fx')
3141
+ h.style.cssText = `position:fixed;top:${CAP.top}px;right:${CAP.right}px;width:${CAP.w}px;height:${CAP.h}px;z-index:2147483646;pointer-events:none;opacity:${how === 'ghost' ? 0.5 : 1}`
3142
+ h.attachShadow({ mode: 'closed' }).innerHTML = `<svg width="${CAP.w}" height="${CAP.h}" viewBox="0 0 ${CAP.w} ${CAP.h}" style="display:block;overflow:visible">` +
3143
+ `<rect x=".25" y=".25" width="${CAP.w - 0.5}" height="${CAP.h - 0.5}" rx="${CAP.h / 2 - 0.25}" fill="${white ? 'rgba(0,0,0,.15)' : 'rgba(255,255,255,.6)'}" stroke="${white ? 'rgba(255,255,255,.25)' : 'rgba(0,0,0,.08)'}" stroke-width=".5"/>` +
3144
+ `<g fill="${fg}"><circle cx="20.5" cy="16" r="2.2"/><circle cx="28" cy="16" r="3.1"/><circle cx="35.5" cy="16" r="2.2"/><circle cx="65" cy="16" r="2.6"/></g>` +
3145
+ `<path d="M43.5 9v14" stroke="${soft}" stroke-width=".5"/><circle cx="65" cy="16" r="7.5" fill="none" stroke="${fg}" stroke-width="2"/></svg>`
3146
+ document.documentElement.appendChild(h)
3147
+ capEl = h
3148
+ return { white }
3149
+ }
3150
+
3151
+ /** 截图时把桌面软件的窗口按钮画上去(和右侧栏外框一样):solid 实的、ghost 半透明,别的值撤掉 */
3152
+ let winEl = null
3153
+ function showWinCtl(how) {
3154
+ if (winEl) { winEl.remove(); winEl = null }
3155
+ if (how !== 'solid' && how !== 'ghost') return null
3156
+ const os = deskOs()
3157
+ const h = document.createElement('dsh-design-fx')
3158
+ let svg
3159
+ if (os === 'win') {
3160
+ const W = WINBTN.w, H = WINBTN.h
3161
+ const c = topDark() ? '#fff' : '#1f1f1f'
3162
+ h.style.cssText = `position:fixed;top:0;right:0;width:${W * 3}px;height:${H}px`
3163
+ svg = `<svg width="${W * 3}" height="${H}" viewBox="0 0 ${W * 3} ${H}" style="display:block" fill="none" stroke="${c}" stroke-width="1">` +
3164
+ `<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"/>` +
3165
+ `<path d="M${W * 2.5 - 5} ${H / 2 - 5}l10 10M${W * 2.5 + 5} ${H / 2 - 5}l-10 10"/></svg>`
3166
+ } else {
3167
+ const { x, y, d, gap } = LIGHTS
3168
+ const w = d * 3 + gap * 2
3169
+ h.style.cssText = `position:fixed;top:${y}px;left:${x}px;width:${w}px;height:${d}px`
3170
+ svg = `<svg width="${w}" height="${d}" viewBox="0 0 ${w} ${d}" style="display:block">` +
3171
+ ['#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>'
3172
+ }
3173
+ h.style.cssText += `;z-index:2147483646;pointer-events:none;opacity:${how === 'ghost' ? 0.5 : 1}`
3174
+ h.attachShadow({ mode: 'closed' }).innerHTML = svg
3175
+ document.documentElement.appendChild(h)
3176
+ winEl = h
3177
+ return { os }
3178
+ }
3179
+
2266
3180
  /** 量之前等字体、图片载完(最多等 2 秒):刚载入的页面字体没到时字宽不对,断行、截断都会量错 */
2267
3181
  async function settle() {
2268
3182
  const wait = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))])
@@ -2444,13 +3358,13 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2444
3358
  else if (d.type === 'check') onCheck(d)
2445
3359
  else if (d.type === 'checkClear') { clearMarks(); lastIssues = [] }
2446
3360
  else if (d.type === 'checkFocus') focusIssue(Number(d.i))
2447
- else if (d.type === 'theme') setDark(d.dark)
3361
+ else if (d.type === 'theme') { setDark(d.dark); postTop(); setTimeout(postTop, 400) }
2448
3362
  else if (d.type === 'demo') demo = !!d.on
2449
3363
  else if (d.type === 'edit') setTextEdit(!!d.on, d.hint !== false)
2450
3364
  else if (d.type === 'styleMode') setStyleMode(!!d.on)
2451
3365
  else if (d.type === 'styleSel') onStyleSel(d.selector)
2452
- else if (d.type === 'style') styleApply(d.css)
2453
- else if (d.type === 'tweak') applyTweak(d.vars, d.attrs)
3366
+ else if (d.type === 'style') { styleApply(d.css); queueTop() }
3367
+ else if (d.type === 'tweak') { applyTweak(d.vars, d.attrs); queueTop() }
2454
3368
  })
2455
3369
 
2456
3370
  // 无头浏览器(check_design / look_design 在电脑上另开的,见 browser.js)直接调这几个:
@@ -2469,6 +3383,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2469
3383
  return { issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics }
2470
3384
  },
2471
3385
  dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
3386
+ capsule: (how) => showCapsule(how),
3387
+ winctl: (how) => showWinCtl(how),
2472
3388
  /** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
2473
3389
  tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
2474
3390
  settle: () => settle().then(() => true),
@@ -2476,7 +3392,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2476
3392
  links: () => linksOf().map(({ path, text }) => ({ path, text })),
2477
3393
  info: () => {
2478
3394
  const meta = document.querySelector('meta[name="dsh-design"]')
2479
- return { title: document.title, meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '', page: Math.max(se().scrollHeight, innerHeight), w: innerWidth, h: innerHeight }
3395
+ return { title: document.title, meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '', page: Math.max(se().scrollHeight, innerHeight), w: innerWidth, h: innerHeight, topDark: topDark() }
2480
3396
  },
2481
3397
  /** 'end':所有能滚的地方都滚到底;数字 i:主要的那块滚到第 i 屏(相邻两屏留出顶栏、底栏的高度,栏后面的内容下一屏能看到) */
2482
3398
  // 滚完等页面跟上再截图