helper.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import config from '@/sites/config/env';
  2. import { reactive, watch, onMounted, computed, onBeforeUnmount } from 'vue';
  3. import { useRoute } from 'vue-router';
  4. import configs from '../../../../config.json';
  5. type Obj = {
  6. [k: string]: any;
  7. };
  8. type Store = {
  9. variables: Obj[];
  10. variablesMap: Obj;
  11. rawStyles: Obj;
  12. [k: string]: any;
  13. };
  14. const components = configs.nav.map(({ packages }) => packages.map(({ name }) => name)).flat(1);
  15. const getRawFileText = async function (url: string) {
  16. const response = await fetch(url);
  17. const res = await response.text();
  18. return res;
  19. };
  20. const getInputType = (value: string) => {
  21. if (/^\d+$/.test(value)) {
  22. return 'number';
  23. }
  24. if (/^#[A-Za-z0-9]+$/.test(value)) {
  25. return 'hex';
  26. }
  27. if (/^(rgb|hsl)a?\((\s*\/?\s*[+-]?\d*(\.\d+)?%?,?\s*){3,5}\)/gim.test(value)) {
  28. return 'rgb';
  29. }
  30. return 'input';
  31. };
  32. const loadScript = async (url: string) =>
  33. new Promise((resolve, reject) => {
  34. const script = document.createElement('script');
  35. script.onload = resolve;
  36. script.onerror = reject;
  37. script.src = url;
  38. document.head.appendChild(script);
  39. });
  40. const awaitIframe = async () => {
  41. while (!window.frames[0] || !window.frames[0].document.querySelector('#nav')) {
  42. await new Promise((r) => setTimeout(r, 100));
  43. }
  44. };
  45. // 提取变量
  46. const extractVariables = (matched: string[], name: string, lowerCaseName: string) =>
  47. matched.reduce((res, str) => {
  48. const extract = str.replace(/\s+!default/, '').match(/(.*):(?:\s+)?([\s\S]*)(?:\s+)?;/);
  49. if (extract) {
  50. const key = extract[1];
  51. const value = extract[2];
  52. res.push({
  53. name, // 组件名
  54. lowerCaseName, // 组件名小写
  55. key, // 变量名
  56. rawValue: value, // 原始值
  57. computedRawValue: '', // 计算后的原始值
  58. value, // 编辑的值
  59. // 编辑的类型
  60. inputType: getInputType(value)
  61. });
  62. }
  63. return res;
  64. }, [] as Obj[]);
  65. // 提取样式代码,只保留有使用变量的行
  66. const extractStyle = (style: string) => {
  67. if (!store.variables.length) {
  68. return '';
  69. }
  70. const extract = style.split('\n').filter((str) => {
  71. const matched = str.match(/\$[\w-]+\b/g);
  72. if (matched) {
  73. return matched.some((k) => store.variablesMap[k]);
  74. }
  75. return /(\{|\})/.test(str);
  76. });
  77. return extract.join('');
  78. };
  79. const parseSassVariables = (text: string, components: string[]) => {
  80. const matchedComponentVariables = components
  81. .map((name) => {
  82. const lowerCaseName = name.toLowerCase();
  83. const reg = new RegExp(`(?<!\\/\\/(\\s+)?)\\$(${name}|${lowerCaseName})\\b[\\w-]+:[^:;]+;`, 'g');
  84. const matched = text.match(reg);
  85. if (matched) {
  86. return extractVariables(matched, name, lowerCaseName);
  87. }
  88. })
  89. .filter(Boolean)
  90. .flat(2);
  91. const baseVariablesReg = new RegExp(
  92. `\\$(?!(${matchedComponentVariables
  93. .map((item) => (item && `${item.name}|${item.lowerCaseName}`) || '')
  94. .join('|')})\\b)[\\w-]+:[^:]+;`,
  95. 'g'
  96. );
  97. const variables = matchedComponentVariables as Obj[];
  98. const matchedBaseVariables = text.match(baseVariablesReg);
  99. // 组件变量以外的都作为基础变量
  100. if (matchedBaseVariables) {
  101. variables.unshift(...extractVariables(matchedBaseVariables, 'Base', 'base'));
  102. }
  103. return variables;
  104. };
  105. const cachedStyles: Obj = {};
  106. const store: Store = reactive({
  107. init: false,
  108. variables: [],
  109. variablesMap: {},
  110. rawStyles: {}
  111. });
  112. const getSassVariables = async () => {
  113. // vite 启动模式 bug 待修复
  114. const rawVariablesText = await getRawFileText(`${config.themeUrl}/styles/variables.scss_source`);
  115. const rawVariables = parseSassVariables(rawVariablesText, components);
  116. // 固定自定义主题的访问链接: https://nutui.jd.com/theme/?theme=自定义变量的文件地址#/
  117. // e.g. https://nutui.jd.com/theme/?theme=xxx.com%2variables.scss#/
  118. // vite issue https://github.com/vitejs/vite/issues/6894
  119. const params = new URLSearchParams(window.location.search);
  120. const customUrl = params.get('theme');
  121. if (customUrl) {
  122. const customVariablesText = await getRawFileText(customUrl);
  123. const customVariables = parseSassVariables(customVariablesText, components);
  124. // merge
  125. rawVariables.forEach((item) => {
  126. const custom = customVariables.find(({ key }) => key === item.key);
  127. if (custom) {
  128. item.value = custom.value;
  129. }
  130. });
  131. }
  132. const variablesMap = rawVariables.reduce((map, item) => {
  133. map[item.key] = 1;
  134. return map;
  135. }, {});
  136. store.variables = rawVariables;
  137. store.variablesMap = variablesMap;
  138. };
  139. export const getRawSassStyle = async (name: string): Promise<void> => {
  140. if (!store.rawStyles[name]) {
  141. const style = await getRawFileText(`${config.themeUrl}/packages/${name}/index.scss_source`);
  142. store.rawStyles[name] = style;
  143. }
  144. };
  145. export const useThemeEditor = function (): Obj {
  146. const route = useRoute();
  147. const cssText = computed(() => {
  148. const variablesText = store.variables.map(({ key, value }) => `${key}:${value}`).join(';');
  149. const styleText = Object.keys(store.rawStyles)
  150. .map((name) => {
  151. cachedStyles[name] = cachedStyles[name] || extractStyle(store.rawStyles[name]);
  152. return cachedStyles[name] || '';
  153. })
  154. .join('');
  155. return `${variablesText};${styleText}`;
  156. });
  157. const formItems = computed(() => {
  158. const name = route.path.substring(1);
  159. return store.variables.filter(({ lowerCaseName }) => lowerCaseName === name);
  160. });
  161. onMounted(async () => {
  162. if (!store.init) {
  163. await Promise.all([getSassVariables(), loadScript('https://cdnout.com/sass.js/sass.sync.min.js')]);
  164. store.init = true;
  165. }
  166. });
  167. watch(
  168. () => route.path,
  169. (path) => {
  170. const name = path.substring(1);
  171. if (name !== 'base') {
  172. getRawSassStyle(name);
  173. }
  174. },
  175. {
  176. immediate: true
  177. }
  178. );
  179. let timer: any = null;
  180. onBeforeUnmount(() => {
  181. clearTimeout(timer);
  182. });
  183. watch(
  184. () => cssText.value,
  185. (css) => {
  186. clearTimeout(timer);
  187. timer = setTimeout(() => {
  188. const Sass = (window as any).Sass;
  189. Sass &&
  190. Sass.compile(css, async (res: Obj) => {
  191. await awaitIframe();
  192. const iframe = window.frames[0] as any;
  193. if (res.text && iframe) {
  194. if (!iframe.__styleEl) {
  195. const style = iframe.document.createElement('style');
  196. style.id = 'theme';
  197. iframe.__styleEl = style;
  198. }
  199. iframe.__styleEl.innerHTML = res.text;
  200. iframe.document.head.appendChild(iframe.__styleEl);
  201. }
  202. });
  203. }, 300);
  204. },
  205. { immediate: true }
  206. );
  207. return {
  208. formItems,
  209. downloadScssVariables() {
  210. if (!store.variables.length) {
  211. return;
  212. }
  213. let temp = '';
  214. const variablesText = store.variables
  215. .map(({ name, key, value }) => {
  216. let comment = '';
  217. if (temp !== name) {
  218. temp = name;
  219. comment = `\n// ${name}\n`;
  220. }
  221. return comment + `${key}: ${value};`;
  222. })
  223. .join('\n');
  224. download(`// NutUI主题定制\n${variablesText}`, 'variables.scss');
  225. }
  226. };
  227. };
  228. function download(content: string, filename: string) {
  229. const eleLink = document.createElement('a');
  230. eleLink.download = filename;
  231. eleLink.style.display = 'none';
  232. const blob = new Blob([content]);
  233. eleLink.href = URL.createObjectURL(blob);
  234. document.body.appendChild(eleLink);
  235. eleLink.click();
  236. document.body.removeChild(eleLink);
  237. }