dsh-design-studio 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/picker.js CHANGED
@@ -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: location.href, title: document.title, viewport: { w: innerWidth, h: innerHeight } } })
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: location.href, scroll: { x: scrollX, y: scrollY }, sig: snapshot().map })
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 === location.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: location.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.href || location.href
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, location.href) } catch { return '' }
871
- if (u.origin !== location.origin || u.pathname === location.pathname) return ''
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) return
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, location.href, inline)};`)
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, location.href).href).then((d) => { if (d) c.setAttribute('href', d) }))
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], location.href, inline).then((t) => { pseudoRules[i] = t }))
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.href || location.href
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
- post({ type: 'captured', id: d.id, png: r.png, w: r.w, h: r.h, scale: r.scale, title: document.title })
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
@@ -1320,12 +2340,44 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1320
2340
  }
1321
2341
  }
1322
2342
 
2343
+ // 调节滑块(右侧栏拖的时候):CSS 变量写在 <html> 的行内样式上(带 !important,样式表里 !important 的默认值也压得住),
2344
+ // 属性直接改 <html> 上的;原来的行内值(连同优先级)、属性值都记着,撤掉时原样还原。不动文件
2345
+ const tweakVars = new Map() // 变量 → 原来的行内值 { v, p }(v 为空 = 原来没有)
2346
+ const tweakAttrs = new Map() // 属性 → 原来的值(null = 原来没有)
2347
+ function applyTweak(vars, attrs) {
2348
+ const de = document.documentElement
2349
+ const nv = vars && typeof vars === 'object' ? vars : {}
2350
+ const na = attrs && typeof attrs === 'object' ? attrs : {}
2351
+ for (const [k, old] of [...tweakVars]) {
2352
+ if (Object.hasOwn(nv, k)) continue
2353
+ if (old.v) de.style.setProperty(k, old.v, old.p)
2354
+ else de.style.removeProperty(k)
2355
+ tweakVars.delete(k)
2356
+ }
2357
+ for (const [k, v] of Object.entries(nv)) {
2358
+ if (!/^--[\w-]{1,60}$/.test(k) || v == null || String(v).length > 80) continue
2359
+ if (!tweakVars.has(k)) tweakVars.set(k, { v: de.style.getPropertyValue(k), p: de.style.getPropertyPriority(k) })
2360
+ de.style.setProperty(k, String(v), 'important')
2361
+ }
2362
+ for (const [a, old] of [...tweakAttrs]) {
2363
+ if (Object.hasOwn(na, a)) continue
2364
+ if (old == null) de.removeAttribute(a)
2365
+ else de.setAttribute(a, old)
2366
+ tweakAttrs.delete(a)
2367
+ }
2368
+ for (const [a, v] of Object.entries(na)) {
2369
+ if (!/^data-[a-z0-9-]{1,40}$/.test(a) || v == null || String(v).length > 60) continue
2370
+ if (!tweakAttrs.has(a)) tweakAttrs.set(a, de.getAttribute(a))
2371
+ de.setAttribute(a, String(v))
2372
+ }
2373
+ }
2374
+
1323
2375
  addEventListener('message', (e) => {
1324
2376
  if (!parentWin || e.source !== parentWin || !parentOrigins.includes(e.origin)) return
1325
2377
  const d = e.data
1326
2378
  if (!d || d.dshDesign !== 1) return
1327
2379
  if (d.type === 'pick') start(d.hint !== false)
1328
- else if (d.type === 'cancel') stop(true)
2380
+ else if (d.type === 'cancel') { if (!styleOn) stop(true) }
1329
2381
  else if (d.type === 'init') boot(d.seed || null)
1330
2382
  else if (d.type === 'reveal') reveal(d)
1331
2383
  else if (d.type === 'zoom') setK(1 / (Number(d.z) || 1))
@@ -1334,11 +2386,87 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1334
2386
  else if (d.type === 'unpin') unpin()
1335
2387
  else if (d.type === 'reload') selfReload()
1336
2388
  else if (d.type === 'capture') onCapture(d)
2389
+ else if (d.type === 'check') onCheck(d)
2390
+ else if (d.type === 'checkClear') { clearMarks(); lastIssues = [] }
2391
+ else if (d.type === 'checkFocus') focusIssue(Number(d.i))
1337
2392
  else if (d.type === 'theme') setDark(d.dark)
1338
2393
  else if (d.type === 'demo') demo = !!d.on
1339
2394
  else if (d.type === 'edit') setTextEdit(!!d.on, d.hint !== false)
2395
+ else if (d.type === 'styleMode') setStyleMode(!!d.on)
2396
+ else if (d.type === 'styleSel') onStyleSel(d.selector)
2397
+ else if (d.type === 'style') styleApply(d.css)
2398
+ else if (d.type === 'tweak') applyTweak(d.vars, d.attrs)
1340
2399
  })
1341
2400
 
2401
+ // 无头浏览器(check_design / look_design 在电脑上另开的,见 browser.js)直接调这几个:
2402
+ // 量一遍、切深色、滚到底 / 滚到第几屏、取页面信息。都只读或只动这个页面自己
2403
+ function mainArea() {
2404
+ const areas = scrollAreas()
2405
+ if (areas.includes(se())) return se()
2406
+ return areas.sort((a, b) => (b.scrollHeight - b.clientHeight) - (a.scrollHeight - a.clientHeight))[0] || se()
2407
+ }
2408
+ try {
2409
+ Object.defineProperty(window, '__dshDesign', {
2410
+ value: Object.freeze({
2411
+ check: async (kind) => {
2412
+ await settle()
2413
+ const r = runCheck(String(kind || 'web'))
2414
+ return { issues: r.issues.map(({ el, ...rest }) => rest), metrics: r.metrics }
2415
+ },
2416
+ dark: (on) => { const has = hasDark(); if (has) setDark(!!on); return has },
2417
+ /** 调节滑块的值套到页面上(和右侧栏拖的时候一样) */
2418
+ tweak: (vars, attrs) => { applyTweak(vars, attrs); return true },
2419
+ settle: () => settle().then(() => true),
2420
+ /** 跳到别的屏的地方(路径和按钮上的字),量完查一下目标在不在 */
2421
+ links: () => linksOf().map(({ path, text }) => ({ path, text })),
2422
+ info: () => {
2423
+ const meta = document.querySelector('meta[name="dsh-design"]')
2424
+ return { title: document.title, meta: meta ? String(meta.getAttribute('content') || '').slice(0, 80) : '', page: Math.max(se().scrollHeight, innerHeight), w: innerWidth, h: innerHeight }
2425
+ },
2426
+ /** 'end':所有能滚的地方都滚到底;数字 i:主要的那块滚到第 i 屏(相邻两屏留出顶栏、底栏的高度,栏后面的内容下一屏能看到) */
2427
+ // 滚完等页面跟上再截图
2428
+ scroll: (where) => {
2429
+ const after = afterScroll
2430
+ if (where === 'end') { scrollAll(scrollAreas(), true); return after({ i: 0, of: 1 }) }
2431
+ const el = mainArea()
2432
+ const view = el === se() ? innerHeight : el.clientHeight
2433
+ const bars = [...barsAt('top'), ...barsAt('bottom')].reduce((m, b) => m + b.r.height, 0)
2434
+ const step = Math.max(Math.round(view * 0.5), Math.round(view - bars - 16))
2435
+ const max = Math.max(0, el.scrollHeight - view)
2436
+ const of = 1 + Math.ceil(max / step)
2437
+ const y = Math.min(max, Math.max(0, Number(where) || 0) * step)
2438
+ if (el === se()) scrollTo({ left: 0, top: y, behavior: 'instant' })
2439
+ else el.scrollTo({ left: 0, top: y, behavior: 'instant' })
2440
+ return after({ i: Number(where) || 0, of, y })
2441
+ },
2442
+ /** 一个元素的样式信息(和调样式选中它时报给右侧栏的一样:现在的值、用了哪些设计变量、各由哪条规则决定) */
2443
+ style: (sel) => {
2444
+ let el = null
2445
+ try { el = document.querySelector(sel) } catch { el = null }
2446
+ return el ? styleInfo(el) : null
2447
+ },
2448
+ /** 一个元素的某一项样式由哪条规则决定(体检找文件和行号用):{ sel, href, n, maybe? } / { inline: true } / null */
2449
+ ruleOf: (sel, prop) => {
2450
+ let el = null
2451
+ try { el = document.querySelector(sel) } catch { el = null }
2452
+ return el ? rulesOf(el, [String(prop)])[String(prop)] || null : null
2453
+ },
2454
+ /** 体检的问题附图:{ sel, other?, edge?, hscroll?, also? } → 摆好、画红框,给裁剪范围 { x, y, w, h, inside }(整页坐标) */
2455
+ crop: (o) => cropAt(o || {}),
2456
+ /** 一个元素的位置(先滚到能看见;按整页坐标给,截图裁剪就是按整页坐标裁的) */
2457
+ rect: (sel) => {
2458
+ let el = null
2459
+ try { el = document.querySelector(sel) } catch { el = null }
2460
+ if (!el) return null
2461
+ try { el.scrollIntoView({ block: 'center', behavior: 'instant' }) } catch { el.scrollIntoView() }
2462
+ const r = el.getBoundingClientRect()
2463
+ // 视口的范围也给:裁剪别伸出视口(外面截出来是空白)
2464
+ return { x: r.left + scrollX, y: r.top + scrollY, w: r.width, h: r.height, vx: scrollX, vy: scrollY, vw: innerWidth, vh: innerHeight }
2465
+ },
2466
+ }),
2467
+ })
2468
+ } catch { /* 页面自己占了这个名字 */ }
2469
+
1342
2470
  function selfReload() {
1343
2471
  try { sessionStorage.setItem(SEED_KEY, JSON.stringify({ ...seedNow(), at: Date.now() })) } catch { /* 隐私模式 / 太大 */ }
1344
2472
  location.reload()
@@ -1346,7 +2474,7 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1346
2474
 
1347
2475
  function onLoad() {
1348
2476
  if (parentWin) {
1349
- post({ type: 'hello', href: location.href })
2477
+ post({ type: 'hello', href: here() })
1350
2478
  setTimeout(() => boot(null), 1500) // 右侧栏没回话(老版本 / 不在 DSH 里)也照常就绪
1351
2479
  return
1352
2480
  }
@@ -1370,7 +2498,8 @@ background:rgba(20,20,22,.86);color:#e8e8ea;font:12px/18px -apple-system,system-
1370
2498
  /** 这一页在目录里的相对路径(/ 结尾的当 index.html) */
1371
2499
  function selfRel() {
1372
2500
  let p = ''
1373
- try { p = decodeURIComponent(location.pathname) } catch { p = location.pathname }
2501
+ const pn = hereUrl().pathname
2502
+ try { p = decodeURIComponent(pn) } catch { p = pn }
1374
2503
  p = p.replace(/^\/+/, '')
1375
2504
  if (!p || p.endsWith('/')) p += 'index.html'
1376
2505
  return p