dsh-design-studio 0.7.1 → 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/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +2 -2
- package/lib/client.js +169 -37
- package/lib/core.js +4 -3
- package/lib/handoff.js +7 -0
- package/lib/index.js +5 -5
- package/lib/look.js +24 -13
- package/lib/picker.js +576 -33
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/skills/ui-design/SKILL.md +3 -2
- package/skills/ui-design/references/miniprogram.md +27 -0
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) || (
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
1702
|
-
const stack =
|
|
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)
|
|
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
|
|
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]
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
1726
|
-
return {
|
|
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
|
-
|
|
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 =
|
|
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
|
// 滚完等页面跟上再截图
|