dsh-design-studio 0.8.0 → 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/README.en.md +1 -1
- package/README.md +1 -1
- package/cordis.patch.yml +6 -4
- package/lib/browser.js +34 -0
- package/lib/client.js +334 -46
- package/lib/core.js +4 -3
- package/lib/handoff.js +7 -0
- package/lib/index.js +1 -1
- package/lib/look.js +19 -4
- package/lib/picker.js +407 -34
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +4 -3
- package/skills/ui-design/references/app.md +1 -1
- package/skills/ui-design/references/desktop.md +24 -0
- package/skills/ui-design/references/miniprogram.md +36 -0
package/lib/picker.js
CHANGED
|
@@ -1659,6 +1659,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1659
1659
|
// contrast 对比度不够 · tap 点击区太小 · tiny 字太小 · img 图片没加载出来 · fonts 字号太杂
|
|
1660
1660
|
// covered 被固定栏压住(滚到底也看不全) · tight 离固定栏太近 · jserr 页面脚本报错 · missing 样式表 / 脚本 / 字体没载出来
|
|
1661
1661
|
// capsule 小程序右上角的胶囊(微信画的,页面盖不住)挡住了字或按钮
|
|
1662
|
+
// island 灵动岛挡住了字或按钮 · homebar 底栏里的字、图标伸进底部横条区 · tabbar 标签栏超过 5 项 · navbar 小程序这屏没有导航栏
|
|
1663
|
+
// winctl 桌面软件的窗口按钮(Mac 红绿灯 / Windows 右上角三个)挡住了字或按钮
|
|
1662
1664
|
// 有意为之的不报:省略号(text-overflow: ellipsis)、行数截断(line-clamp)、可以滚动的区域(轮播)、很淡的装饰大字。
|
|
1663
1665
|
|
|
1664
1666
|
const CHECK_PER = 8 // 每类最多报几条
|
|
@@ -1667,6 +1669,13 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1667
1669
|
const CLICKABLE = 'a[href],button,[role="button"],[role="tab"],[role="link"],input:not([type="hidden"]),select,textarea,summary,[onclick],[data-href]'
|
|
1668
1670
|
// 小程序右上角的胶囊:离屏幕顶 48(状态栏 44 + 4)、离右边 7、87×32(和右侧栏外框画的一样)
|
|
1669
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 }
|
|
1670
1679
|
let checkMarks = []
|
|
1671
1680
|
|
|
1672
1681
|
const isOurs = (el) => SIG_SKIP.has(el.tagName)
|
|
@@ -1941,15 +1950,17 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1941
1950
|
/** 滚完等两帧、再等新露出来的图和字体(页面的滚动监听、懒加载的图有机会跟上),再截图 */
|
|
1942
1951
|
const afterScroll = (v) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(v)))).then((v2) => settle().then(() => v2, () => v2))
|
|
1943
1952
|
|
|
1944
|
-
function barsAt(edge) {
|
|
1953
|
+
function barsAt(edge, inflow = false) {
|
|
1945
1954
|
const out = []
|
|
1946
1955
|
const vw = innerWidth, vh = innerHeight
|
|
1956
|
+
// inflow:页面本身不滚(flex / grid 撑满一屏、中间内容区自己滚)时,贴着屏幕边的普通元素也算栏
|
|
1957
|
+
const shell = inflow && (document.scrollingElement || document.documentElement).scrollHeight <= vh + 2
|
|
1947
1958
|
const all = document.body.getElementsByTagName('*')
|
|
1948
1959
|
for (let i = 0; i < all.length; i++) {
|
|
1949
1960
|
const el = all[i]
|
|
1950
1961
|
if (isOurs(el)) continue
|
|
1951
1962
|
const pos = getComputedStyle(el).position
|
|
1952
|
-
if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute') continue
|
|
1963
|
+
if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute' && !shell) continue
|
|
1953
1964
|
if (out.some((b) => b.el.contains(el))) continue // 栏里面的东西
|
|
1954
1965
|
const r = el.getBoundingClientRect()
|
|
1955
1966
|
if (r.width < vw * 0.5 || r.height < 16 || r.height > vh * BAR_MAX) continue
|
|
@@ -2299,7 +2310,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2299
2310
|
|
|
2300
2311
|
// 4. 对比度(WCAG:正文 4.5,大字 3)。取底色那段装一次「量画着什么」的临时样式(见 withProbe),量完撤掉
|
|
2301
2312
|
const sizes = new Map()
|
|
2302
|
-
const tinyMin = kind === 'app' || kind === 'miniprogram' ? 10 : kind === 'tablet' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
|
|
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
|
|
2303
2314
|
let pool = null
|
|
2304
2315
|
const poolOf = () => pool || (pool = paintPool()) // 有字不在屏幕里才建
|
|
2305
2316
|
withProbe(() => { for (const a of atoms) {
|
|
@@ -2325,7 +2336,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2325
2336
|
} })
|
|
2326
2337
|
|
|
2327
2338
|
// 5. 点击区太小(App / 小程序 / 平板 44,网页 24;文字中间的链接不算)
|
|
2328
|
-
const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
|
|
2339
|
+
const minTap = kind === 'app' || kind === 'miniprogram' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' || kind === 'desktop' ? 24 : 0
|
|
2329
2340
|
if (minTap) {
|
|
2330
2341
|
const small = []
|
|
2331
2342
|
for (const el of body.querySelectorAll(CLICKABLE)) {
|
|
@@ -2419,6 +2430,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2419
2430
|
|
|
2420
2431
|
// 13. 小程序:右上角胶囊挡住的字和按钮。不固定的按没滚时的位置算(滚下去本来就会从胶囊下面过)
|
|
2421
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)
|
|
2422
2437
|
|
|
2423
2438
|
issues.sort((x, y) => y.sev - x.sev)
|
|
2424
2439
|
const se0 = se()
|
|
@@ -2432,29 +2447,45 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2432
2447
|
* 元素在「各层都没滚」时露出来的那块(视口坐标):窗口、内部滚动区滚过的量加回去,被外层裁掉的去掉。
|
|
2433
2448
|
* 滚动量、边框都按那层实际的缩放(transform、zoom)换成视口像素;裁剪按 padding box、只裁 overflow 不是 visible 的那个方向。
|
|
2434
2449
|
* 绝对 / 固定定位的只受包含块和更外面的管;固定 / 吸顶的那层按现在看到的位置算,它外面的滚动不管、裁剪照算。全被裁掉返回 null。
|
|
2435
|
-
* now:按现在的滚动位置算(只去掉裁掉的部分)。upto
|
|
2450
|
+
* now:按现在的滚动位置算(只去掉裁掉的部分)。upto:只算到包着它的那层为止(更外面的裁剪不管)。
|
|
2451
|
+
* pos:量的是 el 的伪元素,它自己的定位方式(绝对定位的从 el 起只受包含块和更外面的裁,跳过中间不包它的层)
|
|
2436
2452
|
*/
|
|
2437
|
-
function restBox(el, now = false, upto = null) {
|
|
2453
|
+
function restBox(el, now = false, upto = null, self = false, pos = '') {
|
|
2438
2454
|
const chain = []
|
|
2439
|
-
let mode = ''
|
|
2440
|
-
let pinned =
|
|
2455
|
+
let mode = pos === 'absolute' || pos === 'fixed' ? pos : ''
|
|
2456
|
+
let pinned = pos === 'fixed'
|
|
2441
2457
|
for (let e = el; e && e !== document.documentElement; e = e.parentElement) {
|
|
2442
2458
|
if (upto && e !== el && e.contains(upto)) break
|
|
2443
2459
|
const cs = getComputedStyle(e)
|
|
2444
|
-
if (e !== el) {
|
|
2445
|
-
|
|
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
|
+
}
|
|
2446
2468
|
const r = e.getBoundingClientRect()
|
|
2447
2469
|
const kx = e.offsetWidth ? r.width / e.offsetWidth : 1
|
|
2448
2470
|
const ky = e.offsetHeight ? r.height / e.offsetHeight : 1
|
|
2449
|
-
const
|
|
2450
|
-
const
|
|
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
|
|
2451
2485
|
chain.push({
|
|
2452
2486
|
sx: pinned || now ? 0 : e.scrollLeft * kx,
|
|
2453
2487
|
sy: pinned || now ? 0 : e.scrollTop * ky,
|
|
2454
|
-
clip
|
|
2455
|
-
l: cx ? r.left + e.clientLeft * kx : -Infinity, r: cx ? r.left + (e.clientLeft + e.clientWidth) * kx : Infinity,
|
|
2456
|
-
t: cy ? r.top + e.clientTop * ky : -Infinity, b: cy ? r.top + (e.clientTop + e.clientHeight) * ky : Infinity,
|
|
2457
|
-
} : null,
|
|
2488
|
+
clip,
|
|
2458
2489
|
})
|
|
2459
2490
|
}
|
|
2460
2491
|
if (cs.position === 'fixed' || cs.position === 'sticky') pinned = true
|
|
@@ -2476,30 +2507,340 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2476
2507
|
}
|
|
2477
2508
|
}
|
|
2478
2509
|
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
const ow = Math.min(r.r, cap.r) - Math.max(r.l, cap.l)
|
|
2485
|
-
const oh = Math.min(r.b, cap.b) - Math.max(r.t, cap.t)
|
|
2486
|
-
return ow > 2 && oh > 2 ? Math.round(Math.min(ow, r.r - cap.l)) : 0
|
|
2487
|
-
}
|
|
2510
|
+
/**
|
|
2511
|
+
* 盖在页面上面、页面盖不住的一块(胶囊、灵动岛):可点的东西和字横竖都伸进去 2px 以上就报。
|
|
2512
|
+
* 按「各层都没滚」时露出来的那块量(不固定的滚下去本来就会从下面过);px(o, ow, oh) 算报多少
|
|
2513
|
+
*/
|
|
2514
|
+
function zoneCheck(add, atoms, zone, type, px, extra) {
|
|
2488
2515
|
const hit = new Set()
|
|
2489
|
-
const
|
|
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) => {
|
|
2490
2523
|
if (hiddenChain(el) || [...hit].some((h) => h.contains(el) || el.contains(h))) return
|
|
2491
|
-
const rest = restBox(el)
|
|
2492
|
-
const
|
|
2493
|
-
if (!
|
|
2524
|
+
const rest = restBox(el, false, null, self)
|
|
2525
|
+
const n = Math.max(0, ...rects.map((r) => into(rest(r))))
|
|
2526
|
+
if (!n) return
|
|
2494
2527
|
hit.add(el)
|
|
2495
|
-
add(
|
|
2528
|
+
add(type, el, { px: n, ...extra }, 2)
|
|
2496
2529
|
}
|
|
2497
2530
|
for (const el of document.body.querySelectorAll(CLICKABLE)) {
|
|
2498
2531
|
if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
2499
2532
|
const r = el.getBoundingClientRect()
|
|
2500
2533
|
if (r.width >= 1 && r.height >= 1) check(el, [{ l: r.left, r: r.right, t: r.top, b: r.bottom }])
|
|
2501
2534
|
}
|
|
2502
|
-
for (const a of atoms) check(a.el, a.rects)
|
|
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)
|
|
2503
2844
|
}
|
|
2504
2845
|
|
|
2505
2846
|
/**
|
|
@@ -2563,8 +2904,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2563
2904
|
const m = document.querySelector('meta[name="dsh-capsule"]')
|
|
2564
2905
|
const say = m ? String(m.getAttribute('content') || '').trim().toLowerCase() : ''
|
|
2565
2906
|
if (say === 'white' || say === 'black') return say === 'white'
|
|
2566
|
-
|
|
2567
|
-
const
|
|
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
|
|
2568
2911
|
const hits = paintedAt(x, y).filter((e) => !isOurs(e) && !(e instanceof SVGElement)).reverse() // 从下往上
|
|
2569
2912
|
const { base, onCanvas } = canvasBase()
|
|
2570
2913
|
const layers = []
|
|
@@ -2635,7 +2978,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2635
2978
|
function holds(cs, fixed) {
|
|
2636
2979
|
return (!fixed && cs.position !== 'static') || moved(cs) || cs.perspective !== 'none' || cs.filter !== 'none' ||
|
|
2637
2980
|
(!!cs.backdropFilter && cs.backdropFilter !== 'none') || /paint|layout|strict|content/.test(cs.contain) ||
|
|
2638
|
-
/transform|scale|translate|rotate|perspective|filter/.test(cs.willChange)
|
|
2981
|
+
/transform|scale|translate|rotate|perspective|filter|contain/.test(cs.willChange)
|
|
2639
2982
|
}
|
|
2640
2983
|
/** 变换过的:transform,或单独写的 scale / translate / rotate */
|
|
2641
2984
|
function moved(cs) {
|
|
@@ -2805,6 +3148,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
2805
3148
|
return { white }
|
|
2806
3149
|
}
|
|
2807
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
|
+
|
|
2808
3180
|
/** 量之前等字体、图片载完(最多等 2 秒):刚载入的页面字体没到时字宽不对,断行、截断都会量错 */
|
|
2809
3181
|
async function settle() {
|
|
2810
3182
|
const wait = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))])
|
|
@@ -3012,6 +3384,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
3012
3384
|
},
|
|
3013
3385
|
dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
|
|
3014
3386
|
capsule: (how) => showCapsule(how),
|
|
3387
|
+
winctl: (how) => showWinCtl(how),
|
|
3015
3388
|
/** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
|
|
3016
3389
|
tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
|
|
3017
3390
|
settle: () => settle().then(() => true),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 界面设计(配合右侧「设计工作台」)
|
|
2
2
|
|
|
3
|
-
做网页、App
|
|
3
|
+
做网页、App 界面、微信小程序、平板界面、桌面软件、海报 / 社媒图、幻灯片、邮件、组件这类**看得见的设计**时照这份来。目标:一眼看上去像专业设计师做的,不像模板,也没有「AI 味」。
|
|
4
4
|
|
|
5
5
|
> 不归这里管:用户只是要把回答做成一页看或用(对比、表格、清单、图表、计算器、学习卡片),而你有 `show_page` 工具——直接调 `show_page`,不用这份流程、不写文件、不开预览。
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
- **什么时候跳过**:改已有的设计;用户说了「直接做」「不用问」;或者需求已经够具体(做什么、给谁用、什么风格或配色、要哪几屏都说了)。这时不问、也不出三个方向,第一句话说清你按什么理解开工(「按温暖手作风格做首页、详情、购物车三屏」),直接从第 1 步做全套。拿不准算不算够具体,就只问缺的那一两项。
|
|
12
12
|
|
|
13
|
-
-
|
|
13
|
+
- **问**:只问最关键的两三个问题,一次问完:给谁用、想要什么感觉(给三个具体选项,从工作台的风格卡里挑最合场景的三个:极简白、温暖手作、杂志编辑、深色高级、活泼年轻、清新自然、专业科技、玻璃质感、柔和立体、国潮、中式雅致、赛博朋克、可爱卡通、复古、电商促销、日系原木、瑞士网格、粗野主义、奶油风、运动能量)、有没有必须有的内容或品牌色。有 `ask_user_question` 工具就用它问(带选项),没有就在回复里问完、停下等回答。用户说「你定」就按场景替他定。
|
|
14
14
|
- **用户有默认的品牌设计系统**(这份技能末尾「设计系统」一节写着):感觉已经定了,照那一节做,不问感觉、不推风格卡。
|
|
15
15
|
- **用户点了风格卡**(消息以〔风格〕开头,带着配色、字体、形状和要点):感觉已经定了,不用再问感觉,只问还缺的(给谁用、必须有的内容);三个方向都按这套配色和字体来,在版式和信息重点上拉开。
|
|
16
16
|
- **出三个方向**:先只做最能代表这套设计的一屏(通常是首页或封面),做三个明显不同的方向:`home-a.html`、`home-b.html`、`home-c.html`。用户已经挑了感觉(比如「温暖手作」),三个方向就都在这个感觉里拉开:版式、信息重点、字体搭配、配色深浅各不相同(比如「大图海报式 / 卡片列表式 / 一步下单式」),不要再出两个他没选的风格;没挑或说「你定」,三个方向就是三种不同的感觉。不管哪种,都不是同一个版式换颜色。这时还不建 `tokens.css`,每个方向的设计变量写在自己文件的 `<style>` 里(`:root{…}`)。`<title>` 写成「方向 A · 温暖手作」这样,画布上一眼分得清。写完第一个就调 `preview_page`,另外两个会并排出现。
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
| `app` | 390×844(iPhone) | 手机 + 灵动岛 | `references/app.md` |
|
|
25
25
|
| `miniprogram` | 375×812(微信小程序) | 手机 + 右上角胶囊 | `references/miniprogram.md` |
|
|
26
26
|
| `tablet` | 820×1180 | 平板 | `references/app.md` 末尾 |
|
|
27
|
+
| `desktop` | 1280×800(Mac / Windows 软件窗口) | 窗口 + 左上角红绿灯(Windows 是右上角三个按钮) | `references/desktop.md` |
|
|
27
28
|
| `poster` | 1080×1350(4:5),可改 1080×1920 / 1080×1080 | 卡片 | `references/poster.md` |
|
|
28
29
|
| `slides` | 1280×720(16:9) | 卡片 | `references/slides.md` |
|
|
29
30
|
| `email` | 600 宽 | 卡片 | `references/email.md` |
|
|
@@ -35,7 +36,7 @@
|
|
|
35
36
|
3. **一屏一个 HTML 文件,放同一个目录**,按用户会看的顺序一屏一屏写(画布按创建顺序排:登录 → 首页 → 详情 → …)。文件名用英文小写(`login.html`、`home.html`),`<title>` 写「屏名 · 产品名」,用界面的语言(如「首页 · 梅林练」「Home · Pedal」,每屏的产品名写成一样的):画布上只显示屏名,导出整套展示图时产品名做大标题。临时试验的页面用 `_` 开头(`_test.html`),不会上画布。每屏 `<head>` 里写:
|
|
36
37
|
|
|
37
38
|
```html
|
|
38
|
-
<meta name="dsh-design" content="app"> <!-- 类型;小程序写 miniprogram;海报可带尺寸:poster 1080x1920 -->
|
|
39
|
+
<meta name="dsh-design" content="app"> <!-- 类型;小程序写 miniprogram;Windows 软件写 desktop windows;海报可带尺寸:poster 1080x1920 -->
|
|
39
40
|
```
|
|
40
41
|
|
|
41
42
|
**几屏共用的东西只写一份**:样式放 `tokens.css` 和一个公共的 `app.css`;图标、书封这类 SVG 放进一个 `icons.svg`(每个图标一个 `<symbol id="…">`),页面里用 `<svg><use href="icons.svg#cart"></use></svg>` 引用——换风格时改一处所有屏一起变,不会漏改。状态栏、标签栏这种 HTML 结构每屏照抄一份,保持一模一样。
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- 手机按 390×844(iPhone 15/16)设计,页面宽度跟着视口走(`width: 100%`),不要居中一个小盒子。`<meta name="viewport" content="width=device-width,initial-scale=1">`。别写死 `width: 390px`、也别给 body 加 `max-width` 再居中:工作台已经按 390 宽显示、套好手机外框,多尺寸视图还会拿 375 / 430 宽来看,写死就露馅。也别为了这个另开浏览器截图——要检查就调 `check_design`。
|
|
6
6
|
- 顶部 54px 是状态栏 + 灵动岛:自己画一条状态栏(时间 9:41、信号、电量),中间空着——灵动岛预览的外框会画,页面里别再画一个(不然换尺寸看时会出现两个)。内容从 54px 以下开始。
|
|
7
|
-
- 底部 34px 是横条(home indicator)区:底部的标签栏 / 按钮要把它让出来(标签栏总高约 83px
|
|
7
|
+
- 底部 34px 是横条(home indicator)区:底部的标签栏 / 按钮要把它让出来(标签栏总高约 83px = 49 + 34)。设计稿里写死 34px:预览里 `env(safe-area-inset-bottom)` 是 0,靠它让不出来;做成真代码时再换成 `env()`。体检会报「伸进底部横条区」。
|
|
8
8
|
- 左右边距 16–24px;卡片之间 12–16px。
|
|
9
9
|
|
|
10
10
|
## 结构和组件
|