dsh-design-studio 0.7.0 → 0.8.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,15 @@ 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 小程序右上角的胶囊(微信画的,页面盖不住)挡住了字或按钮
1647
1662
  // 有意为之的不报:省略号(text-overflow: ellipsis)、行数截断(line-clamp)、可以滚动的区域(轮播)、很淡的装饰大字。
1648
1663
 
1649
1664
  const CHECK_PER = 8 // 每类最多报几条
1650
1665
  const CHECK_MAX = 40
1651
1666
  const CHECK_ATOMS = 600 // 最多看多少个带字的元素
1652
1667
  const CLICKABLE = 'a[href],button,[role="button"],[role="tab"],[role="link"],input:not([type="hidden"]),select,textarea,summary,[onclick],[data-href]'
1668
+ // 小程序右上角的胶囊:离屏幕顶 48(状态栏 44 + 4)、离右边 7、87×32(和右侧栏外框画的一样)
1669
+ const CAP = { top: 48, right: 7, w: 87, h: 32 }
1653
1670
  let checkMarks = []
1654
1671
 
1655
1672
  const isOurs = (el) => SIG_SKIP.has(el.tagName)
@@ -1672,13 +1689,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1672
1689
  return o
1673
1690
  }
1674
1691
 
1675
- /** 'rgb(1, 2, 3)' / 'rgba(1, 2, 3, .5)' / 'color(srgb …)' → [r, g, b, a];别的写法(oklch 之类浏览器已经换算过)认不出就 null */
1692
+ /**
1693
+ * 'rgb(1, 2, 3)' / 'rgba(1, 2, 3, .5)' / 'color(srgb …)' → [r, g, b, a]。
1694
+ * oklch()、oklab()、lab()、lch()、hwb()、color(display-p3 …) 这些(算好的值保留原写法,Tailwind 4 默认就是 oklch):交给 canvas 换成 sRGB。认不出就 null
1695
+ */
1696
+ const colorMemo = new Map()
1697
+ let colorCtx = null
1676
1698
  function rgba(s) {
1677
1699
  const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s)
1678
1700
  if (m) return [+m[1], +m[2], +m[3], m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]]
1679
1701
  const c = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/.exec(s)
1680
1702
  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
1703
+ if (typeof s !== 'string' || !/^(?:oklch|oklab|lab|lch|hwb|hsla?|color)\(/.test(s)) return null
1704
+ if (colorMemo.has(s)) return colorMemo.get(s)
1705
+ let out = null
1706
+ try {
1707
+ if (!colorCtx) { const cv = document.createElement('canvas'); cv.width = cv.height = 1; colorCtx = cv.getContext('2d', { willReadFrequently: true }) }
1708
+ const g = colorCtx
1709
+ g.fillStyle = '#010203'
1710
+ g.fillStyle = s
1711
+ if (g.fillStyle !== '#010203') {
1712
+ g.clearRect(0, 0, 1, 1)
1713
+ g.fillRect(0, 0, 1, 1)
1714
+ const d = g.getImageData(0, 0, 1, 1).data
1715
+ out = [d[0], d[1], d[2], d[3] / 255]
1716
+ }
1717
+ } catch { out = null }
1718
+ if (colorMemo.size > 500) colorMemo.clear()
1719
+ colorMemo.set(s, out)
1720
+ return out
1682
1721
  }
1683
1722
  const over = (top, under) => {
1684
1723
  const a = top[3]
@@ -1693,37 +1732,158 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1693
1732
 
1694
1733
  const MEDIA = new Set(['IMG', 'VIDEO', 'CANVAS', 'IFRAME', 'PICTURE', 'OBJECT', 'EMBED'])
1695
1734
  /**
1696
- * 字背后的底色:从字往下一层层叠背景色,遇到不透明的为止;中间有背景图、渐变、图片视频就说不准,返回 null。
1697
- * 字在屏幕里时按实际叠放顺序找(字下面压着的可能不是祖先,比如首屏背景视频);不在屏幕里就顺着祖先找
1735
+ * 字背后的底色,和字叠上去以后的颜色:从字往下一层层叠背景色,遇到不透明(且不在半透明的组里)的为止;
1736
+ * 透明度按组算(见 compose),字的透明度也一样。中间有背景图、渐变、图片视频就说不准,返回 null。
1737
+ * 字在屏幕里时按实际叠放顺序找(字下面压着的可能不是祖先,比如首屏背景视频、不可点的装饰底);
1738
+ * 不在屏幕里、或被滚动区裁掉的按位置找(paintedBelow)。pool:页面上画了底的元素(见 paintPool)
1698
1739
  */
1699
- function backdrop(el, pt) {
1740
+ function backdrop(el, pt, fg, pool) {
1700
1741
  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)
1742
+ if (pt.x >= 0 && pt.y >= 0 && pt.x < innerWidth && pt.y < innerHeight) {
1743
+ const stack = paintedAt(pt.x, pt.y) // 不可点的背景层(装饰底图、卡片底色)也算
1703
1744
  const i = stack.indexOf(el)
1704
1745
  if (i >= 0) chain = stack.slice(i)
1705
1746
  }
1706
- if (!chain) { chain = []; for (let e = el; e && e.nodeType === 1; e = e.parentElement) chain.push(e) }
1747
+ if (!chain) chain = paintedBelow(el, pt.x, pt.y, pool())
1748
+ if (!chain) return null
1749
+ const { base, explicit, onCanvas } = canvasBase()
1707
1750
  const layers = []
1708
- let base = null
1751
+ let floor = false
1709
1752
  for (const e of chain) {
1710
1753
  if (isOurs(e)) continue
1754
+ if (e !== el && !e.contains(el) && opacityChain(e) <= 0.01) continue // 整个透明的装饰层(淡入前、藏起来的)什么都没画
1711
1755
  if (MEDIA.has(e.tagName) || (e instanceof SVGElement && e.tagName.toLowerCase() !== 'svg')) return null
1712
1756
  const cs = getComputedStyle(e)
1713
1757
  if (cs.backgroundImage && cs.backgroundImage !== 'none') return null
1714
1758
  const c = rgba(cs.backgroundColor)
1715
1759
  if (!c) return null
1716
- if (c[3] > 0) {
1717
- if (c[3] >= 0.999) { base = c; break }
1718
- layers.push(c)
1760
+ if (onCanvas(e) || c[3] <= 0) continue
1761
+ layers.push([e, c])
1762
+ if (c[3] >= 0.999 && opacityChain(e) >= 0.999) { floor = true; break }
1763
+ }
1764
+ if (!floor && darkOn && !explicit) return null // 深色模式下画布底色不一定是白的
1765
+ layers.reverse()
1766
+ return { color: compose(layers, base), fg: fg ? compose([...layers, [el, fg]], base) : null }
1767
+ }
1768
+
1769
+ /**
1770
+ * 页面上画了东西、看得见的元素(底色、背景图、图片视频、SVG 图形)和它们现在的框,按位置找背景用。
1771
+ * 按最近的固定 / 吸顶那层(自己或祖先,没有是 null)分组,组里按竖直位置分段放(rows,每段 POOL_ROW px)
1772
+ */
1773
+ const POOL_ROW = 256
1774
+ function paintPool() {
1775
+ const groups = new Map()
1776
+ const pins = new Map()
1777
+ const held = new Map() // 祖先里有给固定定位当包含块的(transform 之类):里面的 fixed 其实跟着它走
1778
+ const heldIn = (e) => {
1779
+ if (!e || e.nodeType !== 1) return false
1780
+ if (!held.has(e)) held.set(e, holds(getComputedStyle(e), true) || heldIn(e.parentElement))
1781
+ return held.get(e)
1782
+ }
1783
+ const pinOf = (e) => {
1784
+ if (!e || e.nodeType !== 1) return null
1785
+ if (pins.has(e)) return pins.get(e)
1786
+ const pos = getComputedStyle(e).position
1787
+ const v = pos === 'sticky' || (pos === 'fixed' && !heldIn(e.parentElement)) ? e : pinOf(e.parentElement)
1788
+ pins.set(e, v)
1789
+ return v
1790
+ }
1791
+ const all = [document.documentElement, ...(document.body ? [document.body, ...document.body.getElementsByTagName('*')] : [])]
1792
+ for (const e of all) {
1793
+ if (isOurs(e)) continue
1794
+ const svg = e instanceof SVGElement && e.tagName.toLowerCase() !== 'svg'
1795
+ const cs = getComputedStyle(e)
1796
+ if (cs.visibility !== 'visible') continue
1797
+ const c = rgba(cs.backgroundColor)
1798
+ if (!svg && !MEDIA.has(e.tagName) && c && c[3] <= 0 && cs.backgroundImage === 'none') continue
1799
+ const r = e.getBoundingClientRect()
1800
+ if (r.width < 1 || r.height < 1) continue
1801
+ const pin = pinOf(e)
1802
+ if (!groups.has(pin)) groups.set(pin, { rows: new Map(), all: [] })
1803
+ const g = groups.get(pin)
1804
+ g.all.push([e, r])
1805
+ for (let k = Math.floor(r.top / POOL_ROW), n = Math.floor(r.bottom / POOL_ROW); k <= n; k++) {
1806
+ if (!g.rows.has(k)) g.rows.set(k, [])
1807
+ g.rows.get(k).push([e, r])
1719
1808
  }
1720
1809
  }
1721
- if (!base) {
1722
- if (darkOn) return null // 深色模式下画布底色不一定是白的
1723
- base = [255, 255, 255, 1]
1810
+ // 固定 / 吸顶的那些对这个字来说看得见吗:整体透明的不算(量过记着);被它自己那一支(不包着字的那几层)裁光的不算,
1811
+ // 和字共用的滚动区暂时裁掉的照算(字滚进来时它也跟着进来)
1812
+ const gone = new Map()
1813
+ const clear = (e) => {
1814
+ if (!gone.has(e)) gone.set(e, opacityChain(e) <= 0.01)
1815
+ return gone.get(e)
1724
1816
  }
1725
- for (let i = layers.length - 1; i >= 0; i--) base = over(layers[i], base)
1726
- return { color: base }
1817
+ const shown = (e, r, el) => !clear(e) && !!restBox(e, true, el)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
1818
+ return { groups, pinOf, shown, clear }
1819
+ }
1820
+
1821
+ /** 底色不透明、没有背景图、不在半透明组里:叠到它就到底了 */
1822
+ function solidFloor(e) {
1823
+ if (MEDIA.has(e.tagName) || e instanceof SVGElement) return false
1824
+ const cs = getComputedStyle(e)
1825
+ const c = rgba(cs.backgroundColor)
1826
+ return !!c && c[3] >= 0.999 && cs.backgroundImage === 'none' && opacityChain(e) >= 0.999
1827
+ }
1828
+
1829
+ /**
1830
+ * 字所在这一点(视口坐标,可以在屏幕外、被滚动区裁掉)下面画着的,从字往下排:框盖住这一点、画在字下面的(祖先,或按层叠顺序在它下面的);
1831
+ * 只算它自己那一支的裁剪。字在能滚的区域(main、横向轮播)里没滚进来时,对那个区域外面的东西(和区域自己的底)来说,
1832
+ * 字滚进来以后在区域露出来的那块里——按就近滚进来算位置(由里往外一层层挪进去)。
1833
+ * 和字不在同一个固定 / 吸顶层里的跟着窗口走,字滚进来以后它在哪说不准:横向盖得住、画在字下面、又不在到底那层(字自己的底也算)下面的,
1834
+ * 返回 null(不查)。不滚页面(滚了会打断页面自己的滚动、带着外层画布一起滚)
1835
+ */
1836
+ function paintedBelow(el, x, y, pool) {
1837
+ const mine = new Set([null]) // 和字一起动的:不固定的,和字所在的各层固定 / 吸顶
1838
+ for (let p = pool.pinOf(el); p; p = pool.pinOf(p.parentElement)) mine.add(p)
1839
+ // 字外面会裁的各层(由里往外)和字一层层挪进去以后的位置:pts[j] = 挪进了前 j 层
1840
+ const boxes = []
1841
+ const pts = [{ x, y }]
1842
+ for (let e = el.parentElement; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
1843
+ const cs = getComputedStyle(e)
1844
+ const cx = cs.overflowX !== 'visible', cy = cs.overflowY !== 'visible'
1845
+ if (!cx && !cy) continue
1846
+ const r = e.getBoundingClientRect()
1847
+ const kx = e.offsetWidth ? r.width / e.offsetWidth : 1 // 这层实际的缩放(transform、zoom),和 restBox 一样换成视口像素
1848
+ const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
1849
+ const l = r.left + e.clientLeft * kx, t = r.top + e.clientTop * ky
1850
+ const q = pts[pts.length - 1]
1851
+ boxes.push(e)
1852
+ 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 })
1853
+ }
1854
+ // c 看到的字在哪:c 外面那几层(c 自己能滚的话也算,它的底不跟着内容走)挪进去以后
1855
+ const ptFor = (c) => { let j = 0; while (j < boxes.length && (boxes[j] === c || !boxes[j].contains(c))) j++; return pts[j] }
1856
+ const near = new Set()
1857
+ for (const pin of mine) {
1858
+ const g = pool.groups.get(pin)
1859
+ if (!g) continue
1860
+ 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)
1861
+ }
1862
+ const below = []
1863
+ for (const [c, r] of near) {
1864
+ if (c === el || el.contains(c)) continue
1865
+ const p = ptFor(c)
1866
+ if (p.x < r.left || p.x > r.right || p.y < r.top || p.y > r.bottom) continue
1867
+ if (!paintsOver(el, c)) continue // 盖在字上面的不算
1868
+ if (!c.contains(el)) {
1869
+ const vis = restBox(c, true, el)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
1870
+ if (!vis || p.x < vis.l || p.x > vis.r || p.y < vis.t || p.y > vis.b) continue
1871
+ }
1872
+ below.push(c)
1873
+ }
1874
+ below.sort((a, b) => (paintsOver(a, b) ? -1 : paintsOver(b, a) ? 1 : 0))
1875
+ const { onCanvas } = canvasBase()
1876
+ const floor = [el, ...below].find((c) => !onCanvas(c) && solidFloor(c))
1877
+ const out = pts[pts.length - 1].x // 各层都挪进去以后的横向位置
1878
+ for (const [pin, g] of pool.groups) {
1879
+ if (mine.has(pin)) continue
1880
+ for (const [c, r] of g.all) {
1881
+ if (out < r.left || out > r.right || pool.clear(c) || el.contains(c)) continue
1882
+ if (floor && paintsOver(floor, c)) continue // 在到底那层下面(同一层的字都一样,记着)
1883
+ if (paintsOver(el, c) && pool.shown(c, r, el)) return null
1884
+ }
1885
+ }
1886
+ return [el, ...below]
1727
1887
  }
1728
1888
 
1729
1889
  /** 自己那几段文字在屏幕上的矩形(每行一块) */
@@ -2137,10 +2297,12 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2137
2297
  }
2138
2298
  }
2139
2299
 
2140
- // 4. 对比度(WCAG:正文 4.5,大字 3)
2300
+ // 4. 对比度(WCAG:正文 4.5,大字 3)。取底色那段装一次「量画着什么」的临时样式(见 withProbe),量完撤掉
2141
2301
  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) {
2302
+ const tinyMin = kind === 'app' || kind === 'miniprogram' ? 10 : kind === 'tablet' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
2303
+ let pool = null
2304
+ const poolOf = () => pool || (pool = paintPool()) // 有字不在屏幕里才建
2305
+ withProbe(() => { for (const a of atoms) {
2144
2306
  const { el, cs } = a
2145
2307
  const px = parseFloat(cs.fontSize) || 0
2146
2308
  sizes.set(Math.round(px * 2) / 2, (sizes.get(Math.round(px * 2) / 2) || 0) + 1)
@@ -2153,17 +2315,17 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2153
2315
  const op = opacityChain(el)
2154
2316
  // 半透明还在变的(淡入动画、跟着滚动渐显的)量的是中间状态,不算
2155
2317
  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)
2318
+ const bd = backdrop(el, { x: (r0.l + r0.r) / 2, y: (r0.t + r0.b) / 2 }, fg, poolOf)
2319
+ if (!bd || !bd.fg) continue
2320
+ fg = bd.fg
2159
2321
  const cr = ratio(fg, bd.color)
2160
2322
  const bold = +cs.fontWeight >= 700
2161
2323
  const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5
2162
2324
  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
- }
2325
+ } })
2164
2326
 
2165
- // 5. 点击区太小(App / 平板 44,网页 24;文字中间的链接不算)
2166
- const minTap = kind === 'app' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
2327
+ // 5. 点击区太小(App / 小程序 / 平板 44,网页 24;文字中间的链接不算)
2328
+ const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
2167
2329
  if (minTap) {
2168
2330
  const small = []
2169
2331
  for (const el of body.querySelectorAll(CLICKABLE)) {
@@ -2255,6 +2417,9 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2255
2417
  for (const f of document.fonts || []) if (f.status === 'error') add('missing', null, { what: 'font', src: String(f.family || '').replace(/['"]/g, '') }, 1)
2256
2418
  } catch { /* 老浏览器 */ }
2257
2419
 
2420
+ // 13. 小程序:右上角胶囊挡住的字和按钮。不固定的按没滚时的位置算(滚下去本来就会从胶囊下面过)
2421
+ if (kind === 'miniprogram') capsuleCheck(add, atoms, vw)
2422
+
2258
2423
  issues.sort((x, y) => y.sev - x.sev)
2259
2424
  const se0 = se()
2260
2425
  return {
@@ -2263,6 +2428,383 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2263
2428
  }
2264
2429
  }
2265
2430
 
2431
+ /**
2432
+ * 元素在「各层都没滚」时露出来的那块(视口坐标):窗口、内部滚动区滚过的量加回去,被外层裁掉的去掉。
2433
+ * 滚动量、边框都按那层实际的缩放(transform、zoom)换成视口像素;裁剪按 padding box、只裁 overflow 不是 visible 的那个方向。
2434
+ * 绝对 / 固定定位的只受包含块和更外面的管;固定 / 吸顶的那层按现在看到的位置算,它外面的滚动不管、裁剪照算。全被裁掉返回 null。
2435
+ * now:按现在的滚动位置算(只去掉裁掉的部分)。upto:只算到包着它的那层为止(更外面的裁剪不管)
2436
+ */
2437
+ function restBox(el, now = false, upto = null) {
2438
+ const chain = []
2439
+ let mode = ''
2440
+ let pinned = false
2441
+ for (let e = el; e && e !== document.documentElement; e = e.parentElement) {
2442
+ if (upto && e !== el && e.contains(upto)) break
2443
+ const cs = getComputedStyle(e)
2444
+ if (e !== el) {
2445
+ if ((mode === 'absolute' && !holds(cs, false)) || (mode === 'fixed' && !holds(cs, true))) continue
2446
+ const r = e.getBoundingClientRect()
2447
+ const kx = e.offsetWidth ? r.width / e.offsetWidth : 1
2448
+ const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
2449
+ const cx = e !== document.body && cs.overflowX !== 'visible'
2450
+ const cy = e !== document.body && cs.overflowY !== 'visible'
2451
+ chain.push({
2452
+ sx: pinned || now ? 0 : e.scrollLeft * kx,
2453
+ sy: pinned || now ? 0 : e.scrollTop * ky,
2454
+ clip: cx || cy ? {
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,
2458
+ })
2459
+ }
2460
+ if (cs.position === 'fixed' || cs.position === 'sticky') pinned = true
2461
+ mode = cs.position
2462
+ }
2463
+ let dx = pinned || now ? 0 : scrollX
2464
+ let dy = pinned || now ? 0 : scrollY
2465
+ let box = { l: -Infinity, r: Infinity, t: -Infinity, b: Infinity }
2466
+ // 从外往里:每层的位置跟着它外面各层滚过的量走,它自己滚过的量只挪它里面的
2467
+ for (let i = chain.length - 1; i >= 0; i--) {
2468
+ const c = chain[i]
2469
+ 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) }
2470
+ dx += c.sx
2471
+ dy += c.sy
2472
+ }
2473
+ return (r) => {
2474
+ 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) }
2475
+ return o.r - o.l > 0 && o.b - o.t > 0 ? o : null
2476
+ }
2477
+ }
2478
+
2479
+ function capsuleCheck(add, atoms, vw) {
2480
+ const cap = { l: vw - CAP.right - CAP.w, r: vw - CAP.right, t: CAP.top, b: CAP.top + CAP.h }
2481
+ // 伸进胶囊多少(横竖都要伸进 2px 以上才算):按「各层都没滚」时露出来的那块量
2482
+ const into = (r) => {
2483
+ if (!r) return 0
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
+ }
2488
+ const hit = new Set()
2489
+ const check = (el, rects) => {
2490
+ if (hiddenChain(el) || [...hit].some((h) => h.contains(el) || el.contains(h))) return
2491
+ const rest = restBox(el)
2492
+ const px = Math.max(0, ...rects.map((r) => into(rest(r))))
2493
+ if (!px) return
2494
+ hit.add(el)
2495
+ add('capsule', el, { px, x: Math.round(cap.l) }, 2)
2496
+ }
2497
+ for (const el of document.body.querySelectorAll(CLICKABLE)) {
2498
+ if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
2499
+ const r = el.getBoundingClientRect()
2500
+ if (r.width >= 1 && r.height >= 1) check(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])
2501
+ }
2502
+ for (const a of atoms) check(a.el, a.rects)
2503
+ }
2504
+
2505
+ /**
2506
+ * 最底下的画布:html(没有就 body)的底色铺满整个窗口。html 有底色或背景图时画布用 html 的、body 的背景照常算;
2507
+ * 都没有时 body 的底色传到画布上。onCanvas(e):它自己的底色就是画布,叠的时候别再算一遍。explicit:页面自己写了底色
2508
+ */
2509
+ function canvasBase() {
2510
+ if (probe && probe.canvas) return probe.canvas
2511
+ const de = document.documentElement
2512
+ const dcs = getComputedStyle(de)
2513
+ const htmlBg = rgba(dcs.backgroundColor)
2514
+ const htmlHas = (!!htmlBg && htmlBg[3] > 0) || dcs.backgroundImage !== 'none'
2515
+ const bodyBg = document.body ? rgba(getComputedStyle(document.body).backgroundColor) : null
2516
+ const blank = schemeDark() ? [18, 18, 18, 1] : [255, 255, 255, 1]
2517
+ const can = htmlHas ? htmlBg : bodyBg
2518
+ const explicit = !!can && can[3] > 0
2519
+ const out = { base: explicit ? (can[3] < 1 ? over(can, blank) : can) : blank, explicit, onCanvas: (e) => e === de || (e === document.body && !htmlHas) }
2520
+ if (probe) probe.canvas = out
2521
+ return out
2522
+ }
2523
+
2524
+ /**
2525
+ * 从下往上叠 [元素, 颜色] 这些层,透明度和浏览器一样按组算:opacity < 1 的元素连同它里面的先合成(从全透明起),再整体变淡叠下去。
2526
+ * 元素的祖先不在层里(子元素伸出了它的框)也照算。base 是最底下的颜色(不透明),返回叠完的 [r, g, b, 1]
2527
+ */
2528
+ function compose(layers, base) {
2529
+ const pre = (c) => [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]]
2530
+ 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])]
2531
+ const stack = [{ owner: null, o: 1, c: [0, 0, 0, 0] }]
2532
+ const close = () => { const g = stack.pop(); const up = stack[stack.length - 1]; up.c = ov(g.c.map((v) => v * g.o), up.c) }
2533
+ const memo = new Map()
2534
+ const groupsOf = (e) => {
2535
+ if (memo.has(e)) return memo.get(e)
2536
+ const a = []
2537
+ for (let p = e; p && p.nodeType === 1; p = p.parentElement) { const o = +getComputedStyle(p).opacity; if (o < 1) a.unshift([p, o]) }
2538
+ memo.set(e, a)
2539
+ return a
2540
+ }
2541
+ for (const [e, col] of layers) {
2542
+ const want = groupsOf(e)
2543
+ let k = 0
2544
+ while (k < want.length && k + 1 < stack.length && stack[k + 1].owner === want[k][0]) k++
2545
+ while (stack.length > k + 1) close()
2546
+ for (let i = k; i < want.length; i++) stack.push({ owner: want[i][0], o: want[i][1], c: [0, 0, 0, 0] })
2547
+ if (col && col[3] > 0) { const g = stack[stack.length - 1]; g.c = ov(pre(col), g.c) }
2548
+ }
2549
+ while (stack.length > 1) close()
2550
+ const c = ov(stack[0].c, base)
2551
+ return [c[0], c[1], c[2], 1]
2552
+ }
2553
+
2554
+ /**
2555
+ * 顶栏右边(胶囊那块)是不是深色:是的话外框画白色胶囊(和微信 navigationBarTextStyle: white 一样)。
2556
+ * 页面写了 <meta name="dsh-capsule" content="white|black"> 就照它;不然从下往上叠胶囊中心那点的各层:
2557
+ * 底色、渐变(取各色标的平均)、图片 / 视频 / canvas(按 object-fit / object-position 画到小画布上取那块的平均,跨域取不到就跳过)。
2558
+ * 透明度和浏览器一样按组算:opacity < 1 的元素连同里面的先合成,再整体变淡。装饰 SVG 跳过。
2559
+ * 导出带外框的图也用这个结果(截图复制页面那一刻量的),两边一致
2560
+ */
2561
+ function topDark() {
2562
+ try {
2563
+ const m = document.querySelector('meta[name="dsh-capsule"]')
2564
+ const say = m ? String(m.getAttribute('content') || '').trim().toLowerCase() : ''
2565
+ if (say === 'white' || say === 'black') return say === 'white'
2566
+ const x = innerWidth - CAP.right - CAP.w / 2
2567
+ const y = CAP.top + CAP.h / 2
2568
+ const hits = paintedAt(x, y).filter((e) => !isOurs(e) && !(e instanceof SVGElement)).reverse() // 从下往上
2569
+ const { base, onCanvas } = canvasBase()
2570
+ const layers = []
2571
+ for (const e of hits) {
2572
+ const cs = getComputedStyle(e)
2573
+ 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])
2574
+ }
2575
+ const c = compose(layers, base)
2576
+ return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] < 140
2577
+ } catch { return false }
2578
+ }
2579
+ /**
2580
+ * 量「这一点上画着什么」的那段时间:临时加一张样式表(放在层里,盖得过页面没分层的 !important)把 pointer-events 全设成 auto,
2581
+ * 命中测试就不会跳过不可点的背景层;量完撤掉——不改页面的 DOM,页面的 MutationObserver 不会知道。
2582
+ * 还盖不过的(行内 !important、页面更早的层里写的)先列出来,paintedAt 按位置补。fn 得是同步的;嵌套调用共用一次
2583
+ */
2584
+ let probe = null
2585
+ function withProbe(fn) {
2586
+ if (probe) return fn()
2587
+ const was = [...document.adoptedStyleSheets]
2588
+ let sheet = null
2589
+ try {
2590
+ sheet = new CSSStyleSheet()
2591
+ sheet.replaceSync('@layer dsh-design-probe{*{pointer-events:auto!important}}')
2592
+ document.adoptedStyleSheets = [...was, sheet]
2593
+ } catch { sheet = null }
2594
+ try {
2595
+ const extra = []
2596
+ for (const e of [document.documentElement, document.body, ...(document.body ? document.body.querySelectorAll('*') : [])]) {
2597
+ if (!e || isOurs(e) || e instanceof SVGElement) continue
2598
+ const cs = getComputedStyle(e)
2599
+ if (cs.pointerEvents === 'none' && cs.visibility === 'visible') extra.push(e)
2600
+ }
2601
+ probe = { extra, layers: new Map(), paths: new Map(), pairs: new Map(), canvas: null } // 后几个是量的这段时间里的缓存(页面不会变)
2602
+ return fn()
2603
+ } finally {
2604
+ probe = null
2605
+ if (sheet) document.adoptedStyleSheets = was
2606
+ }
2607
+ }
2608
+ /** 这一点上画着的元素(从上往下):不可点的背景层也算;命中测试漏掉的按位置找出来、估个上下顺序插进去(被外层裁掉的不算) */
2609
+ function paintedAt(x, y) {
2610
+ return withProbe(() => {
2611
+ const list = document.elementsFromPoint(x, y)
2612
+ for (const e of probe.extra) {
2613
+ if (list.includes(e)) continue
2614
+ const r = e.getBoundingClientRect()
2615
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
2616
+ const vis = restBox(e, true)({ l: r.left, r: r.right, t: r.top, b: r.bottom })
2617
+ if (!vis || x < vis.l || x > vis.r || y < vis.t || y > vis.b) continue
2618
+ const i = list.findIndex((h) => paintsOver(e, h))
2619
+ list.splice(i < 0 ? list.length : i, 0, e)
2620
+ }
2621
+ return list
2622
+ })
2623
+ }
2624
+ /** 会建层叠上下文的元素:z-index 只在它里面比,里面的东西整块跟着它排 */
2625
+ function stackRoot(e, cs) {
2626
+ if (e === document.documentElement) return true
2627
+ const zi = cs.zIndex !== 'auto'
2628
+ const p = e.parentElement
2629
+ return (zi && (cs.position !== 'static' || (!!p && /flex|grid/.test(getComputedStyle(p).display)))) || cs.position === 'fixed' || cs.position === 'sticky' ||
2630
+ +cs.opacity < 1 || moved(cs) || cs.filter !== 'none' || cs.perspective !== 'none' || cs.isolation === 'isolate' || cs.mixBlendMode !== 'normal' ||
2631
+ (!!cs.clipPath && cs.clipPath !== 'none') || (!!cs.maskImage && cs.maskImage !== 'none') || (!!cs.backdropFilter && cs.backdropFilter !== 'none') ||
2632
+ /paint|layout|strict|content/.test(cs.contain) || /transform|scale|translate|rotate|opacity|filter|perspective|z-index/.test(cs.willChange)
2633
+ }
2634
+ /** 能给绝对定位(fixed:给固定定位)当包含块的:定位了的,或 transform / perspective / filter / contain / will-change 这些 */
2635
+ function holds(cs, fixed) {
2636
+ return (!fixed && cs.position !== 'static') || moved(cs) || cs.perspective !== 'none' || cs.filter !== 'none' ||
2637
+ (!!cs.backdropFilter && cs.backdropFilter !== 'none') || /paint|layout|strict|content/.test(cs.contain) ||
2638
+ /transform|scale|translate|rotate|perspective|filter/.test(cs.willChange)
2639
+ }
2640
+ /** 变换过的:transform,或单独写的 scale / translate / rotate */
2641
+ function moved(cs) {
2642
+ return cs.transform !== 'none' || (!!cs.scale && cs.scale !== 'none') || (!!cs.translate && cs.translate !== 'none') || (!!cs.rotate && cs.rotate !== 'none')
2643
+ }
2644
+ /**
2645
+ * a 画在 b 上面吗(命中测试漏掉的那几个用来估位置):祖先在下、后代在上(负 z-index 跑到不建上下文的祖先底下的除外);不相干的找两者共同的层叠上下文,
2646
+ * 各取在它里面代表自己的那个(往下第一个自己建上下文的,没有就是路上离自己最近的定位了的),比层级(负 z < 普通流 < 定位 / z 0 < 正 z),一样按文档顺序
2647
+ */
2648
+ function paintsOver(a, b) {
2649
+ const memo = probe && probe.pairs
2650
+ let row = memo && memo.get(a)
2651
+ if (row && row.has(b)) return row.get(b)
2652
+ const v = paintsOverNow(a, b)
2653
+ if (memo) { if (!row) memo.set(a, (row = new Map())); row.set(b, v) }
2654
+ return v
2655
+ }
2656
+ /** 叠放用到的几样(建不建层叠上下文、z-index、定位),量的这段时间里记着 */
2657
+ function layerOf(e) {
2658
+ const m = probe && probe.layers
2659
+ let v = m && m.get(e)
2660
+ if (v) return v
2661
+ const cs = getComputedStyle(e)
2662
+ v = { root: stackRoot(e, cs), z: parseInt(cs.zIndex, 10), pos: cs.position }
2663
+ if (m) m.set(e, v)
2664
+ return v
2665
+ }
2666
+ /** 从 html 到它的那串祖先(含自己),量的这段时间里记着 */
2667
+ function pathOf(e) {
2668
+ const m = probe && probe.paths
2669
+ let v = m && m.get(e)
2670
+ if (v) return v
2671
+ v = e.parentElement ? [...pathOf(e.parentElement), e] : [e]
2672
+ if (m) m.set(e, v)
2673
+ return v
2674
+ }
2675
+ function paintsOverNow(a, b) {
2676
+ // 后代 d 画在祖先 anc 底下:anc 自己不建层叠上下文,而 d 所在的、紧挨着 anc 的那个上下文是负 z-index 的(整块画到外面那层的最底下)
2677
+ const under = (anc, d) => {
2678
+ if (layerOf(anc).root) return false
2679
+ let top = null
2680
+ for (let x = d; x && x !== anc; x = x.parentElement) if (layerOf(x).root) top = x
2681
+ return !!top && layerOf(top).z < 0
2682
+ }
2683
+ if (a.contains(b)) return under(a, b)
2684
+ if (b.contains(a)) return !under(b, a)
2685
+ const pa = pathOf(a), pb = pathOf(b)
2686
+ let common = 0
2687
+ for (let i = 0; i < pa.length && pa[i] === pb[i]; i++) if (layerOf(pa[i]).root) common = i
2688
+ const rep = (path) => {
2689
+ let pos = null
2690
+ for (let k = common + 1; k < path.length; k++) {
2691
+ const v = layerOf(path[k])
2692
+ 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 }
2693
+ if (v.pos !== 'static') pos = path[k] // 定位了没建上下文的:各自按文档顺序排,取离自己最近的那个
2694
+ }
2695
+ return pos ? { el: pos, lv: 1 } : { el: path[path.length - 1], lv: 0 }
2696
+ }
2697
+ const ra = rep(pa), rb = rep(pb)
2698
+ if (ra.lv !== rb.lv) return ra.lv > rb.lv
2699
+ const [x, y] = ra.el === rb.el ? [a, b] : [ra.el, rb.el]
2700
+ return !!(y.compareDocumentPosition(x) & Node.DOCUMENT_POSITION_FOLLOWING)
2701
+ }
2702
+ /** 渐变 → 各色标的平均(rgb() 以外的写法也认,见 rgba);没有渐变 / 只有图片地址 → null */
2703
+ function gradTone(bi) {
2704
+ if (!bi || bi === 'none' || !/gradient\(/.test(bi)) return null
2705
+ const cs = (bi.match(/(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^()]*\)/g) || []).map(rgba).filter(Boolean)
2706
+ if (!cs.length) return null
2707
+ const a = cs.reduce((s, c) => s + c[3], 0) / cs.length
2708
+ if (a <= 0) return [0, 0, 0, 0]
2709
+ const ch = (k) => cs.reduce((s, c) => s + c[k] * c[3], 0) / (a * cs.length)
2710
+ return [ch(0), ch(1), ch(2), a]
2711
+ }
2712
+ /** 图片 / 视频 / canvas 在胶囊那块的平均色:按内容框、object-fit、object-position 摆,裁在内容框里;没载完、跨域取不到 → null */
2713
+ function mediaTone(e, cs) {
2714
+ try {
2715
+ if (!/^(IMG|VIDEO|CANVAS)$/.test(e.tagName)) return null
2716
+ const nw = e.naturalWidth || e.videoWidth || e.width
2717
+ const nh = e.naturalHeight || e.videoHeight || e.height
2718
+ if (!nw || !nh) return null
2719
+ const r = e.getBoundingClientRect()
2720
+ const k = e.offsetWidth ? r.width / e.offsetWidth : 1
2721
+ const n = (v) => parseFloat(v) || 0
2722
+ const cl = r.left + (n(cs.borderLeftWidth) + n(cs.paddingLeft)) * k
2723
+ const ct = r.top + (n(cs.borderTopWidth) + n(cs.paddingTop)) * k
2724
+ const cw = r.right - (n(cs.borderRightWidth) + n(cs.paddingRight)) * k - cl
2725
+ const ch = r.bottom - (n(cs.borderBottomWidth) + n(cs.paddingBottom)) * k - ct
2726
+ if (cw <= 0 || ch <= 0) return null
2727
+ const fit = cs.objectFit || 'fill'
2728
+ let w = cw, h = ch
2729
+ if (fit !== 'fill') {
2730
+ let z = fit === 'cover' ? Math.max(cw / nw, ch / nh) : fit === 'none' ? k : Math.min(cw / nw, ch / nh)
2731
+ if (fit === 'scale-down') z = Math.min(k, z)
2732
+ w = nw * z
2733
+ h = nh * z
2734
+ }
2735
+ // object-position 算好的值是「横 竖」两项,每项是百分比、像素或 calc(百分比 ± 像素)(right 0 bottom 20px → 100% calc(100% - 20px));认不出的按居中
2736
+ const toks = []
2737
+ let depth = 0, cur = ''
2738
+ for (const ch of String(cs.objectPosition || '')) {
2739
+ if (ch === '(') depth++
2740
+ else if (ch === ')') depth--
2741
+ if (/\s/.test(ch) && !depth) { if (cur) toks.push(cur); cur = '' } else cur += ch
2742
+ }
2743
+ if (cur) toks.push(cur)
2744
+ const [px, py] = toks.length === 2 ? toks : []
2745
+ const WORD = { left: 0, top: 0, center: 50, right: 100, bottom: 100 }
2746
+ const at = (v, free) => {
2747
+ if (!v) return free / 2
2748
+ if (v in WORD) return (free * WORD[v]) / 100
2749
+ let pct = 0, pix = 0, ok = false
2750
+ 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 }
2751
+ const r = (free * pct) / 100 + pix * k
2752
+ return ok && Number.isFinite(r) ? r : free / 2
2753
+ }
2754
+ const ox = cl + at(px, cw - w)
2755
+ const oy = ct + at(py, ch - h)
2756
+ const c = document.createElement('canvas')
2757
+ c.width = 22
2758
+ c.height = 8
2759
+ const g = c.getContext('2d')
2760
+ const capL = innerWidth - CAP.right - CAP.w
2761
+ const sx = c.width / CAP.w
2762
+ const sy = c.height / CAP.h
2763
+ g.beginPath()
2764
+ g.rect((cl - capL) * sx, (ct - CAP.top) * sy, cw * sx, ch * sy)
2765
+ g.clip()
2766
+ g.drawImage(e, (ox - capL) * sx, (oy - CAP.top) * sy, w * sx, h * sy)
2767
+ const d = g.getImageData(0, 0, c.width, c.height).data
2768
+ let R = 0, G = 0, B = 0, A = 0
2769
+ 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 }
2770
+ return A > 0 ? [R / A, G / A, B / A, A / (d.length / 4)] : [0, 0, 0, 0]
2771
+ } catch { return null }
2772
+ }
2773
+ let lastTop = null
2774
+ const postTop = () => { const dk = topDark(); if (dk !== lastTop) { lastTop = dk; post({ type: 'top', dark: dk }) } }
2775
+ // 顶栏的颜色会变:切主题 / 调色的过渡放完、滚动后顶栏变实色、点了换状态、窗口换尺寸(媒体查询)、图片换了——过一下再量。
2776
+ // 节流:一直滚也每 120ms 量一次;这段时间里又有动静就再补量一次(页面在 rAF 里换样式的也赶得上)
2777
+ let topTimer = 0
2778
+ let topAgain = false
2779
+ const queueTop = () => {
2780
+ if (topTimer) { topAgain = true; return }
2781
+ const run = () => { postTop(); if (topAgain) { topAgain = false; topTimer = setTimeout(run, 120) } else topTimer = 0 }
2782
+ topTimer = setTimeout(run, 120)
2783
+ }
2784
+ // 图片的 load 传不到 window(load 事件的路径到 document 为止),所以挂在 document 上;resize 只在 window 上
2785
+ for (const ev of ['transitionend', 'animationend', 'scroll', 'click', 'load', 'error', 'loadeddata']) document.addEventListener(ev, queueTop, { capture: true, passive: true })
2786
+ addEventListener('resize', queueTop, { passive: true })
2787
+ try { matchMedia('(prefers-color-scheme: dark)').addEventListener('change', queueTop) } catch { /* 老浏览器 */ } // 系统换了深浅色
2788
+
2789
+ /** 无头浏览器截图用:右上角画上微信的胶囊(真机上它盖在页面上面)。ghost 半透明,透出下面被挡住的东西;别的值撤掉 */
2790
+ let capEl = null
2791
+ function showCapsule(how) {
2792
+ if (capEl) { capEl.remove(); capEl = null }
2793
+ if (how !== 'solid' && how !== 'ghost') return null
2794
+ const white = topDark()
2795
+ const fg = white ? '#fff' : '#000'
2796
+ const soft = white ? 'rgba(255,255,255,.25)' : 'rgba(0,0,0,.15)'
2797
+ const h = document.createElement('dsh-design-fx')
2798
+ 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}`
2799
+ h.attachShadow({ mode: 'closed' }).innerHTML = `<svg width="${CAP.w}" height="${CAP.h}" viewBox="0 0 ${CAP.w} ${CAP.h}" style="display:block;overflow:visible">` +
2800
+ `<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"/>` +
2801
+ `<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>` +
2802
+ `<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>`
2803
+ document.documentElement.appendChild(h)
2804
+ capEl = h
2805
+ return { white }
2806
+ }
2807
+
2266
2808
  /** 量之前等字体、图片载完(最多等 2 秒):刚载入的页面字体没到时字宽不对,断行、截断都会量错 */
2267
2809
  async function settle() {
2268
2810
  const wait = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))])
@@ -2444,13 +2986,13 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2444
2986
  else if (d.type === 'check') onCheck(d)
2445
2987
  else if (d.type === 'checkClear') { clearMarks(); lastIssues = [] }
2446
2988
  else if (d.type === 'checkFocus') focusIssue(Number(d.i))
2447
- else if (d.type === 'theme') setDark(d.dark)
2989
+ else if (d.type === 'theme') { setDark(d.dark); postTop(); setTimeout(postTop, 400) }
2448
2990
  else if (d.type === 'demo') demo = !!d.on
2449
2991
  else if (d.type === 'edit') setTextEdit(!!d.on, d.hint !== false)
2450
2992
  else if (d.type === 'styleMode') setStyleMode(!!d.on)
2451
2993
  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)
2994
+ else if (d.type === 'style') { styleApply(d.css); queueTop() }
2995
+ else if (d.type === 'tweak') { applyTweak(d.vars, d.attrs); queueTop() }
2454
2996
  })
2455
2997
 
2456
2998
  // 无头浏览器(check_design / look_design 在电脑上另开的,见 browser.js)直接调这几个:
@@ -2469,6 +3011,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2469
3011
  return { issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics }
2470
3012
  },
2471
3013
  dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
3014
+ capsule: (how) => showCapsule(how),
2472
3015
  /** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
2473
3016
  tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
2474
3017
  settle: () => settle().then(() => true),
@@ -2476,7 +3019,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
2476
3019
  links: () => linksOf().map(({ path, text }) => ({ path, text })),
2477
3020
  info: () => {
2478
3021
  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 }
3022
+ 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
3023
  },
2481
3024
  /** 'end':所有能滚的地方都滚到底;数字 i:主要的那块滚到第 i 屏(相邻两屏留出顶栏、底栏的高度,栏后面的内容下一屏能看到) */
2482
3025
  // 滚完等页面跟上再截图