canvas-scale.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /* ============================================================
  2. * lib/player/canvas-scale.js · 画布等比缩放(固定 1280×720 画布)
  3. * ------------------------------------------------------------
  4. * 问题:demo 页内部用 vw/vh,iframe/窗口尺寸一变(手机横屏 926×428、
  5. * 16:10 等),版式随视口重排,与电脑 16:9 观感不一致。
  6. * 方案:固定 1280×720 画布,JS 按 min(innerW/1280, innerH/720) 等比
  7. * scale 并居中(letterbox)。任何视口下版式与 1280×720 完全一致,
  8. * 只整体缩放;16:10 / 手机横竖屏自动适配,无需特判。
  9. * 关键一步(画布内 vw/vh 全量归零):初始化时把本页样式表(含
  10. * tokens/base/style.css 与页内 <style>)和行内 style 里的
  11. * vw/vh/dvh 按 1280×720 等效换算成 px(1vw=12.8px,1vh=7.2px),
  12. * 并删除全部 @media 规则(画布恒 1280 宽,窄屏规则永不生效)。
  13. * 此后版式与窗口彻底解耦,缩放只由 transform 完成。
  14. * 两种模式(自动检测):
  15. * A. 包裹模式(普通 demo 页):body 现有内容包进 .cv-stage 缩放。
  16. * B. shell 模式(总控页,含 .shell-stage):直接把 .shell-stage 当画布
  17. * (1280×720 + scale),嵌入的 section / iframe 全部随之缩放;
  18. * !important 压过 deck.js 全屏 letterbox 的 width/height 声明,
  19. * 全屏仅负责隐藏系统 UI,缩放始终由本脚本负责。
  20. * 规则(见 .claude/rules/page-craft.md「画布等比缩放规范」):
  21. * - 新页面禁止再写 vw/vh 与窄屏 @media;画布内 1vw=12.8px、1vh=7.2px。
  22. * 用法:demo 页在 deck.js 之前引一次;shell 页在 deck.init() 之后引一次。
  23. * <script src="../../../../../../lib/player/canvas-scale.js"></script>
  24. * 纪律:只用 var(--...),不新增色值;file:// 直接可用;零依赖;幂等。
  25. * ============================================================ */
  26. (function () {
  27. 'use strict';
  28. if (window.__CANVAS_SCALE__) return; // 幂等
  29. window.__CANVAS_SCALE__ = true;
  30. var W = 1280, H = 720, PXW = 12.8, PXH = 7.2;
  31. var VW_RE = /(\d*\.?\d+)(d?v[hw])\b/g;
  32. /* Nvw/Nvh/Ndvh → 1280×720 画布等效 px(1vw=12.8px,1vh=7.2px) */
  33. function pxify(text) {
  34. return text.replace(VW_RE, function (m, num, unit) {
  35. var n = parseFloat(num);
  36. if (unit === 'vw') return round1(n * PXW) + 'px';
  37. return round1(n * PXH) + 'px'; // vh / dvh 都按画布高
  38. });
  39. }
  40. function round1(n) { return Math.round(n * 10) / 10; }
  41. function hasViewportUnit(text) { return /\d[ \t]*d?v[hw]\b/.test(text); }
  42. /* 样式表改写:vw/vh→px;@media 整条删除(画布恒 1280 宽)。
  43. 仅处理同源样式表(本仓库全部本地),跨域/解析失败静默跳过。 */
  44. function rewriteSheet(sheet) {
  45. if (!sheet) return;
  46. if (sheet.ownerNode && sheet.ownerNode.id === 'cv-scale-style') return; // 跳过本脚本注入的样式
  47. var rules;
  48. try { rules = sheet.cssRules; } catch (e) { return; }
  49. if (!rules) return;
  50. rewriteRuleList(rules);
  51. }
  52. function rewriteRuleList(list) {
  53. Array.prototype.forEach.call(list, function (rule) {
  54. var type = rule.type;
  55. if (type === 4 /* CSSMediaRule */ || type === 12 /* CSSSupportsRule */) {
  56. deleteRule(list, rule); // @media/@supports 整条移除
  57. return;
  58. }
  59. if ((type === 1 /* CSSStyleRule */ || type === 5 /* CSSFontFaceRule */) &&
  60. hasViewportUnit(rule.cssText)) {
  61. var css = pxify(rule.cssText);
  62. try { rule.style.cssText = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}')); }
  63. catch (e) { /* 保底:无法就地改则跳过 */ }
  64. }
  65. });
  66. }
  67. function deleteRule(list, rule) {
  68. try {
  69. var idx = Array.prototype.indexOf.call(list, rule);
  70. if (idx >= 0 && list.deleteRule) list.deleteRule(idx);
  71. } catch (e) { /* 忽略 */ }
  72. }
  73. /* 行内 style:clamp(...vw...) 等直接写在 style 属性上,样式表改写覆盖不到 */
  74. function rewriteInlineStyles(doc) {
  75. var els = doc.querySelectorAll('[style]');
  76. Array.prototype.forEach.call(els, function (el) {
  77. var s = el.getAttribute('style');
  78. if (s && hasViewportUnit(s)) el.setAttribute('style', pxify(s));
  79. });
  80. }
  81. var CSS_TEXT =
  82. /* 画布页高度基准:--screen-h 由「视口高」改为「画布高」(仅本页覆盖,不动 tokens.css) */
  83. 'html.cv-fit { --screen-h: 720px; }\n' +
  84. 'html.cv-fit, html.cv-fit body { width:100%; height:100%; margin:0; padding:0; overflow:hidden; background:var(--bg-0); }\n' +
  85. /* 包裹模式:画布舞台固定 1280×720,transform-origin 左上,JS scale + 居中 */
  86. 'html.cv-fit .cv-stage {\n' +
  87. ' position: fixed; left: 0; top: 0;\n' +
  88. ' width: 1280px; height: 720px;\n' +
  89. ' transform-origin: 0 0;\n' +
  90. ' background: var(--bg-0);\n' +
  91. ' overflow: hidden;\n' +
  92. '}\n' +
  93. /* shell 模式:.shell-stage 本身即画布(!important 压过 deck.js 全屏规则,
  94. 全屏时 deck 只隐藏系统 UI;top/left 由 fit() 内联控制,优先级更高) */
  95. 'html.cv-fit .shell-stage {\n' +
  96. ' width: 1280px !important; height: 720px !important;\n' +
  97. ' transform-origin: 0 0 !important;\n' +
  98. ' overflow: hidden;\n' +
  99. '}\n' +
  100. /* 兜底:base.css 的 .screen 宽高已由 vw/vh 归零改写,这里再锚一次 */
  101. 'html.cv-fit .screen { width: 1280px; }\n' +
  102. /* 页内 style.css 的 100vh 高度基准已归零,这里再锚一次 */
  103. 'html.cv-fit .page { height: 720px; }\n' +
  104. 'html.cv-fit .screen{ min-height: 720px; }\n' +
  105. 'html.cv-fit .cover { min-height: 720px; }\n';
  106. function makeFit(el) {
  107. return function fit() {
  108. var s = Math.min(window.innerWidth / W, window.innerHeight / H);
  109. if (!isFinite(s) || s <= 0) s = 1;
  110. el.style.transform = 'scale(' + s + ')';
  111. el.style.left = Math.round((window.innerWidth - W * s) / 2) + 'px';
  112. el.style.top = Math.round((window.innerHeight - H * s) / 2) + 'px';
  113. document.documentElement.style.setProperty('--cv-scale', String(Math.round(s * 1000) / 1000));
  114. };
  115. }
  116. function init() {
  117. var docEl = document.documentElement;
  118. if (docEl.classList.contains('cv-fit')) return; // 已初始化
  119. var body = document.body;
  120. if (!body) { document.addEventListener('DOMContentLoaded', init, { once: true }); return; }
  121. docEl.classList.add('cv-fit');
  122. // 1) 画布内 vw/vh 全量归零(样式表 + 行内 style),@media 删除
  123. Array.prototype.forEach.call(document.styleSheets, rewriteSheet);
  124. rewriteInlineStyles(document);
  125. // 2) 注入画布样式(在改写之后,自身不受 pxify 影响)
  126. var st = document.createElement('style');
  127. st.id = 'cv-scale-style';
  128. st.textContent = CSS_TEXT;
  129. document.head.appendChild(st);
  130. // 3) shell 模式:.shell-stage 即画布(shell chrome 在画布外,保持原生尺寸)
  131. var shellStage = body.querySelector('.shell-stage');
  132. if (shellStage) {
  133. var fitShell = makeFit(shellStage);
  134. window.addEventListener('resize', fitShell, { passive: true });
  135. window.addEventListener('orientationchange', fitShell);
  136. fitShell();
  137. // shell embed 模式:deck.init() 异步把 demo 屏克隆进 .shell-stage,
  138. // 屏内行内 style 携带 vw/vh → 到达后重写一遍(幂等)
  139. var moTimer = null;
  140. var mo = new MutationObserver(function () {
  141. clearTimeout(moTimer);
  142. moTimer = setTimeout(function () {
  143. Array.prototype.forEach.call(document.styleSheets, rewriteSheet);
  144. rewriteInlineStyles(document);
  145. fitShell();
  146. }, 80);
  147. });
  148. mo.observe(shellStage, { childList: true, subtree: true });
  149. return;
  150. }
  151. // 4) 包裹模式:body 现有全部子节点移入 .cv-stage(保留顺序与事件绑定)
  152. var stage = document.createElement('div');
  153. stage.className = 'cv-stage';
  154. stage.id = 'cv-stage';
  155. var moving = Array.prototype.slice.call(body.childNodes);
  156. for (var i = 0; i < moving.length; i++) stage.appendChild(moving[i]);
  157. body.appendChild(stage);
  158. var fit = makeFit(stage);
  159. window.addEventListener('resize', fit, { passive: true });
  160. window.addEventListener('orientationchange', fit);
  161. fit();
  162. }
  163. // 同步脚本:body 已在 DOM 即刻执行;未就绪则等 DOMContentLoaded
  164. if (document.readyState === 'loading' && !document.body) {
  165. document.addEventListener('DOMContentLoaded', init, { once: true });
  166. } else {
  167. init();
  168. }
  169. })();