dsh-design-studio 0.4.0 → 0.5.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 +13 -7
- package/README.md +33 -11
- package/cordis.patch.yml +1 -1
- package/lib/browser.js +251 -0
- package/lib/bundle.js +228 -0
- package/lib/client.js +1074 -41
- package/lib/core.js +233 -21
- package/lib/doc.js +356 -0
- package/lib/index.js +428 -21
- package/lib/look.js +550 -0
- package/lib/picker.js +1118 -24
- package/lib/preset.js +20 -3
- package/lib/serve.js +23 -7
- package/lib/tool.js +6 -3
- package/locale/tool/en.json +2 -2
- package/locale/tool/zh.json +2 -2
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +40 -2
- package/skills/ui-design/references/app.md +1 -1
package/lib/picker.js
CHANGED
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
const parentOrigins = window.parent === window ? [] : anc ? (allowed.some(fitsParent) ? [anc] : []) : allowed.filter((o) => !o.endsWith(':*'))
|
|
30
30
|
const parentWin = parentOrigins.length ? window.parent : null
|
|
31
31
|
const SEED_KEY = '__dsh_design_seed__'
|
|
32
|
+
// 远程看(右侧栏把这一屏打包放进沙盒 iframe 的 srcdoc,location 是 about:srcdoc):这一屏原本的地址由右侧栏给
|
|
33
|
+
const HERE = cfg.remote && typeof cfg.href === 'string' ? cfg.href : ''
|
|
34
|
+
const here = () => HERE || location.href
|
|
35
|
+
const hereUrl = () => { try { return new URL(here()) } catch { return location } }
|
|
36
|
+
/** 样式表从哪来:打包时内联成 <style data-href> 的,按原来的文件算 */
|
|
37
|
+
const sheetHref = (sheet) => {
|
|
38
|
+
if (sheet.href) return sheet.href
|
|
39
|
+
const o = sheet.ownerNode
|
|
40
|
+
const d = o && o.getAttribute ? o.getAttribute('data-href') : ''
|
|
41
|
+
if (d && HERE) { try { return new URL(d, HERE).href } catch { /* 用这一页 */ } }
|
|
42
|
+
return here()
|
|
43
|
+
}
|
|
32
44
|
|
|
33
45
|
function post(msg) {
|
|
34
46
|
if (!parentWin) return
|
|
@@ -305,7 +317,7 @@
|
|
|
305
317
|
|
|
306
318
|
function onMove(e) {
|
|
307
319
|
e.stopPropagation()
|
|
308
|
-
if (down && (dragging || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 6)) {
|
|
320
|
+
if (down && !styleOn && (dragging || Math.hypot(e.clientX - down.x, e.clientY - down.y) > 6)) {
|
|
309
321
|
dragging = true
|
|
310
322
|
const b = regionBox(e)
|
|
311
323
|
place(b, 'region')
|
|
@@ -327,6 +339,14 @@
|
|
|
327
339
|
const b = wasDrag ? regionBox(e) : null
|
|
328
340
|
down = null
|
|
329
341
|
dragging = false
|
|
342
|
+
if (styleOn) {
|
|
343
|
+
// 调样式:选中它(留虚线框),接着还能点别的
|
|
344
|
+
const el = under(e.clientX, e.clientY)
|
|
345
|
+
if (!el || el === document.body) return
|
|
346
|
+
styleSelect(el)
|
|
347
|
+
post({ type: 'stylePick', payload: { ...styleInfo(el), href: here() } })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
330
350
|
let payload
|
|
331
351
|
if (wasDrag && b.width > 6 && b.height > 6) {
|
|
332
352
|
const els = elementsIn(b)
|
|
@@ -345,7 +365,7 @@
|
|
|
345
365
|
pin(null, el, nameOf(el))
|
|
346
366
|
}
|
|
347
367
|
stop(false)
|
|
348
|
-
post({ type: 'picked', payload: { ...payload, href:
|
|
368
|
+
post({ type: 'picked', payload: { ...payload, href: here(), title: document.title, viewport: { w: innerWidth, h: innerHeight } } })
|
|
349
369
|
}
|
|
350
370
|
|
|
351
371
|
function onKey(e) {
|
|
@@ -358,7 +378,7 @@
|
|
|
358
378
|
function start(hint = true) {
|
|
359
379
|
if (picking) return
|
|
360
380
|
picking = true
|
|
361
|
-
unpin()
|
|
381
|
+
if (!styleOn) unpin()
|
|
362
382
|
mount(hint)
|
|
363
383
|
addEventListener('keydown', onKey, true)
|
|
364
384
|
}
|
|
@@ -370,7 +390,7 @@
|
|
|
370
390
|
removeEventListener('keydown', onKey, true)
|
|
371
391
|
if (cancelled) {
|
|
372
392
|
unmount()
|
|
373
|
-
post({ type: 'cancelled' })
|
|
393
|
+
post({ type: 'cancelled', style: styleOn }) // style:调样式里按了 Esc(右侧栏退出调样式)
|
|
374
394
|
return
|
|
375
395
|
}
|
|
376
396
|
if (ui) {
|
|
@@ -481,6 +501,19 @@
|
|
|
481
501
|
animation:pin-in .25s ease-out}
|
|
482
502
|
.pin span{opacity:.92}
|
|
483
503
|
@keyframes pin-in{from{opacity:0}}
|
|
504
|
+
.ck{position:absolute;pointer-events:none;border:calc(2px * var(--k, 1)) solid #f59e0b;border-radius:4px;background:rgba(245,158,11,.08)}
|
|
505
|
+
.ck.hi{border-color:#e5484d;background:rgba(229,72,77,.08)}
|
|
506
|
+
.ck b{position:absolute;left:calc(-9px * var(--k, 1));top:calc(-9px * var(--k, 1));min-width:18px;height:18px;padding:0 4px;box-sizing:border-box;border-radius:9px;
|
|
507
|
+
background:#f59e0b;color:#fff;font:700 11px/18px -apple-system,BlinkMacSystemFont,system-ui,sans-serif;text-align:center;
|
|
508
|
+
transform:scale(var(--k, 1));transform-origin:0 0;box-shadow:0 2px 6px rgba(0,0,0,.25)}
|
|
509
|
+
.ck.hi b{background:#e5484d}
|
|
510
|
+
.ck.on{animation:ck-on 1.2s ease-out}
|
|
511
|
+
.cut{position:absolute;pointer-events:none;border:calc(2px * var(--k, 1)) solid #e5484d;border-radius:4px;box-shadow:0 0 0 1px rgba(255,255,255,.85)}
|
|
512
|
+
.cut.alt{border-color:#f59e0b;border-style:dashed}
|
|
513
|
+
.cut b{position:absolute;left:calc(-2px * var(--k, 1));bottom:100%;margin-bottom:calc(3px * var(--k, 1));padding:0 6px;border-radius:4px;background:#e5484d;color:#fff;
|
|
514
|
+
font:600 12px/18px -apple-system,BlinkMacSystemFont,system-ui,sans-serif;white-space:nowrap;transform:scale(var(--k, 1));transform-origin:0 100%}
|
|
515
|
+
.cut.low b{bottom:auto;top:100%;margin:calc(3px * var(--k, 1)) 0 0;transform-origin:0 0}
|
|
516
|
+
@keyframes ck-on{0%,40%{box-shadow:0 0 0 calc(6px * var(--k, 1)) rgba(229,72,77,.35)}100%{box-shadow:0 0 0 0 rgba(229,72,77,0)}}
|
|
484
517
|
.scan{position:fixed;left:0;right:0;top:0;height:38vh;pointer-events:none;opacity:0;
|
|
485
518
|
background:linear-gradient(180deg,rgba(77,107,254,0),rgba(77,107,254,.06) 55%,rgba(124,140,255,.22) 92%,rgba(77,107,254,0));
|
|
486
519
|
animation:scan var(--dur, 1.2s) cubic-bezier(.45,0,.25,1) forwards}
|
|
@@ -647,19 +680,19 @@
|
|
|
647
680
|
let pending = null
|
|
648
681
|
let busy = false
|
|
649
682
|
|
|
650
|
-
const seedNow = () => ({ href:
|
|
683
|
+
const seedNow = () => ({ href: here(), scroll: { x: scrollX, y: scrollY }, sig: snapshot().map })
|
|
651
684
|
|
|
652
685
|
function boot(seed) {
|
|
653
686
|
if (booted) return
|
|
654
687
|
booted = true
|
|
655
|
-
const same = seed && seed.href ===
|
|
688
|
+
const same = seed && seed.href === here()
|
|
656
689
|
if (same && seed.scroll) scrollTo(seed.scroll.x, seed.scroll.y)
|
|
657
690
|
pending = same && seed.sig ? compare(seed.sig, snapshot()) : null
|
|
658
691
|
const meta = document.querySelector('meta[name="dsh-design"]')
|
|
659
692
|
post({
|
|
660
693
|
type: 'ready',
|
|
661
694
|
title: document.title,
|
|
662
|
-
href:
|
|
695
|
+
href: here(),
|
|
663
696
|
meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '',
|
|
664
697
|
tokens: tokensOf(),
|
|
665
698
|
links: firstLinks(),
|
|
@@ -679,7 +712,7 @@
|
|
|
679
712
|
for (const sheet of document.styleSheets) {
|
|
680
713
|
let rules
|
|
681
714
|
try { rules = sheet.cssRules } catch { continue } // 别的源的样式表读不了
|
|
682
|
-
const href = sheet
|
|
715
|
+
const href = sheetHref(sheet)
|
|
683
716
|
for (const r of rules) {
|
|
684
717
|
if (out.length >= MAX_TOKENS) return out
|
|
685
718
|
if (!r.style || !/(^|,)\s*(:root|html)\s*(,|$)/.test(r.selectorText || '')) continue
|
|
@@ -695,6 +728,228 @@
|
|
|
695
728
|
return out
|
|
696
729
|
}
|
|
697
730
|
|
|
731
|
+
// ── 调样式:点一个元素选中,右侧栏拖滑块时先改在它的 inline style 上看效果 ───────────────
|
|
732
|
+
//
|
|
733
|
+
// 写回文件由右侧栏交给服务端:按「第几个这种标签 + id + 类」在源码里找到开头标签,并进 style 属性。
|
|
734
|
+
// 没写回就换一个元素 / 退出:改过的样式还原。
|
|
735
|
+
|
|
736
|
+
let styleOn = false
|
|
737
|
+
let styleEl = null
|
|
738
|
+
let styleOrig = null // 选中时 style 属性的原样(null = 原来没有)
|
|
739
|
+
|
|
740
|
+
const STYLE_KEYS = ['font-size', 'font-weight', 'color', 'background', 'border-radius', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'gap']
|
|
741
|
+
|
|
742
|
+
/** 这些属性在样式表里是不是用设计变量写的(按出现顺序取最后一条,不算优先级,只用来在面板上标出来) */
|
|
743
|
+
function varsOf(el) {
|
|
744
|
+
const out = {}
|
|
745
|
+
const scan = (rules) => {
|
|
746
|
+
for (const r of rules) {
|
|
747
|
+
if (r.cssRules && !r.selectorText) {
|
|
748
|
+
if (!r.media || matchMedia(r.media.mediaText).matches) scan(r.cssRules)
|
|
749
|
+
continue
|
|
750
|
+
}
|
|
751
|
+
if (!r.style || !r.selectorText) continue
|
|
752
|
+
let hit = false
|
|
753
|
+
try { hit = el.matches(r.selectorText) } catch { hit = false }
|
|
754
|
+
if (!hit) continue
|
|
755
|
+
take(r.style)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const take = (style) => {
|
|
759
|
+
for (const k of STYLE_KEYS) {
|
|
760
|
+
const raw = style.getPropertyValue(k) || (k === 'background' ? style.getPropertyValue('background-color') : '') ||
|
|
761
|
+
(k.startsWith('padding-') ? style.getPropertyValue('padding') : '')
|
|
762
|
+
if (!raw) continue
|
|
763
|
+
const m = /var\(\s*(--[\w-]+)/.exec(raw)
|
|
764
|
+
if (m) out[k] = m[1]
|
|
765
|
+
else delete out[k]
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const sheet of document.styleSheets) {
|
|
769
|
+
let rules
|
|
770
|
+
try { rules = sheet.cssRules } catch { continue }
|
|
771
|
+
scan(rules)
|
|
772
|
+
}
|
|
773
|
+
if (el.getAttribute('style')) take(el.style)
|
|
774
|
+
return out
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ── 每项样式现在是哪条规则给的(〔调样式〕交给模型时带上规则和文件行号,模型不用再 grep) ──
|
|
778
|
+
|
|
779
|
+
/** 选择器列表按顶层的逗号拆开(括号、方括号里的逗号不算) */
|
|
780
|
+
function splitList(s) {
|
|
781
|
+
const out = []
|
|
782
|
+
let depth = 0
|
|
783
|
+
let cur = ''
|
|
784
|
+
for (const ch of String(s)) {
|
|
785
|
+
if (ch === '(' || ch === '[') depth++
|
|
786
|
+
else if (ch === ')' || ch === ']') depth--
|
|
787
|
+
if (ch === ',' && !depth) { out.push(cur.trim()); cur = '' } else cur += ch
|
|
788
|
+
}
|
|
789
|
+
if (cur.trim()) out.push(cur.trim())
|
|
790
|
+
return out
|
|
791
|
+
}
|
|
792
|
+
/** 优先级(近似):id × 1e6 + 类 / 属性 / 伪类 × 1e3 + 标签 / 伪元素;:where() 不算,:is / :not / :has 取括号里最高的那个分支 */
|
|
793
|
+
function specOf(sel) {
|
|
794
|
+
let extra = 0
|
|
795
|
+
let s = String(sel).replace(/"[^"]*"|'[^']*'/g, '""').replace(/:where\((?:[^()]|\([^()]*\))*\)/g, '')
|
|
796
|
+
s = s.replace(/:(?:is|not|has|matches)\(((?:[^()]|\([^()]*\))*)\)/g, (_, inner) => { extra += Math.max(0, ...splitList(inner).map(specOf)); return '' })
|
|
797
|
+
const a = (s.match(/#[\w-]+/g) || []).length
|
|
798
|
+
const b = (s.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length
|
|
799
|
+
const c = (s.replace(/\[[^\]]*\]|\([^()]*\)/g, '').match(/(?:^|[\s>+~,])[a-z][\w-]*|::[\w-]+/gi) || []).length
|
|
800
|
+
return a * 1e6 + b * 1e3 + c + extra
|
|
801
|
+
}
|
|
802
|
+
// 面板上的几项各看哪些写法(简写也算)
|
|
803
|
+
const LONGHANDS = {
|
|
804
|
+
'font-size': ['font-size', 'font'], 'font-weight': ['font-weight', 'font'], color: ['color'], background: ['background', 'background-color'],
|
|
805
|
+
'border-radius': ['border-radius', 'border-top-left-radius'], gap: ['gap', 'row-gap', 'column-gap'],
|
|
806
|
+
'padding-top': ['padding', 'padding-top'], 'padding-right': ['padding', 'padding-right'], 'padding-bottom': ['padding', 'padding-bottom'], 'padding-left': ['padding', 'padding-left'],
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* 每项样式(STYLE_KEYS)现在由哪条规则决定:按 !important、优先级、先后挑出胜出的那条,
|
|
810
|
+
* 给 { sel, href, n }(n:同一份样式表里同样选择器的第几条,服务端按它在源码里找行号);
|
|
811
|
+
* 元素上的行内 style 盖过它就是 { inline: true, ...被盖过的那条 }。只看生效的 @media;层(@layer)不细算。
|
|
812
|
+
* keys 默认是调样式面板那几项;体检找原因时只问一项(如 margin-top)
|
|
813
|
+
*/
|
|
814
|
+
function rulesOf(el, keys = STYLE_KEYS) {
|
|
815
|
+
const best = {}
|
|
816
|
+
let order = 0
|
|
817
|
+
// maybe:在 @layer / @container 这类没细算的块里,结论只是「大概」
|
|
818
|
+
const scan = (rules, href, counts, active, maybe) => {
|
|
819
|
+
for (const r of rules) {
|
|
820
|
+
if (r.cssRules && !r.selectorText) {
|
|
821
|
+
let on = active
|
|
822
|
+
let unsure = maybe
|
|
823
|
+
if (r.media) on = on && matchMedia(r.media.mediaText).matches
|
|
824
|
+
else if (typeof CSSSupportsRule !== 'undefined' && r instanceof CSSSupportsRule) on = on && CSS.supports(r.conditionText)
|
|
825
|
+
else unsure = true
|
|
826
|
+
scan(r.cssRules, href, counts, on, unsure)
|
|
827
|
+
continue
|
|
828
|
+
}
|
|
829
|
+
if (!r.style || !r.selectorText) continue
|
|
830
|
+
const n = counts.get(r.selectorText) || 0
|
|
831
|
+
counts.set(r.selectorText, n + 1)
|
|
832
|
+
order++
|
|
833
|
+
if (!active) continue
|
|
834
|
+
let spec = -1
|
|
835
|
+
for (const part of splitList(r.selectorText)) {
|
|
836
|
+
try { if (el.matches(part)) spec = Math.max(spec, specOf(part)) } catch { /* 浏览器不认的选择器 */ }
|
|
837
|
+
}
|
|
838
|
+
if (spec < 0) continue
|
|
839
|
+
for (const k of keys) {
|
|
840
|
+
for (const L of LONGHANDS[k] || [k]) {
|
|
841
|
+
if (!r.style.getPropertyValue(L)) continue
|
|
842
|
+
const imp = r.style.getPropertyPriority(L) === 'important'
|
|
843
|
+
const b = best[k]
|
|
844
|
+
if (!b || imp > b.imp || (imp === b.imp && (spec > b.spec || (spec === b.spec && order >= b.order)))) best[k] = { sel: r.selectorText, href, n, imp, spec, order, maybe }
|
|
845
|
+
break
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// 页面里的几段 <style> 合起来数(服务端在整个 HTML 里按第几条找);外链的样式表各数各的(同一个文件引了两次也不累加)
|
|
851
|
+
const countsOf = new Map()
|
|
852
|
+
for (const sheet of document.styleSheets) {
|
|
853
|
+
if (sheet.disabled) continue
|
|
854
|
+
const mq = sheet.media && sheet.media.mediaText
|
|
855
|
+
if (mq && !matchMedia(mq).matches) continue // media="print" 这种
|
|
856
|
+
let rules
|
|
857
|
+
try { rules = sheet.cssRules } catch { continue }
|
|
858
|
+
const href = sheetHref(sheet)
|
|
859
|
+
const own = sheet.ownerNode
|
|
860
|
+
const external = !!sheet.href || !!(own && own.getAttribute && own.getAttribute('data-href'))
|
|
861
|
+
if (!external && !countsOf.has(href)) countsOf.set(href, new Map())
|
|
862
|
+
scan(rules, href, external ? new Map() : countsOf.get(href), true, false)
|
|
863
|
+
}
|
|
864
|
+
const out = {}
|
|
865
|
+
for (const k of keys) {
|
|
866
|
+
const b = best[k]
|
|
867
|
+
const inl = (LONGHANDS[k] || [k]).find((L) => el.style.getPropertyValue(L))
|
|
868
|
+
if (inl && (!b || !b.imp || el.style.getPropertyPriority(inl) === 'important')) out[k] = { inline: true, ...(b ? { sel: clip(b.sel, 200), href: b.href, n: b.n } : {}) }
|
|
869
|
+
else if (b) out[k] = { sel: clip(b.sel, 200), href: b.href, n: b.n, ...(b.maybe ? { maybe: true } : {}) }
|
|
870
|
+
}
|
|
871
|
+
return out
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** 右侧栏要的:名字、选择器、源码里怎么找到它(loc)、现在的几项样式、它们各来自哪条规则 */
|
|
875
|
+
function styleInfo(el) {
|
|
876
|
+
const cs = getComputedStyle(el)
|
|
877
|
+
const tag = el.tagName.toLowerCase()
|
|
878
|
+
const same = [...document.getElementsByTagName(tag)]
|
|
879
|
+
const css = {}
|
|
880
|
+
for (const k of STYLE_KEYS) css[k] = k === 'background' ? cs.backgroundColor : k === 'border-radius' ? cs.borderTopLeftRadius : cs.getPropertyValue(k)
|
|
881
|
+
return {
|
|
882
|
+
name: nameOf(el),
|
|
883
|
+
selector: cssPath(el),
|
|
884
|
+
text: textOf(el),
|
|
885
|
+
loc: { tag, n: same.indexOf(el), total: same.length, id: el.getAttribute('id') || '', cls: [...el.classList] },
|
|
886
|
+
css,
|
|
887
|
+
vars: varsOf(el),
|
|
888
|
+
rules: rulesOf(el),
|
|
889
|
+
gradient: cs.backgroundImage !== 'none',
|
|
890
|
+
layout: /flex|grid/.test(cs.display),
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function styleRevert() {
|
|
895
|
+
if (!styleEl) return
|
|
896
|
+
if (styleOrig === null) styleEl.removeAttribute('style')
|
|
897
|
+
else styleEl.setAttribute('style', styleOrig)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function styleSelect(el) {
|
|
901
|
+
if (el === styleEl) return
|
|
902
|
+
styleRevert()
|
|
903
|
+
styleEl = el
|
|
904
|
+
styleOrig = el ? el.getAttribute('style') : null
|
|
905
|
+
if (el) pin(null, el, nameOf(el))
|
|
906
|
+
else unpin()
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** css:属性 → 值(这次一共改了哪些);每次从原样重来,去掉的那项就回去了 */
|
|
910
|
+
function styleApply(css) {
|
|
911
|
+
if (!styleEl) return
|
|
912
|
+
styleRevert()
|
|
913
|
+
for (const [k, v] of Object.entries(css || {})) {
|
|
914
|
+
if (!STYLE_KEYS.includes(k) || typeof v !== 'string' || !v) continue
|
|
915
|
+
styleEl.style.setProperty(k, v)
|
|
916
|
+
}
|
|
917
|
+
placePin()
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 收起选取层,不通知右侧栏(模式是右侧栏自己切的) */
|
|
921
|
+
function quietStop() {
|
|
922
|
+
if (!picking) return
|
|
923
|
+
picking = false
|
|
924
|
+
removeEventListener('keydown', onKey, true)
|
|
925
|
+
unmount()
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** 右侧栏说选哪个(新载的一帧、换了一屏):已经选着一个对得上的就不动 */
|
|
929
|
+
function onStyleSel(selector) {
|
|
930
|
+
if (!styleOn) return
|
|
931
|
+
let el = null
|
|
932
|
+
try {
|
|
933
|
+
if (selector && styleEl && styleEl.isConnected && styleEl.matches(selector)) return
|
|
934
|
+
el = selector ? document.querySelector(selector) : null
|
|
935
|
+
} catch { el = null }
|
|
936
|
+
styleSelect(el)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function setStyleMode(on) {
|
|
940
|
+
if (on === styleOn) return
|
|
941
|
+
if (on) {
|
|
942
|
+
if (textEdit) setTextEdit(false)
|
|
943
|
+
quietStop()
|
|
944
|
+
styleOn = true
|
|
945
|
+
start(false)
|
|
946
|
+
} else {
|
|
947
|
+
styleOn = false
|
|
948
|
+
styleSelect(null)
|
|
949
|
+
quietStop()
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
698
953
|
// ── 直接改字:点一下文字就地改,回车写回文件 ─────────────────────────────────
|
|
699
954
|
//
|
|
700
955
|
// 改的是指针下面那一个文字节点(临时包一层可编辑的 span)。写回源码靠「这段文字 + 它是第几次出现」:
|
|
@@ -820,7 +1075,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
820
1075
|
const after = normWs(ed.span.innerText)
|
|
821
1076
|
const text = document.createTextNode(save && after ? ed.raw.replace(ed.raw.trim(), after) : ed.raw)
|
|
822
1077
|
if (ed.span.parentNode) ed.span.parentNode.replaceChild(text, ed.span)
|
|
823
|
-
if (save && after && after !== ed.before) post({ type: 'edited', before: ed.before, after, nth: ed.nth })
|
|
1078
|
+
if (save && after && after !== ed.before) post({ type: 'edited', before: ed.before, after, nth: ed.nth, href: here() })
|
|
824
1079
|
}
|
|
825
1080
|
|
|
826
1081
|
function onEditKey(e) {
|
|
@@ -835,6 +1090,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
835
1090
|
|
|
836
1091
|
function setTextEdit(on, hint = true) {
|
|
837
1092
|
if (on === textEdit) return
|
|
1093
|
+
if (on && styleOn) setStyleMode(false)
|
|
838
1094
|
textEdit = on
|
|
839
1095
|
if (on) {
|
|
840
1096
|
editMount(hint)
|
|
@@ -867,8 +1123,9 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
867
1123
|
raw = raw.trim()
|
|
868
1124
|
if (!raw || raw.startsWith('#') || /^(javascript|mailto|tel|sms|data):/i.test(raw)) return ''
|
|
869
1125
|
let u
|
|
870
|
-
try { u = new URL(raw,
|
|
871
|
-
|
|
1126
|
+
try { u = new URL(raw, here()) } catch { return '' }
|
|
1127
|
+
const me = hereUrl()
|
|
1128
|
+
if (u.origin !== me.origin || u.pathname === me.pathname) return ''
|
|
872
1129
|
try { return decodeURIComponent(u.pathname) } catch { return u.pathname }
|
|
873
1130
|
}
|
|
874
1131
|
|
|
@@ -918,18 +1175,48 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
918
1175
|
}
|
|
919
1176
|
|
|
920
1177
|
// 演示:点了跳页的地方,不在 iframe 里直接跳,告诉右侧栏换一屏(带过场动画)
|
|
1178
|
+
// 远程看也是:沙盒里的页面跳不过去(地址指着电脑本机),由右侧栏打包那一页换上来
|
|
921
1179
|
let demo = false
|
|
922
1180
|
addEventListener('click', (e) => {
|
|
923
|
-
if (!demo || picking) return
|
|
1181
|
+
if ((!demo && !HERE) || picking) return
|
|
924
1182
|
const el = e.target && e.target.closest ? e.target.closest(LINK_SEL) : null
|
|
925
1183
|
if (!el) return
|
|
926
1184
|
const p = linkPath(el)
|
|
927
|
-
if (!p)
|
|
1185
|
+
if (!p) {
|
|
1186
|
+
const raw = String(el.getAttribute('href') || '').trim()
|
|
1187
|
+
if (HERE && raw.startsWith('#')) { e.preventDefault(); jumpTo(raw) }
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
928
1190
|
e.preventDefault()
|
|
929
1191
|
e.stopPropagation()
|
|
930
1192
|
post({ type: 'nav', path: p })
|
|
931
1193
|
}, true)
|
|
932
1194
|
|
|
1195
|
+
/** 页内锚点(远程看时 <base> 指着原地址,#xx 会被当成跳到别的页面) */
|
|
1196
|
+
function jumpTo(hash) {
|
|
1197
|
+
let id = String(hash || '').replace(/^#/, '')
|
|
1198
|
+
try { id = decodeURIComponent(id) } catch { /* 原样 */ }
|
|
1199
|
+
const t = id ? document.getElementById(id) || document.getElementsByName(id)[0] : null
|
|
1200
|
+
if (t) t.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
1201
|
+
else if (!id || id === 'top') scrollTo({ top: 0, behavior: 'smooth' })
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// 远程看:脚本里跳页(location.href = 'x.html'、表单提交)也拦下来
|
|
1205
|
+
if (HERE && window.navigation && typeof navigation.addEventListener === 'function') {
|
|
1206
|
+
navigation.addEventListener('navigate', (e) => {
|
|
1207
|
+
if (!e.cancelable || e.navigationType === 'traverse' || e.downloadRequest) return
|
|
1208
|
+
let u
|
|
1209
|
+
try { u = new URL(e.destination.url) } catch { return }
|
|
1210
|
+
const me = hereUrl()
|
|
1211
|
+
if (u.origin !== me.origin) return // 外网地址:随它
|
|
1212
|
+
e.preventDefault()
|
|
1213
|
+
if (u.pathname === me.pathname) { if (u.hash) jumpTo(u.hash); return }
|
|
1214
|
+
let p = u.pathname
|
|
1215
|
+
try { p = decodeURIComponent(p) } catch { /* 原样 */ }
|
|
1216
|
+
post({ type: 'nav', path: p })
|
|
1217
|
+
})
|
|
1218
|
+
}
|
|
1219
|
+
|
|
933
1220
|
/** 换上来了:刚生成的一屏播生成动画,否则闪改动 */
|
|
934
1221
|
function reveal(opts) {
|
|
935
1222
|
shown = true
|
|
@@ -947,7 +1234,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
947
1234
|
const ours = (n) => n === host || (fx && n === fx.host)
|
|
948
1235
|
|
|
949
1236
|
function watchHmr() {
|
|
950
|
-
if (cfg.live || !shown || !window.MutationObserver) return
|
|
1237
|
+
if (cfg.live || HERE || !shown || !window.MutationObserver) return // 远程看:文件改了右侧栏重新打包
|
|
951
1238
|
if (busy && !mo) {
|
|
952
1239
|
baseline = snapshot().map
|
|
953
1240
|
mo = new MutationObserver((recs) => {
|
|
@@ -979,9 +1266,10 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
979
1266
|
}
|
|
980
1267
|
|
|
981
1268
|
// ── 导出图片:把页面画成 PNG(SVG foreignObject,样式逐个元素内联,图片 / 字体转成 data URL) ──
|
|
982
|
-
// 右侧栏发 { type: 'capture', id, full, scale } → 回 { type: 'captured', id, png: Blob, w, h } 或 { error }
|
|
1269
|
+
// 右侧栏发 { type: 'capture', id, full, scale } → 回 { type: 'captured', id, png: Blob, w, h, links } 或 { error }
|
|
1270
|
+
// links:页面里跳到别的屏的地方,坐标换算到这张图上(导出 PDF / PPT 时做成能点的跳转)
|
|
983
1271
|
|
|
984
|
-
const SKIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'LINK', 'META', 'TITLE', 'BASE', 'DSH-DESIGN-PICKER', 'DSH-DESIGN-FX'])
|
|
1272
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT', 'TEMPLATE', 'LINK', 'META', 'TITLE', 'BASE', 'DSH-DESIGN-PICKER', 'DSH-DESIGN-FX', 'DSH-DESIGN-PROBE'])
|
|
985
1273
|
const SKIP_PROP = /^(--|animation|transition|will-change|cursor|pointer-events|user-select|-webkit-user|caret-color|view-transition|content-visibility)/
|
|
986
1274
|
const MAX_INLINE = 12 * 1024 * 1024 // 图片 + 字体最多内联这么多字节,再多就跳过(不让导出卡死)
|
|
987
1275
|
const XHTML = 'http://www.w3.org/1999/xhtml'
|
|
@@ -993,6 +1281,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
993
1281
|
f.style.cssText = 'position:fixed;left:-99999px;top:0;width:800px;height:600px;border:0;visibility:hidden'
|
|
994
1282
|
document.documentElement.appendChild(f)
|
|
995
1283
|
const doc = f.contentDocument
|
|
1284
|
+
if (!doc) { f.remove(); return shadowSandbox() } // 远程看(页面在沙盒 iframe 里):再建的 iframe 是另一个不透明的源,进不去
|
|
996
1285
|
doc.open()
|
|
997
1286
|
doc.write('<!doctype html><html><head></head><body><svg xmlns="http://www.w3.org/2000/svg"></svg></body></html>')
|
|
998
1287
|
doc.close()
|
|
@@ -1016,6 +1305,35 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1016
1305
|
}
|
|
1017
1306
|
}
|
|
1018
1307
|
|
|
1308
|
+
/** 量默认样式的另一种办法:关闭的 shadow root 里放一个元素(页面样式进不来;宿主 all: initial,继承的属性也是初始值) */
|
|
1309
|
+
function shadowSandbox() {
|
|
1310
|
+
const host = document.createElement('dsh-design-probe')
|
|
1311
|
+
host.style.cssText = 'all:initial;position:fixed;left:-99999px;top:0;width:800px;height:600px;visibility:hidden'
|
|
1312
|
+
document.documentElement.appendChild(host)
|
|
1313
|
+
const root = host.attachShadow({ mode: 'closed' })
|
|
1314
|
+
const body = document.createElement('div')
|
|
1315
|
+
root.appendChild(body)
|
|
1316
|
+
const svg = document.createElementNS(SVGNS, 'svg')
|
|
1317
|
+
body.appendChild(svg)
|
|
1318
|
+
const cache = new Map()
|
|
1319
|
+
return {
|
|
1320
|
+
defaults(el, pseudo) {
|
|
1321
|
+
const key = (el.namespaceURI || '') + '|' + el.localName + (pseudo || '')
|
|
1322
|
+
let map = cache.get(key)
|
|
1323
|
+
if (map) return map
|
|
1324
|
+
const d = document.createElementNS(el.namespaceURI || XHTML, el.localName)
|
|
1325
|
+
;(el.namespaceURI === SVGNS && el.localName !== 'svg' ? svg : body).appendChild(d)
|
|
1326
|
+
const cs = getComputedStyle(d, pseudo || null)
|
|
1327
|
+
map = {}
|
|
1328
|
+
for (let i = 0; i < cs.length; i++) map[cs[i]] = cs.getPropertyValue(cs[i])
|
|
1329
|
+
d.remove()
|
|
1330
|
+
cache.set(key, map)
|
|
1331
|
+
return map
|
|
1332
|
+
},
|
|
1333
|
+
remove() { host.remove() },
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1019
1337
|
function blobToDataUrl(blob) {
|
|
1020
1338
|
return new Promise((resolve, reject) => {
|
|
1021
1339
|
const r = new FileReader()
|
|
@@ -1154,7 +1472,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1154
1472
|
let s = c.getAttribute('style')
|
|
1155
1473
|
for (const p of withUrls) {
|
|
1156
1474
|
const v = cs.getPropertyValue(p)
|
|
1157
|
-
s = s.replace(`${p}:${v};`, `${p}:${await inlineUrls(v,
|
|
1475
|
+
s = s.replace(`${p}:${v};`, `${p}:${await inlineUrls(v, here(), inline)};`)
|
|
1158
1476
|
}
|
|
1159
1477
|
c.setAttribute('style', s)
|
|
1160
1478
|
})())
|
|
@@ -1166,7 +1484,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1166
1484
|
}
|
|
1167
1485
|
if (el.namespaceURI === SVGNS && el.localName === 'image') {
|
|
1168
1486
|
const href = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href')
|
|
1169
|
-
if (href) jobs.push(inline(new URL(href,
|
|
1487
|
+
if (href) jobs.push(inline(new URL(href, here()).href).then((d) => { if (d) c.setAttribute('href', d) }))
|
|
1170
1488
|
}
|
|
1171
1489
|
if (tag === 'INPUT') {
|
|
1172
1490
|
if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) c.setAttribute('checked', '') } else c.setAttribute('value', el.value)
|
|
@@ -1187,7 +1505,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1187
1505
|
const body = styleText(ps, box.defaults(el, pe), urls)
|
|
1188
1506
|
const i = pseudoRules.length
|
|
1189
1507
|
pseudoRules.push(`.${cls}${pe}{${body}}`)
|
|
1190
|
-
if (urls.length) jobs.push(inlineUrls(pseudoRules[i],
|
|
1508
|
+
if (urls.length) jobs.push(inlineUrls(pseudoRules[i], here(), inline).then((t) => { pseudoRules[i] = t }))
|
|
1191
1509
|
}
|
|
1192
1510
|
}
|
|
1193
1511
|
|
|
@@ -1214,7 +1532,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1214
1532
|
for (const sheet of document.styleSheets) {
|
|
1215
1533
|
let rules
|
|
1216
1534
|
try { rules = sheet.cssRules } catch { continue }
|
|
1217
|
-
const base = sheet
|
|
1535
|
+
const base = sheetHref(sheet)
|
|
1218
1536
|
const scan = (list) => {
|
|
1219
1537
|
for (const r of list) {
|
|
1220
1538
|
if (r.cssRules && !(r instanceof CSSFontFaceRule)) { try { scan(r.cssRules) } catch { /* 读不了 */ } continue }
|
|
@@ -1258,12 +1576,714 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1258
1576
|
async function onCapture(d) {
|
|
1259
1577
|
try {
|
|
1260
1578
|
const r = await capture(d)
|
|
1261
|
-
|
|
1579
|
+
// 整页图从文档顶上画起,链接加上滚动距离;只截视口时就是视口里的位置
|
|
1580
|
+
const links = linksOf().map((l) => (d.full ? { ...l, x: l.x + scrollX, y: l.y + scrollY } : l))
|
|
1581
|
+
.filter((l) => l.x < r.w && l.y < r.h && l.x + l.w > 0 && l.y + l.h > 0)
|
|
1582
|
+
post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title, links })
|
|
1262
1583
|
} catch (e) {
|
|
1263
1584
|
post({ type: 'captured', id: d.id, error: String((e && e.message) || e) })
|
|
1264
1585
|
}
|
|
1265
1586
|
}
|
|
1266
1587
|
|
|
1588
|
+
// ── 设计体检:在画好的页面上实测常见毛病,标在页面上,报给右侧栏(给人看的文字由右侧栏按界面语言拼) ──
|
|
1589
|
+
//
|
|
1590
|
+
// hscroll 超出屏幕(会横向滚动 / 被切掉) · clipped 文字被截断 · overflow 文字超出框 · overlap 文字叠在一起
|
|
1591
|
+
// contrast 对比度不够 · tap 点击区太小 · tiny 字太小 · img 图片没加载出来 · fonts 字号太杂
|
|
1592
|
+
// covered 被固定栏压住(滚到底也看不全) · tight 离固定栏太近 · jserr 页面脚本报错 · missing 样式表 / 脚本 / 字体没载出来
|
|
1593
|
+
// 有意为之的不报:省略号(text-overflow: ellipsis)、行数截断(line-clamp)、可以滚动的区域(轮播)、很淡的装饰大字。
|
|
1594
|
+
|
|
1595
|
+
const CHECK_PER = 8 // 每类最多报几条
|
|
1596
|
+
const CHECK_MAX = 40
|
|
1597
|
+
const CHECK_ATOMS = 600 // 最多看多少个带字的元素
|
|
1598
|
+
const CLICKABLE = 'a[href],button,[role="button"],[role="tab"],[role="link"],input:not([type="hidden"]),select,textarea,summary,[onclick],[data-href]'
|
|
1599
|
+
let checkMarks = []
|
|
1600
|
+
|
|
1601
|
+
const isOurs = (el) => SIG_SKIP.has(el.tagName)
|
|
1602
|
+
function hiddenChain(el) {
|
|
1603
|
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
|
|
1604
|
+
const cs = getComputedStyle(e)
|
|
1605
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || +cs.opacity === 0) return true
|
|
1606
|
+
}
|
|
1607
|
+
return false
|
|
1608
|
+
}
|
|
1609
|
+
function animating(el) {
|
|
1610
|
+
for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
|
|
1611
|
+
try { if (e.getAnimations().some((a) => a.playState === 'running')) return true } catch { return false }
|
|
1612
|
+
}
|
|
1613
|
+
return false
|
|
1614
|
+
}
|
|
1615
|
+
function opacityChain(el, stop) {
|
|
1616
|
+
let o = 1
|
|
1617
|
+
for (let e = el; e && e.nodeType === 1 && e !== stop; e = e.parentElement) o *= +getComputedStyle(e).opacity
|
|
1618
|
+
return o
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/** 'rgb(1, 2, 3)' / 'rgba(1, 2, 3, .5)' / 'color(srgb …)' → [r, g, b, a];别的写法(oklch 之类浏览器已经换算过)认不出就 null */
|
|
1622
|
+
function rgba(s) {
|
|
1623
|
+
const m = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(s)
|
|
1624
|
+
if (m) return [+m[1], +m[2], +m[3], m[4] == null ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]]
|
|
1625
|
+
const c = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/.exec(s)
|
|
1626
|
+
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]]
|
|
1627
|
+
return null
|
|
1628
|
+
}
|
|
1629
|
+
const over = (top, under) => {
|
|
1630
|
+
const a = top[3]
|
|
1631
|
+
return [top[0] * a + under[0] * (1 - a), top[1] * a + under[1] * (1 - a), top[2] * a + under[2] * (1 - a), 1]
|
|
1632
|
+
}
|
|
1633
|
+
const lum = (c) => {
|
|
1634
|
+
const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) }
|
|
1635
|
+
return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2])
|
|
1636
|
+
}
|
|
1637
|
+
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
|
1638
|
+
const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
1639
|
+
|
|
1640
|
+
const MEDIA = new Set(['IMG', 'VIDEO', 'CANVAS', 'IFRAME', 'PICTURE', 'OBJECT', 'EMBED'])
|
|
1641
|
+
/**
|
|
1642
|
+
* 字背后的底色:从字往下一层层叠背景色,遇到不透明的为止;中间有背景图、渐变、图片视频就说不准,返回 null。
|
|
1643
|
+
* 字在屏幕里时按实际叠放顺序找(字下面压着的可能不是祖先,比如首屏背景视频);不在屏幕里就顺着祖先找
|
|
1644
|
+
*/
|
|
1645
|
+
function backdrop(el, pt) {
|
|
1646
|
+
let chain = null
|
|
1647
|
+
if (pt && pt.x >= 0 && pt.y >= 0 && pt.x < innerWidth && pt.y < innerHeight) {
|
|
1648
|
+
const stack = document.elementsFromPoint(pt.x, pt.y)
|
|
1649
|
+
const i = stack.indexOf(el)
|
|
1650
|
+
if (i >= 0) chain = stack.slice(i)
|
|
1651
|
+
}
|
|
1652
|
+
if (!chain) { chain = []; for (let e = el; e && e.nodeType === 1; e = e.parentElement) chain.push(e) }
|
|
1653
|
+
const layers = []
|
|
1654
|
+
let base = null
|
|
1655
|
+
for (const e of chain) {
|
|
1656
|
+
if (isOurs(e)) continue
|
|
1657
|
+
if (MEDIA.has(e.tagName) || (e instanceof SVGElement && e.tagName.toLowerCase() !== 'svg')) return null
|
|
1658
|
+
const cs = getComputedStyle(e)
|
|
1659
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') return null
|
|
1660
|
+
const c = rgba(cs.backgroundColor)
|
|
1661
|
+
if (!c) return null
|
|
1662
|
+
if (c[3] > 0) {
|
|
1663
|
+
if (c[3] >= 0.999) { base = c; break }
|
|
1664
|
+
layers.push(c)
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
if (!base) {
|
|
1668
|
+
if (darkOn) return null // 深色模式下画布底色不一定是白的
|
|
1669
|
+
base = [255, 255, 255, 1]
|
|
1670
|
+
}
|
|
1671
|
+
for (let i = layers.length - 1; i >= 0; i--) base = over(layers[i], base)
|
|
1672
|
+
return { color: base }
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** 自己那几段文字在屏幕上的矩形(每行一块) */
|
|
1676
|
+
function textRects(el) {
|
|
1677
|
+
const out = []
|
|
1678
|
+
const rg = document.createRange()
|
|
1679
|
+
for (const n of el.childNodes) {
|
|
1680
|
+
if (n.nodeType !== 3 || !n.nodeValue.trim()) continue
|
|
1681
|
+
rg.selectNodeContents(n)
|
|
1682
|
+
for (const r of rg.getClientRects()) {
|
|
1683
|
+
if (r.width >= 1 && r.height >= 1) out.push({ l: r.left, t: r.top, r: r.right, b: r.bottom })
|
|
1684
|
+
if (out.length >= 24) return out
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return out
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/** 最近的一个会裁掉内容的祖先(overflow: hidden / clip),和它是不是能滚 */
|
|
1691
|
+
function clipperOf(el) {
|
|
1692
|
+
for (let e = el.parentElement; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
|
|
1693
|
+
const cs = getComputedStyle(e)
|
|
1694
|
+
const ox = cs.overflowX, oy = cs.overflowY
|
|
1695
|
+
if (/auto|scroll/.test(ox) || /auto|scroll/.test(oy)) return { el: e, scroll: true }
|
|
1696
|
+
if (/hidden|clip/.test(ox) || /hidden|clip/.test(oy)) return { el: e, scroll: false, x: /hidden|clip/.test(ox), y: /hidden|clip/.test(oy) }
|
|
1697
|
+
}
|
|
1698
|
+
return null
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// ── 固定栏:贴着视口上 / 下边、横着铺开的固定 / 吸顶吸底 / 绝对定位的块(顶栏、Tab 栏、底部按钮条) ──
|
|
1702
|
+
|
|
1703
|
+
const BAR_MAX = 0.4 // 栏最高占视口的这么多
|
|
1704
|
+
const se = () => document.scrollingElement || document.documentElement
|
|
1705
|
+
|
|
1706
|
+
/** 能上下滚的地方:页面本身,和里面 overflow 是 auto / scroll 的区域(App 常把内容放在一个能滚的 main 里) */
|
|
1707
|
+
function scrollAreas() {
|
|
1708
|
+
const out = []
|
|
1709
|
+
if (se().scrollHeight > innerHeight + 1) out.push(se())
|
|
1710
|
+
const all = document.body.getElementsByTagName('*')
|
|
1711
|
+
for (let i = 0; i < all.length && out.length < 20; i++) {
|
|
1712
|
+
const el = all[i]
|
|
1713
|
+
if (el.scrollHeight <= el.clientHeight + 1 || el.clientHeight < 40 || isOurs(el)) continue
|
|
1714
|
+
if (/auto|scroll|overlay/.test(getComputedStyle(el).overflowY)) out.push(el)
|
|
1715
|
+
}
|
|
1716
|
+
return out
|
|
1717
|
+
}
|
|
1718
|
+
/** 都滚到顶 / 底(instant:页面写了 scroll-behavior: smooth 也一下到位) */
|
|
1719
|
+
function scrollAll(areas, end) {
|
|
1720
|
+
for (const el of areas) {
|
|
1721
|
+
const top = end ? el.scrollHeight : 0
|
|
1722
|
+
if (el === se()) scrollTo({ left: scrollX, top, behavior: 'instant' })
|
|
1723
|
+
else el.scrollTo({ left: el.scrollLeft, top, behavior: 'instant' })
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** 滚完等两帧、再等新露出来的图和字体(页面的滚动监听、懒加载的图有机会跟上),再截图 */
|
|
1728
|
+
const afterScroll = (v) => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(v)))).then((v2) => settle().then(() => v2, () => v2))
|
|
1729
|
+
|
|
1730
|
+
function barsAt(edge) {
|
|
1731
|
+
const out = []
|
|
1732
|
+
const vw = innerWidth, vh = innerHeight
|
|
1733
|
+
const all = document.body.getElementsByTagName('*')
|
|
1734
|
+
for (let i = 0; i < all.length; i++) {
|
|
1735
|
+
const el = all[i]
|
|
1736
|
+
if (isOurs(el)) continue
|
|
1737
|
+
const pos = getComputedStyle(el).position
|
|
1738
|
+
if (pos !== 'fixed' && pos !== 'sticky' && pos !== 'absolute') continue
|
|
1739
|
+
if (out.some((b) => b.el.contains(el))) continue // 栏里面的东西
|
|
1740
|
+
const r = el.getBoundingClientRect()
|
|
1741
|
+
if (r.width < vw * 0.5 || r.height < 16 || r.height > vh * BAR_MAX) continue
|
|
1742
|
+
if (edge === 'bottom' ? Math.abs(r.bottom - vh) > 2 : Math.abs(r.top) > 2) continue
|
|
1743
|
+
if (hiddenChain(el)) continue
|
|
1744
|
+
out.push({ el, r, pos, edge })
|
|
1745
|
+
}
|
|
1746
|
+
return out
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/** 这一点上最上面那个元素(我们自己画的框不算) */
|
|
1750
|
+
const topAt = (x, y) => {
|
|
1751
|
+
const cx = Math.max(0, Math.min(innerWidth - 1, x)), cy = Math.max(0, Math.min(innerHeight - 1, y))
|
|
1752
|
+
for (const e of document.elementsFromPoint(cx, cy)) if (!isOurs(e)) return e
|
|
1753
|
+
return null
|
|
1754
|
+
}
|
|
1755
|
+
/** 栏在这一点是不是不透明的(有底色 / 背景图 / 毛玻璃,或者压着的是栏里的字、图标) */
|
|
1756
|
+
function barSolid(top, bar) {
|
|
1757
|
+
if (top !== bar && (MEDIA.has(top.tagName) || top instanceof SVGElement || ownText(top))) return true
|
|
1758
|
+
for (let e = top; e && e.nodeType === 1; e = e.parentElement) {
|
|
1759
|
+
const cs = getComputedStyle(e)
|
|
1760
|
+
const c = rgba(cs.backgroundColor)
|
|
1761
|
+
if ((c && c[3] > 0.5) || (cs.backgroundImage && cs.backgroundImage !== 'none') || (cs.backdropFilter && cs.backdropFilter !== 'none')) return true
|
|
1762
|
+
if (e === bar) break
|
|
1763
|
+
}
|
|
1764
|
+
return false
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
/**
|
|
1768
|
+
* 固定栏压没压住内容:没滚时看顶栏,滚到底(页面和里面能滚的区域)时看底栏。
|
|
1769
|
+
* 压住了报 covered;最近的字离栏不到 8px 报 tight。量完马上滚回去(同一帧里,看不到跳动)。
|
|
1770
|
+
* 返回每条栏的高度和离内容的距离(负数 = 压住了这么多)
|
|
1771
|
+
*/
|
|
1772
|
+
/** 这一点在元素的某个外层(overflow 不是 visible 的)外面:被裁掉了,看不见 */
|
|
1773
|
+
function clippedAt(el, x, y) {
|
|
1774
|
+
for (let e = el.parentElement; e && e !== document.body && e !== document.documentElement; e = e.parentElement) {
|
|
1775
|
+
const cs = getComputedStyle(e)
|
|
1776
|
+
if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') {
|
|
1777
|
+
const r = e.getBoundingClientRect()
|
|
1778
|
+
if (x < r.left - 1 || x > r.right + 1 || y < r.top - 1 || y > r.bottom + 1) return true
|
|
1779
|
+
}
|
|
1780
|
+
if (cs.position === 'fixed') break // 固定定位的外层不再受更外面的裁剪
|
|
1781
|
+
}
|
|
1782
|
+
return false
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function edgeCheck(add, atoms) {
|
|
1786
|
+
const areas = scrollAreas()
|
|
1787
|
+
const was = areas.map((el) => [el, el.scrollLeft, el.scrollTop])
|
|
1788
|
+
const out = []
|
|
1789
|
+
const covered = new Set()
|
|
1790
|
+
const look = (edge) => {
|
|
1791
|
+
for (const bar of barsAt(edge)) {
|
|
1792
|
+
const b = bar.r
|
|
1793
|
+
let near = null // 离栏最近的那行字:{ d, el }
|
|
1794
|
+
for (const a of atoms) {
|
|
1795
|
+
if (bar.el.contains(a.el) || covered.has(a.el)) continue
|
|
1796
|
+
for (const r of textRects(a.el)) {
|
|
1797
|
+
if (r.r <= b.left + 1 || r.l >= b.right - 1 || r.b <= 0 || r.t >= innerHeight) continue // 和栏不在一竖条上、不在视口里
|
|
1798
|
+
// 离栏多远(负数 = 伸进栏里了);没滚时的顶栏、滚到底时的底栏后面的字已经没法再滚出来了,也算
|
|
1799
|
+
const d = edge === 'bottom' ? b.top - r.b : r.t - b.bottom
|
|
1800
|
+
if (d > 120) continue
|
|
1801
|
+
const x = (Math.max(r.l, b.left) + Math.min(r.r, b.right)) / 2
|
|
1802
|
+
const y = d < 0 ? (Math.max(r.t, b.top) + Math.min(r.b, b.bottom)) / 2 : (r.t + r.b) / 2
|
|
1803
|
+
if (clippedAt(a.el, x, y)) continue // 被外层裁掉了,本来就看不见
|
|
1804
|
+
const top = topAt(x, y)
|
|
1805
|
+
if (!top) continue
|
|
1806
|
+
const mine = a.el === top || a.el.contains(top)
|
|
1807
|
+
const under = !mine && bar.el.contains(top) && barSolid(top, bar.el)
|
|
1808
|
+
if (!mine && !under) continue // 被别的东西挡着、或者裁掉了:不算
|
|
1809
|
+
if (mine && d < 0) continue // 字画在栏上面(层级更高):没被压住,也不算进离内容多远
|
|
1810
|
+
if (!near || d < near.d) near = { d, el: a.el }
|
|
1811
|
+
if (under && d < -2) {
|
|
1812
|
+
covered.add(a.el)
|
|
1813
|
+
add('covered', a.el, { px: Math.round(-d), edge, bar: nameOf(bar.el) }, 2)
|
|
1814
|
+
break
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
if (near && near.d >= 0 && near.d < 8 && !covered.has(near.el)) add('tight', near.el, { px: Math.round(near.d), edge, bar: nameOf(bar.el) }, 1)
|
|
1819
|
+
out.push({ edge, name: nameOf(bar.el), pos: bar.pos, h: Math.round(b.height), gap: near ? Math.round(near.d) : null })
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
try {
|
|
1823
|
+
scrollAll(areas, false)
|
|
1824
|
+
look('top')
|
|
1825
|
+
if (areas.length) scrollAll(areas, true)
|
|
1826
|
+
look('bottom')
|
|
1827
|
+
} finally {
|
|
1828
|
+
for (const [el, x, y] of was) {
|
|
1829
|
+
if (el === se()) scrollTo({ left: x, top: y, behavior: 'instant' })
|
|
1830
|
+
else el.scrollTo({ left: x, top: y, behavior: 'instant' })
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return { bars: out, areas: areas.filter((el) => el !== se()).length }
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/**
|
|
1837
|
+
* 体检附图:把出问题的元素摆回量的时候的样子(被栏压住的滚到顶 / 底,别的滚到屏幕中间),
|
|
1838
|
+
* 红框圈出它(叠字时两个都圈;被栏压住的把栏调成半透明,透出下面的字),
|
|
1839
|
+
* 给出裁剪范围(整页坐标;连上叠着的元素、压住它的栏,四周留 24px,至少约 40% 宽、30% 高,不伸出视口,看得到周围的排版)。
|
|
1840
|
+
* also:挨着的几条问题的元素,在图里的也圈上,inside 里说哪几个在图里。
|
|
1841
|
+
* 叠着的另一个用橙色虚线框;upper 说哪个画在上面(self / other);label 是标在红框上的数字(如「压住 44px」)。
|
|
1842
|
+
* 元素没了、没有大小、不在屏幕里给 null
|
|
1843
|
+
*/
|
|
1844
|
+
let cutMarks = []
|
|
1845
|
+
let cutUndo = []
|
|
1846
|
+
async function cropAt({ sel, other, edge, hscroll, also = [], label = '' }) {
|
|
1847
|
+
for (const m of cutMarks) m.remove()
|
|
1848
|
+
for (const f of cutUndo) f()
|
|
1849
|
+
cutMarks = []
|
|
1850
|
+
cutUndo = []
|
|
1851
|
+
const q = (s) => { try { return s ? document.querySelector(s) : null } catch { return null } }
|
|
1852
|
+
const el = q(sel)
|
|
1853
|
+
if (!el) return null
|
|
1854
|
+
if (edge === 'top' || edge === 'bottom') scrollAll(scrollAreas(), edge === 'bottom')
|
|
1855
|
+
else {
|
|
1856
|
+
try { el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }) } catch { el.scrollIntoView() }
|
|
1857
|
+
if (hscroll) scrollTo({ left: 0, top: scrollY, behavior: 'instant' }) // 伸出屏幕的:照用户看到的截,别横着滚过去
|
|
1858
|
+
}
|
|
1859
|
+
await afterScroll()
|
|
1860
|
+
const vw = document.documentElement.clientWidth || innerWidth, vh = innerHeight
|
|
1861
|
+
const box = (e) => { const r = e && e.getBoundingClientRect(); return r && (r.width >= 1 || r.height >= 1) ? r : null }
|
|
1862
|
+
const own = box(el)
|
|
1863
|
+
if (!own || own.bottom <= 0 || own.top >= vh || own.right <= 0 || own.left >= vw) return null
|
|
1864
|
+
const parts = [own]
|
|
1865
|
+
const o = q(other)
|
|
1866
|
+
const oth = box(o)
|
|
1867
|
+
if (oth) parts.push(oth)
|
|
1868
|
+
if (edge) {
|
|
1869
|
+
const bar = barsAt(edge).find((b) => b.r.left < own.right && b.r.right > own.left)
|
|
1870
|
+
if (bar) {
|
|
1871
|
+
parts.push(bar.r)
|
|
1872
|
+
const was = bar.el.style.getPropertyValue('opacity'), pri = bar.el.style.getPropertyPriority('opacity')
|
|
1873
|
+
bar.el.style.setProperty('opacity', '0.3', 'important')
|
|
1874
|
+
cutUndo.push(() => { if (was) bar.el.style.setProperty('opacity', was, pri); else bar.el.style.removeProperty('opacity') })
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
const { root } = fxRoot()
|
|
1878
|
+
const mark = (e, cls = '', text = '') => {
|
|
1879
|
+
const r = docRect(e)
|
|
1880
|
+
const d = document.createElement('div')
|
|
1881
|
+
d.className = 'cut' + cls
|
|
1882
|
+
Object.assign(d.style, { left: r.left - 3 + 'px', top: r.top - 3 + 'px', width: r.width + 6 + 'px', height: r.height + 6 + 'px' })
|
|
1883
|
+
if (text) {
|
|
1884
|
+
const t = document.createElement('b')
|
|
1885
|
+
t.textContent = String(text).slice(0, 40)
|
|
1886
|
+
d.append(t)
|
|
1887
|
+
if (e.getBoundingClientRect().top < 30) d.classList.add('low') // 上面放不下:标在框下面
|
|
1888
|
+
}
|
|
1889
|
+
root.append(d)
|
|
1890
|
+
cutMarks.push(d)
|
|
1891
|
+
}
|
|
1892
|
+
mark(el, '', label)
|
|
1893
|
+
if (oth) mark(o, ' alt')
|
|
1894
|
+
// 叠着的两个:重叠那块中间最上面的是谁
|
|
1895
|
+
let upper = ''
|
|
1896
|
+
if (oth) {
|
|
1897
|
+
const x = (Math.max(own.left, oth.left) + Math.min(own.right, oth.right)) / 2, y = (Math.max(own.top, oth.top) + Math.min(own.bottom, oth.bottom)) / 2
|
|
1898
|
+
const top = topAt(x, y)
|
|
1899
|
+
upper = top && el.contains(top) ? 'self' : top && o.contains(top) ? 'other' : ''
|
|
1900
|
+
}
|
|
1901
|
+
// 至少 min 那么大(太小看不出周围),先居中撑开,再挪回视口里
|
|
1902
|
+
const fit = (a, z, min, max) => {
|
|
1903
|
+
if (z - a < min) { const c = (a + z) / 2; a = c - min / 2; z = c + min / 2 }
|
|
1904
|
+
if (a < 0) { z -= a; a = 0 }
|
|
1905
|
+
if (z > max) { a -= z - max; z = max }
|
|
1906
|
+
return [Math.max(0, a), Math.min(max, z)]
|
|
1907
|
+
}
|
|
1908
|
+
const [l, r] = fit(Math.min(...parts.map((x) => x.left)) - 24, Math.max(...parts.map((x) => x.right)) + 24, Math.min(vw, Math.max(320, vw * 0.4)), vw)
|
|
1909
|
+
const [t, b] = fit(Math.min(...parts.map((x) => x.top)) - 24, Math.max(...parts.map((x) => x.bottom)) + 24, Math.min(vh, Math.max(200, vh * 0.3)), vh)
|
|
1910
|
+
if (r - l < 1 || b - t < 1) return null
|
|
1911
|
+
// 挨着的:至少一半在图里才算、才圈
|
|
1912
|
+
const inside = (Array.isArray(also) ? also : []).slice(0, 12).map((s) => {
|
|
1913
|
+
const e = q(s)
|
|
1914
|
+
const x = box(e)
|
|
1915
|
+
if (!x) return false
|
|
1916
|
+
const iw = Math.min(x.right, r) - Math.max(x.left, l), ih = Math.min(x.bottom, b) - Math.max(x.top, t)
|
|
1917
|
+
const ok = iw > 0 && ih > 0 && iw * ih >= 0.5 * x.width * Math.max(1, x.height)
|
|
1918
|
+
if (ok && e !== el && e !== o) mark(e)
|
|
1919
|
+
return ok
|
|
1920
|
+
})
|
|
1921
|
+
return { x: Math.round(l + scrollX), y: Math.round(t + scrollY), w: Math.round(r - l), h: Math.round(b - t), inside, upper }
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* 两段字叠在一起,多半是哪儿挪了位置:从两个元素往上到共同的外层,找负 margin、绝对定位、transform。
|
|
1926
|
+
* 给 { sel, prop, value }(体检再按它找是哪条规则、哪个文件第几行),找不到给 null
|
|
1927
|
+
*/
|
|
1928
|
+
function causeOf(A, B) {
|
|
1929
|
+
const chain = (e) => {
|
|
1930
|
+
const out = []
|
|
1931
|
+
for (let x = e; x && x !== document.body && !x.contains(e === A ? B : A) && out.length < 6; x = x.parentElement) out.push(x)
|
|
1932
|
+
return out
|
|
1933
|
+
}
|
|
1934
|
+
const both = [...chain(A), ...chain(B)]
|
|
1935
|
+
for (const e of both) {
|
|
1936
|
+
const cs = getComputedStyle(e)
|
|
1937
|
+
for (const side of ['top', 'left', 'bottom', 'right']) {
|
|
1938
|
+
const v = cs.getPropertyValue('margin-' + side)
|
|
1939
|
+
if (parseFloat(v) < -1) return { sel: cssPath(e), prop: 'margin-' + side, value: v }
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
for (const e of both) {
|
|
1943
|
+
const pos = getComputedStyle(e).position
|
|
1944
|
+
if (pos === 'absolute' || pos === 'fixed') return { sel: cssPath(e), prop: 'position', value: pos }
|
|
1945
|
+
}
|
|
1946
|
+
for (const e of both) {
|
|
1947
|
+
if (getComputedStyle(e).transform !== 'none') return { sel: cssPath(e), prop: 'transform', value: '' }
|
|
1948
|
+
}
|
|
1949
|
+
return null
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/** 页面里引用的地址 → 设计目录里的相对路径(同一个预览服务器上的);外网的给 '' */
|
|
1953
|
+
function relOf(src) {
|
|
1954
|
+
try {
|
|
1955
|
+
const u = new URL(src, here())
|
|
1956
|
+
if (u.origin !== hereUrl().origin) return ''
|
|
1957
|
+
return decodeURIComponent(u.pathname).replace(/^\/+/, '')
|
|
1958
|
+
} catch { return '' }
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* 量一遍。返回 { issues, metrics }:metrics 是量的时候视口多大、页面多高、有哪些固定栏,
|
|
1963
|
+
* 让模型知道量的是什么(不只是一句「没问题」)
|
|
1964
|
+
*/
|
|
1965
|
+
function runCheck(kind) {
|
|
1966
|
+
const issues = []
|
|
1967
|
+
const per = {}
|
|
1968
|
+
const add = (type, el, extra, sev = 1) => {
|
|
1969
|
+
if ((per[type] = (per[type] || 0) + 1) > CHECK_PER) return
|
|
1970
|
+
const r = el ? docRect(el) : null
|
|
1971
|
+
issues.push({ type, sev, el, sel: el ? cssPath(el) : '', name: el ? nameOf(el) : '', text: el ? ownText(el).slice(0, 40) : '', rect: r ? { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } : null, ...extra })
|
|
1972
|
+
}
|
|
1973
|
+
const body = document.body
|
|
1974
|
+
if (!body) return []
|
|
1975
|
+
const vw = document.documentElement.clientWidth || innerWidth
|
|
1976
|
+
|
|
1977
|
+
// 带字的元素
|
|
1978
|
+
const atoms = []
|
|
1979
|
+
const all = body.getElementsByTagName('*')
|
|
1980
|
+
for (let i = 0; i < all.length && atoms.length < CHECK_ATOMS; i++) {
|
|
1981
|
+
const el = all[i]
|
|
1982
|
+
if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit,script,style,noscript,template,svg')) continue
|
|
1983
|
+
if (!ownText(el)) continue
|
|
1984
|
+
const cs = getComputedStyle(el)
|
|
1985
|
+
if (cs.display === 'none' || cs.visibility !== 'visible') continue
|
|
1986
|
+
const rects = textRects(el)
|
|
1987
|
+
if (!rects.length || hiddenChain(el)) continue
|
|
1988
|
+
atoms.push({ el, cs, rects })
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// 1. 超出屏幕:最外面那个伸出去的元素(在能横向滚动的区域里的不算,那是轮播)
|
|
1992
|
+
const sw = document.documentElement.scrollWidth
|
|
1993
|
+
const culprits = []
|
|
1994
|
+
for (let i = 0; i < all.length && culprits.length < 3; i++) {
|
|
1995
|
+
const el = all[i]
|
|
1996
|
+
if (isOurs(el)) continue
|
|
1997
|
+
const r = el.getBoundingClientRect()
|
|
1998
|
+
if (r.width < 1 || r.height < 1) continue
|
|
1999
|
+
// 按 innerWidth 算:100vw 的块在有滚动条的系统上会多出一条滚动条宽,不算它
|
|
2000
|
+
const out = Math.max(r.right - innerWidth, -r.left)
|
|
2001
|
+
if (out <= 2) continue
|
|
2002
|
+
const cs = getComputedStyle(el)
|
|
2003
|
+
if (cs.position === 'fixed') continue
|
|
2004
|
+
const p = el.parentElement
|
|
2005
|
+
if (p && p !== body) {
|
|
2006
|
+
const pr = p.getBoundingClientRect()
|
|
2007
|
+
if (Math.max(pr.right - innerWidth, -pr.left) > 2) continue // 爸爸已经伸出去了,报爸爸
|
|
2008
|
+
}
|
|
2009
|
+
const cl = clipperOf(el)
|
|
2010
|
+
if (cl) continue // 在会滚 / 会裁的容器里:轮播、跑马灯,或者归「被截断」管
|
|
2011
|
+
if (hiddenChain(el)) continue
|
|
2012
|
+
culprits.push({ el, out: Math.round(out) })
|
|
2013
|
+
}
|
|
2014
|
+
for (const c of culprits) add('hscroll', c.el, { px: c.out, scroll: sw > vw + 1 }, 2)
|
|
2015
|
+
|
|
2016
|
+
// 2. 文字被截断 / 超出框:自己的字伸出了所在的块(行内元素看外面那个块)。
|
|
2017
|
+
// Range 量到的是排版后的位置,被裁掉的部分也量得到
|
|
2018
|
+
const clampOf = (cs) => (cs.webkitLineClamp && cs.webkitLineClamp !== 'none') || (cs.lineClamp && cs.lineClamp !== 'none')
|
|
2019
|
+
for (const a of atoms) {
|
|
2020
|
+
const { el } = a
|
|
2021
|
+
let box = el
|
|
2022
|
+
while (box.parentElement && /^(inline|contents)$/.test(getComputedStyle(box).display)) box = box.parentElement
|
|
2023
|
+
const bcs = box === el ? a.cs : getComputedStyle(box)
|
|
2024
|
+
if (box !== body && box !== document.documentElement && box.clientWidth > 0) {
|
|
2025
|
+
const br = box.getBoundingClientRect()
|
|
2026
|
+
const dx = Math.max(0, ...a.rects.map((r) => Math.max(r.r - br.right, br.left - r.l)))
|
|
2027
|
+
const dy = Math.max(0, ...a.rects.map((r) => Math.max(r.b - br.bottom, br.top - r.t)))
|
|
2028
|
+
const ox = bcs.overflowX, oy = bcs.overflowY
|
|
2029
|
+
// 大字的字形本来就会比行框宽、高一点(字距收紧、行高小于 1),差一点不算
|
|
2030
|
+
const slack = Math.max(3, (parseFloat(a.cs.fontSize) || 16) * 0.2)
|
|
2031
|
+
if (dx > slack && !/auto|scroll/.test(ox) && bcs.textOverflow !== 'ellipsis') { add(/hidden|clip/.test(ox) ? 'clipped' : 'overflow', el, { px: Math.round(dx) }, 2); continue }
|
|
2032
|
+
if (dy > slack && !/auto|scroll/.test(oy) && !clampOf(bcs)) { add(/hidden|clip/.test(oy) ? 'clipped' : 'overflow', el, { px: Math.round(dy) }, 2); continue }
|
|
2033
|
+
}
|
|
2034
|
+
// 被更外面的容器裁掉
|
|
2035
|
+
const cl = clipperOf(box)
|
|
2036
|
+
if (cl && !cl.scroll) {
|
|
2037
|
+
const box = cl.el.getBoundingClientRect()
|
|
2038
|
+
const cut = Math.max(...a.rects.map((r) => Math.max(cl.x ? r.r - box.right : 0, cl.x ? box.left - r.l : 0, cl.y ? r.b - box.bottom : 0, cl.y ? box.top - r.t : 0)))
|
|
2039
|
+
const ccs = getComputedStyle(cl.el)
|
|
2040
|
+
if (cut > 2 && ccs.textOverflow !== 'ellipsis' && !clampOf(ccs)) add('clipped', el, { px: Math.round(cut) }, 2)
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// 3. 文字叠在一起:不同元素的文字矩形(逐行)重叠超过小的那块的 30%;很淡的装饰字不算。
|
|
2045
|
+
// 一个在固定的顶栏 / 底栏里、一个在页面内容里的不算:内容滚动时本来就从栏下面过,滚到头还被压住由第 11 项(covered)查
|
|
2046
|
+
const pinBars = [...barsAt('top'), ...barsAt('bottom')].map((b) => b.el)
|
|
2047
|
+
const pins = new Map()
|
|
2048
|
+
const pinOf = (i) => {
|
|
2049
|
+
if (pins.has(i)) return pins.get(i)
|
|
2050
|
+
let hit = null
|
|
2051
|
+
for (let e = atoms[i].el; e && e !== document.body; e = e.parentElement) {
|
|
2052
|
+
const pos = getComputedStyle(e).position
|
|
2053
|
+
if (pinBars.includes(e) || pos === 'fixed' || pos === 'sticky') { hit = e; break }
|
|
2054
|
+
}
|
|
2055
|
+
pins.set(i, hit)
|
|
2056
|
+
return hit
|
|
2057
|
+
}
|
|
2058
|
+
const boxes = []
|
|
2059
|
+
atoms.forEach((a, i) => {
|
|
2060
|
+
const fg = rgba(a.cs.color)
|
|
2061
|
+
if (fg && fg[3] * opacityChain(a.el) < 0.35) return
|
|
2062
|
+
for (const r of a.rects) if (r.b - r.t >= 4) boxes.push({ ...r, i })
|
|
2063
|
+
})
|
|
2064
|
+
boxes.sort((x, y) => x.t - y.t)
|
|
2065
|
+
const pairs = new Set()
|
|
2066
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
2067
|
+
const p = boxes[i]
|
|
2068
|
+
for (let j = i + 1; j < boxes.length && boxes[j].t < p.b; j++) {
|
|
2069
|
+
const q = boxes[j]
|
|
2070
|
+
if (q.i === p.i) continue
|
|
2071
|
+
const ow = Math.min(p.r, q.r) - Math.max(p.l, q.l)
|
|
2072
|
+
const oh = Math.min(p.b, q.b) - Math.max(p.t, q.t)
|
|
2073
|
+
if (ow <= 3 || oh <= 3) continue
|
|
2074
|
+
const small = Math.min((p.r - p.l) * (p.b - p.t), (q.r - q.l) * (q.b - q.t))
|
|
2075
|
+
if (ow * oh < small * 0.3) continue
|
|
2076
|
+
const A = atoms[p.i].el, B = atoms[q.i].el
|
|
2077
|
+
if (A.contains(B) || B.contains(A)) continue
|
|
2078
|
+
if (!pinOf(p.i) !== !pinOf(q.i)) continue
|
|
2079
|
+
const key = Math.min(p.i, q.i) + ':' + Math.max(p.i, q.i)
|
|
2080
|
+
if (pairs.has(key)) continue
|
|
2081
|
+
pairs.add(key)
|
|
2082
|
+
add('overlap', A, { other: nameOf(B), otherText: ownText(B).slice(0, 40), otherSel: cssPath(B), px: Math.round(oh), cause: causeOf(A, B) }, 2)
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// 4. 对比度(WCAG:正文 4.5,大字 3)
|
|
2087
|
+
const sizes = new Map()
|
|
2088
|
+
const tinyMin = kind === 'app' ? 10 : kind === 'tablet' ? 11 : kind === 'poster' || kind === 'slides' ? Math.max(12, Math.round(vw / 96)) : 12
|
|
2089
|
+
for (const a of atoms) {
|
|
2090
|
+
const { el, cs } = a
|
|
2091
|
+
const px = parseFloat(cs.fontSize) || 0
|
|
2092
|
+
sizes.set(Math.round(px * 2) / 2, (sizes.get(Math.round(px * 2) / 2) || 0) + 1)
|
|
2093
|
+
if (px && px < tinyMin && !el.closest('sup,sub')) add('tiny', el, { size: Math.round(px * 10) / 10, min: tinyMin }, 1)
|
|
2094
|
+
if (el.closest(':disabled,[aria-disabled="true"]')) continue
|
|
2095
|
+
const fill = cs.webkitTextFillColor
|
|
2096
|
+
let fg = rgba(fill && fill !== cs.color ? fill : cs.color)
|
|
2097
|
+
if (!fg || fg[3] === 0) continue // 渐变字之类
|
|
2098
|
+
const r0 = a.rects[0]
|
|
2099
|
+
const op = opacityChain(el)
|
|
2100
|
+
// 半透明还在变的(淡入动画、跟着滚动渐显的)量的是中间状态,不算
|
|
2101
|
+
if (op < 0.999 && (!inView(el) || animating(el))) continue
|
|
2102
|
+
const bd = backdrop(el, { x: (r0.l + r0.r) / 2, y: (r0.t + r0.b) / 2 })
|
|
2103
|
+
if (!bd) continue
|
|
2104
|
+
fg = over([fg[0], fg[1], fg[2], fg[3] * op], bd.color)
|
|
2105
|
+
const cr = ratio(fg, bd.color)
|
|
2106
|
+
const bold = +cs.fontWeight >= 700
|
|
2107
|
+
const need = px >= 24 || (bold && px >= 18.66) ? 3 : 4.5
|
|
2108
|
+
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)
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// 5. 点击区太小(App / 平板 44,网页 24;文字中间的链接不算)
|
|
2112
|
+
const minTap = kind === 'app' || kind === 'tablet' ? 44 : kind === 'web' || kind === 'email' ? 24 : 0
|
|
2113
|
+
if (minTap) {
|
|
2114
|
+
const small = []
|
|
2115
|
+
for (const el of body.querySelectorAll(CLICKABLE)) {
|
|
2116
|
+
if (isOurs(el) || el.closest('dsh-design-picker,dsh-design-fx,dsh-design-edit')) continue
|
|
2117
|
+
const r = el.getBoundingClientRect()
|
|
2118
|
+
if (r.width < 1 || r.height < 1 || (r.width >= minTap && r.height >= minTap)) continue
|
|
2119
|
+
const cs = getComputedStyle(el)
|
|
2120
|
+
if (cs.display === 'inline' && el.parentElement && ownText(el.parentElement)) continue // 一段话里的链接
|
|
2121
|
+
if (el.closest(CLICKABLE) !== el && el.parentElement && el.parentElement.closest(CLICKABLE)) continue // 外面一层已经是可点的
|
|
2122
|
+
if (hiddenChain(el)) continue
|
|
2123
|
+
small.push({ el, w: Math.round(r.width), h: Math.round(r.height) })
|
|
2124
|
+
}
|
|
2125
|
+
small.sort((x, y) => Math.min(x.w, x.h) - Math.min(y.w, y.h))
|
|
2126
|
+
for (const s of small) add('tap', s.el, { w: s.w, h: s.h, min: minTap }, Math.min(s.w, s.h) < minTap * 0.6 ? 2 : 1)
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// 6. 图片没加载出来
|
|
2130
|
+
for (const img of body.querySelectorAll('img')) {
|
|
2131
|
+
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src') && !hiddenChain(img)) add('img', img, { src: img.getAttribute('src').slice(0, 80) }, 2)
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// 7. 图片被拉变形:object-fit 是默认的 fill,显示的宽高比和原图差 4% 以上(SVG 自己保持比例,不算)
|
|
2135
|
+
for (const img of body.querySelectorAll('img')) {
|
|
2136
|
+
if (!img.complete || !img.naturalWidth || !img.naturalHeight || hiddenChain(img)) continue
|
|
2137
|
+
if (/\.svg(\?|#|$)|^data:image\/svg/i.test(img.currentSrc || img.src || '')) continue
|
|
2138
|
+
const r = img.getBoundingClientRect()
|
|
2139
|
+
if (r.width < 24 || r.height < 24) continue
|
|
2140
|
+
if ((getComputedStyle(img).objectFit || 'fill') !== 'fill') continue
|
|
2141
|
+
const skew = (r.width / r.height) / (img.naturalWidth / img.naturalHeight)
|
|
2142
|
+
if (Math.abs(Math.log(skew)) > Math.log(1.04)) add('distort', img, { nw: img.naturalWidth, nh: img.naturalHeight, w: Math.round(r.width), h: Math.round(r.height) }, 1)
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
// 8. 一行太长(网页 / 邮件 / 平板):成段的字一行超过约 45 个汉字或 90 个字母,读着累。
|
|
2146
|
+
// 最宽那行有几个字宽(汉字一个字 1em,字母平均约 0.5em);要折了行才算(一行写完的标题不管);同一屏报几处就够
|
|
2147
|
+
if (kind === 'web' || kind === 'email' || kind === 'tablet') {
|
|
2148
|
+
let long = 0
|
|
2149
|
+
for (const a of atoms) {
|
|
2150
|
+
if (long >= 3) break
|
|
2151
|
+
const text = ownText(a.el)
|
|
2152
|
+
if (text.length < 60 || new Set(a.rects.map((r) => Math.round(r.t))).size < 2) continue
|
|
2153
|
+
const px = parseFloat(a.cs.fontSize) || 16
|
|
2154
|
+
const cjk = (text.match(/[\u3400-\u9fff\uf900-\ufaff]/g) || []).length > text.length * 0.3
|
|
2155
|
+
const max = cjk ? 45 : 90
|
|
2156
|
+
const per = Math.round(Math.max(...a.rects.map((r) => r.r - r.l)) / px * (cjk ? 1 : 2))
|
|
2157
|
+
if (per > max) { add('long', a.el, { chars: per, max }, 1); long++ }
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// 9. 短字被挤断了行:按钮、价格、标签这种几个字的折成了两行(「再来一 / 单」),或者一句短话最后一行只剩一两个字。
|
|
2162
|
+
// 一段话里跟着文字折行的行内元素不算(外面那个块的字多);写了 <br> 的、竖排的不算
|
|
2163
|
+
let wraps = 0
|
|
2164
|
+
for (const a of atoms) {
|
|
2165
|
+
if (wraps >= 5) break
|
|
2166
|
+
const text = ownText(a.el).replace(/\s+/g, ' ').trim()
|
|
2167
|
+
if (text.length < 2 || text.length > 16 || a.el.querySelector('br') || /vertical/.test(a.cs.writingMode)) continue
|
|
2168
|
+
const tops = []
|
|
2169
|
+
for (const r of a.rects) if (!tops.some((t) => Math.abs(t - r.t) < 3)) tops.push(r.t)
|
|
2170
|
+
if (tops.length < 2) continue
|
|
2171
|
+
if (/^inline/.test(a.cs.display) && a.cs.display !== 'inline-block' && a.cs.display !== 'inline-flex') {
|
|
2172
|
+
let box = a.el.parentElement
|
|
2173
|
+
while (box && box !== body && /^(inline|contents)$/.test(getComputedStyle(box).display)) box = box.parentElement
|
|
2174
|
+
if (box && box !== body && (box.textContent || '').replace(/\s+/g, '').length > 20) continue
|
|
2175
|
+
}
|
|
2176
|
+
const px = parseFloat(a.cs.fontSize) || 16
|
|
2177
|
+
const lastTop = Math.max(...tops)
|
|
2178
|
+
const tail = a.rects.filter((r) => Math.abs(r.t - lastTop) < 3).reduce((m, r) => m + (r.r - r.l), 0) / px
|
|
2179
|
+
const short = text.replace(/\s/g, '').length <= 6 || !!a.el.closest('button,[role="button"]')
|
|
2180
|
+
if (short || tail <= 2.2) { add('wrap', a.el, { lines: tops.length }, 1); wraps++ }
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// 10. 字号太杂(整屏一条)
|
|
2184
|
+
const used = [...sizes.keys()].sort((x, y) => x - y)
|
|
2185
|
+
if (used.length > 7) add('fonts', null, { sizes: used }, 1)
|
|
2186
|
+
|
|
2187
|
+
// 11. 固定栏压住内容 / 贴得太近
|
|
2188
|
+
const edges = edgeCheck(add, atoms)
|
|
2189
|
+
|
|
2190
|
+
// 12. 页面脚本报错、样式表 / 脚本 / 字体没载出来(<head> 最前面那段小脚本记下的;图片归第 6 条)
|
|
2191
|
+
for (const e of Array.isArray(window.__dshErrs) ? window.__dshErrs.slice(0, 20) : []) {
|
|
2192
|
+
if (e.res) {
|
|
2193
|
+
if (e.tag === 'img' || !e.src) continue
|
|
2194
|
+
const rel = relOf(e.src)
|
|
2195
|
+
if (rel && !rel.startsWith('__dsh_design__/')) add('missing', null, { what: e.tag === 'link' ? 'css' : e.tag, src: rel }, 2)
|
|
2196
|
+
} else if (!/__dsh_design__/.test(e.src || '')) {
|
|
2197
|
+
add('jserr', null, { msg: String(e.msg || '').slice(0, 160), src: relOf(e.src || '') || '', line: e.line || 0 }, 2)
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
try {
|
|
2201
|
+
for (const f of document.fonts || []) if (f.status === 'error') add('missing', null, { what: 'font', src: String(f.family || '').replace(/['"]/g, '') }, 1)
|
|
2202
|
+
} catch { /* 老浏览器 */ }
|
|
2203
|
+
|
|
2204
|
+
issues.sort((x, y) => y.sev - x.sev)
|
|
2205
|
+
const se0 = se()
|
|
2206
|
+
return {
|
|
2207
|
+
issues: issues.slice(0, CHECK_MAX),
|
|
2208
|
+
metrics: { w: vw, h: innerHeight, page: Math.max(se0.scrollHeight, innerHeight), bars: edges.bars, areas: edges.areas },
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/** 量之前等字体、图片载完(最多等 2 秒):刚载入的页面字体没到时字宽不对,断行、截断都会量错 */
|
|
2213
|
+
async function settle() {
|
|
2214
|
+
const wait = (p, ms) => Promise.race([p, new Promise((r) => setTimeout(r, ms))])
|
|
2215
|
+
try { if (document.fonts && document.fonts.ready) await wait(document.fonts.ready, 2000) } catch { /* 没有 fonts */ }
|
|
2216
|
+
// 懒加载的图:滚到视口里了也等(滚动截图时)
|
|
2217
|
+
const seen = (i) => { const r = i.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth }
|
|
2218
|
+
const imgs = [...document.images].filter((i) => !i.complete && (i.loading !== 'lazy' || seen(i)))
|
|
2219
|
+
if (imgs.length) await wait(Promise.all(imgs.map((i) => new Promise((r) => { i.addEventListener('load', r, { once: true }); i.addEventListener('error', r, { once: true }) }))), 2000)
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
function clearMarks() {
|
|
2223
|
+
for (const m of checkMarks) m.node.remove()
|
|
2224
|
+
checkMarks = []
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
function placeMarks() {
|
|
2228
|
+
for (const m of checkMarks) {
|
|
2229
|
+
if (!m.el || !m.el.isConnected) continue
|
|
2230
|
+
const r = docRect(m.el)
|
|
2231
|
+
Object.assign(m.node.style, { left: r.left - 2 + 'px', top: r.top - 2 + 'px', width: r.width + 4 + 'px', height: r.height + 4 + 'px' })
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
addEventListener('resize', placeMarks)
|
|
2235
|
+
|
|
2236
|
+
function markIssues(issues) {
|
|
2237
|
+
clearMarks()
|
|
2238
|
+
const { root } = fxRoot()
|
|
2239
|
+
issues.forEach((it, i) => {
|
|
2240
|
+
if (!it.el) return
|
|
2241
|
+
const b = document.createElement('div')
|
|
2242
|
+
b.className = 'ck' + (it.sev > 1 ? ' hi' : '')
|
|
2243
|
+
const t = document.createElement('b')
|
|
2244
|
+
t.textContent = String(i + 1)
|
|
2245
|
+
b.append(t)
|
|
2246
|
+
root.append(b)
|
|
2247
|
+
checkMarks.push({ node: b, el: it.el })
|
|
2248
|
+
})
|
|
2249
|
+
placeMarks()
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
let lastIssues = []
|
|
2253
|
+
|
|
2254
|
+
/** 右侧栏点了第 i 条:滚过去,那个框闪一下 */
|
|
2255
|
+
function focusIssue(i) {
|
|
2256
|
+
const it = lastIssues[i]
|
|
2257
|
+
if (!it || !it.el || !it.el.isConnected) return
|
|
2258
|
+
const go = () => {
|
|
2259
|
+
const mk = checkMarks.find((x) => x.el === it.el)
|
|
2260
|
+
if (mk) { mk.node.classList.remove('on'); void mk.node.offsetWidth; mk.node.classList.add('on') }
|
|
2261
|
+
}
|
|
2262
|
+
if (inView(it.el)) go()
|
|
2263
|
+
else {
|
|
2264
|
+
try { it.el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } catch { it.el.scrollIntoView() }
|
|
2265
|
+
setTimeout(() => { placeMarks(); go() }, 450)
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
async function onCheck(d) {
|
|
2269
|
+
let r
|
|
2270
|
+
try {
|
|
2271
|
+
await settle()
|
|
2272
|
+
r = runCheck(String(d.kind || 'web'))
|
|
2273
|
+
} catch (e) {
|
|
2274
|
+
// 量的时候出错了:照实说没量成,别当成「没问题」
|
|
2275
|
+
console.warn('[dsh-design-studio] 体检出错', e)
|
|
2276
|
+
post({ type: 'checked', id: d.id, error: String((e && e.message) || e).slice(0, 200) })
|
|
2277
|
+
return
|
|
2278
|
+
}
|
|
2279
|
+
// 悄悄查(模型干完活顺手量一下,只要个数):不动页面上的标记
|
|
2280
|
+
if (!d.quiet) {
|
|
2281
|
+
lastIssues = r.issues
|
|
2282
|
+
markIssues(r.issues)
|
|
2283
|
+
}
|
|
2284
|
+
post({ type: 'checked', id: d.id, issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics, width: r.metrics.w })
|
|
2285
|
+
}
|
|
2286
|
+
|
|
1267
2287
|
// ── 深色模式:页面带了深色样式([data-theme="dark"] / .dark / prefers-color-scheme: dark)就能切 ──
|
|
1268
2288
|
|
|
1269
2289
|
let darkOn = false
|
|
@@ -1325,7 +2345,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1325
2345
|
const d = e.data
|
|
1326
2346
|
if (!d || d.dshDesign !== 1) return
|
|
1327
2347
|
if (d.type === 'pick') start(d.hint !== false)
|
|
1328
|
-
else if (d.type === 'cancel') stop(true)
|
|
2348
|
+
else if (d.type === 'cancel') { if (!styleOn) stop(true) }
|
|
1329
2349
|
else if (d.type === 'init') boot(d.seed || null)
|
|
1330
2350
|
else if (d.type === 'reveal') reveal(d)
|
|
1331
2351
|
else if (d.type === 'zoom') setK(1 / (Number(d.z) || 1))
|
|
@@ -1334,11 +2354,84 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1334
2354
|
else if (d.type === 'unpin') unpin()
|
|
1335
2355
|
else if (d.type === 'reload') selfReload()
|
|
1336
2356
|
else if (d.type === 'capture') onCapture(d)
|
|
2357
|
+
else if (d.type === 'check') onCheck(d)
|
|
2358
|
+
else if (d.type === 'checkClear') { clearMarks(); lastIssues = [] }
|
|
2359
|
+
else if (d.type === 'checkFocus') focusIssue(Number(d.i))
|
|
1337
2360
|
else if (d.type === 'theme') setDark(d.dark)
|
|
1338
2361
|
else if (d.type === 'demo') demo = !!d.on
|
|
1339
2362
|
else if (d.type === 'edit') setTextEdit(!!d.on, d.hint !== false)
|
|
2363
|
+
else if (d.type === 'styleMode') setStyleMode(!!d.on)
|
|
2364
|
+
else if (d.type === 'styleSel') onStyleSel(d.selector)
|
|
2365
|
+
else if (d.type === 'style') styleApply(d.css)
|
|
1340
2366
|
})
|
|
1341
2367
|
|
|
2368
|
+
// 无头浏览器(check_design / look_design 在电脑上另开的,见 browser.js)直接调这几个:
|
|
2369
|
+
// 量一遍、切深色、滚到底 / 滚到第几屏、取页面信息。都只读或只动这个页面自己
|
|
2370
|
+
function mainArea() {
|
|
2371
|
+
const areas = scrollAreas()
|
|
2372
|
+
if (areas.includes(se())) return se()
|
|
2373
|
+
return areas.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || se()
|
|
2374
|
+
}
|
|
2375
|
+
try {
|
|
2376
|
+
Object.defineProperty(window, '__dshDesign', {
|
|
2377
|
+
value: Object.freeze({
|
|
2378
|
+
check: async (kind) => {
|
|
2379
|
+
await settle()
|
|
2380
|
+
const r = runCheck(String(kind || 'web'))
|
|
2381
|
+
return { issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics }
|
|
2382
|
+
},
|
|
2383
|
+
dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
|
|
2384
|
+
settle: () => settle().then(() => true),
|
|
2385
|
+
/** 跳到别的屏的地方(路径和按钮上的字),量完查一下目标在不在 */
|
|
2386
|
+
links: () => linksOf().map(({ path, text }) => ({ path, text })),
|
|
2387
|
+
info: () => {
|
|
2388
|
+
const meta = document.querySelector('meta[name="dsh-design"]')
|
|
2389
|
+
return { title: document.title, meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '', page: Math.max(se().scrollHeight, innerHeight), w: innerWidth, h: innerHeight }
|
|
2390
|
+
},
|
|
2391
|
+
/** 'end':所有能滚的地方都滚到底;数字 i:主要的那块滚到第 i 屏(相邻两屏留出顶栏、底栏的高度,栏后面的内容下一屏能看到) */
|
|
2392
|
+
// 滚完等页面跟上再截图
|
|
2393
|
+
scroll: (where) => {
|
|
2394
|
+
const after = afterScroll
|
|
2395
|
+
if (where === 'end') { scrollAll(scrollAreas(), true); return after({ i: 0, of: 1 }) }
|
|
2396
|
+
const el = mainArea()
|
|
2397
|
+
const view = el === se() ? innerHeight : el.clientHeight
|
|
2398
|
+
const bars = [...barsAt('top'), ...barsAt('bottom')].reduce((m, b) => m + b.r.height, 0)
|
|
2399
|
+
const step = Math.max(Math.round(view * 0.5), Math.round(view - bars - 16))
|
|
2400
|
+
const max = Math.max(0, el.scrollHeight - view)
|
|
2401
|
+
const of = 1 + Math.ceil(max / step)
|
|
2402
|
+
const y = Math.min(max, Math.max(0, Number(where) || 0) * step)
|
|
2403
|
+
if (el === se()) scrollTo({ left: 0, top: y, behavior: 'instant' })
|
|
2404
|
+
else el.scrollTo({ left: 0, top: y, behavior: 'instant' })
|
|
2405
|
+
return after({ i: Number(where) || 0, of, y })
|
|
2406
|
+
},
|
|
2407
|
+
/** 一个元素的样式信息(和调样式选中它时报给右侧栏的一样:现在的值、用了哪些设计变量、各由哪条规则决定) */
|
|
2408
|
+
style: (sel) => {
|
|
2409
|
+
let el = null
|
|
2410
|
+
try { el = document.querySelector(sel) } catch { el = null }
|
|
2411
|
+
return el ? styleInfo(el) : null
|
|
2412
|
+
},
|
|
2413
|
+
/** 一个元素的某一项样式由哪条规则决定(体检找文件和行号用):{ sel, href, n, maybe? } / { inline: true } / null */
|
|
2414
|
+
ruleOf: (sel, prop) => {
|
|
2415
|
+
let el = null
|
|
2416
|
+
try { el = document.querySelector(sel) } catch { el = null }
|
|
2417
|
+
return el ? rulesOf(el, [String(prop)])[String(prop)] || null : null
|
|
2418
|
+
},
|
|
2419
|
+
/** 体检的问题附图:{ sel, other?, edge?, hscroll?, also? } → 摆好、画红框,给裁剪范围 { x, y, w, h, inside }(整页坐标) */
|
|
2420
|
+
crop: (o) => cropAt(o || {}),
|
|
2421
|
+
/** 一个元素的位置(先滚到能看见;按整页坐标给,截图裁剪就是按整页坐标裁的) */
|
|
2422
|
+
rect: (sel) => {
|
|
2423
|
+
let el = null
|
|
2424
|
+
try { el = document.querySelector(sel) } catch { el = null }
|
|
2425
|
+
if (!el) return null
|
|
2426
|
+
try { el.scrollIntoView({ block: 'center', behavior: 'instant' }) } catch { el.scrollIntoView() }
|
|
2427
|
+
const r = el.getBoundingClientRect()
|
|
2428
|
+
// 视口的范围也给:裁剪别伸出视口(外面截出来是空白)
|
|
2429
|
+
return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height, vx: scrollX, vy: scrollY, vw: innerWidth, vh: innerHeight }
|
|
2430
|
+
},
|
|
2431
|
+
}),
|
|
2432
|
+
})
|
|
2433
|
+
} catch { /* 页面自己占了这个名字 */ }
|
|
2434
|
+
|
|
1342
2435
|
function selfReload() {
|
|
1343
2436
|
try { sessionStorage.setItem(SEED_KEY, JSON.stringify({ ...seedNow(), at: Date.now() })) } catch { /* 隐私模式 / 太大 */ }
|
|
1344
2437
|
location.reload()
|
|
@@ -1346,7 +2439,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1346
2439
|
|
|
1347
2440
|
function onLoad() {
|
|
1348
2441
|
if (parentWin) {
|
|
1349
|
-
post({ type: 'hello', href:
|
|
2442
|
+
post({ type: 'hello', href: here() })
|
|
1350
2443
|
setTimeout(() => boot(null), 1500) // 右侧栏没回话(老版本 / 不在 DSH 里)也照常就绪
|
|
1351
2444
|
return
|
|
1352
2445
|
}
|
|
@@ -1370,7 +2463,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
|
|
|
1370
2463
|
/** 这一页在目录里的相对路径(/ 结尾的当 index.html) */
|
|
1371
2464
|
function selfRel() {
|
|
1372
2465
|
let p = ''
|
|
1373
|
-
|
|
2466
|
+
const pn = hereUrl().pathname
|
|
2467
|
+
try { p = decodeURIComponent(pn) } catch { p = pn }
|
|
1374
2468
|
p = p.replace(/^\/+/, '')
|
|
1375
2469
|
if (!p || p.endsWith('/')) p += 'index.html'
|
|
1376
2470
|
return p
|