device === 'mini-program';
+export const isMobile = (device) => device === 'mobile' || isMiniProgram(device);
+
+export function normalizeDevice(device) {
+ return isMobile(device) ? 'mobile' : 'web';
+}
+
+/**
+ * 初始化给生成器本身使用的变量,避免部分样式在用户调整主题时产生冲突
+ */
+export function initGeneratorVars() {
+ const siteStylesheet = appendStyleSheet(GENERATOR_ID);
+ siteStylesheet.textContent = GENERATOR_VARIABLES;
+}
+
+export function getDefaultTheme(device) {
+ return isMobile(device) ? TDESIGN_MOBILE_THEME : TDESIGN_WEB_THEME;
+}
+
+export function getRecommendThemes(device) {
+ return isMobile(device) ? MOBILE_RECOMMEND_THEMES : WEB_RECOMMEND_THEMES;
+}
+
+/**
+ * 同步 site 的 亮暗模式给主题生成器 Web Component
+ */
+export function syncModeToGenerator() {
+ setUpModeObserver((theme) => {
+ const generator = document.querySelector('td-theme-generator');
+ if (!generator) return;
+ generator.setAttribute('theme-mode', theme);
+ });
+}
+
+export function findThemeByEnName(device, enName) {
+ const themes = getRecommendThemes(device);
+ for (const category of themes) {
+ const theme = category.options.find((t) => t.enName === enName);
+ if (theme) return theme;
+ }
+ return getDefaultTheme(device);
+}
+
+/**
+ * 初始化当前主题对应的样式表
+ */
+export function initThemeStyleSheet(themeName, device) {
+ const deviceType = normalizeDevice(device);
+ const theme = findThemeByEnName(deviceType, themeName);
+
+ const styleSheet = appendStyleSheet(CUSTOM_THEME_ID);
+ const darkStyleSheet = appendStyleSheet(CUSTOM_DARK_ID);
+ const extraStyleSheet = appendStyleSheet(CUSTOM_EXTRA_ID);
+
+ const { light, dark, extra } = theme.css;
+
+ styleSheet.textContent = light;
+ darkStyleSheet.textContent = dark;
+ extraStyleSheet.textContent = extra;
+
+ return theme;
+}
+
+export function exportCustomStyleSheet(device) {
+ const styleSheet = document.getElementById(CUSTOM_THEME_ID);
+ const darkStyleSheet = document.getElementById(CUSTOM_DARK_ID);
+ const extraStyleSheet = document.getElementById(CUSTOM_EXTRA_ID);
+
+ const cssString = extractRootContent(styleSheet?.textContent);
+ const darkCssString = extractRootContent(darkStyleSheet?.textContent);
+ const extraCssString = extraStyleSheet?.textContent || '';
+
+ let finalCssString;
+ if (isMiniProgram(device)) {
+ finalCssString = `
+ @media (prefers-color-scheme: light) {
+ page, .page {
+ ${cssString}
+ }
+ }
+ @media (prefers-color-scheme: dark) {
+ page, .page {
+ ${darkCssString}
+ }
+ }
+ ${extraCssString}
+ `;
+ } else {
+ finalCssString = `
+ :root, :root[theme-mode="light"] {
+ ${cssString}
+ }
+ :root[theme-mode="dark"] {
+ ${darkCssString}
+ }
+ ${extraCssString}
+ `;
+ }
+
+ const beautifyCssString = cssbeautify(finalCssString.trim());
+ const blob = new Blob([beautifyCssString], { type: 'text' });
+ const fileSuffix = isMiniProgram(device) ? 'wxss' : 'css';
+ downloadFile(blob, `theme.${fileSuffix}`);
+}
+
+export function modifyToken(tokenName, newVal, saveToLocal = true) {
+ // 获取所有可能包含 token 的样式表
+ const styleSheets = document.querySelectorAll(`#${CUSTOM_THEME_ID}, #${CUSTOM_DARK_ID}, #${CUSTOM_EXTRA_ID}`);
+
+ let tokenFound = false;
+ styleSheets.forEach((styleSheet) => {
+ const reg = new RegExp(`${tokenName}:\\s*(.*?);`);
+ const match = styleSheet.textContent.match(reg);
+
+ if (!match) return;
+ if (match[1] === newVal) {
+ tokenFound = true;
+ return;
+ }
+
+ const currentVal = match[1];
+ styleSheet.textContent = styleSheet.textContent.replace(`${tokenName}: ${currentVal}`, `${tokenName}: ${newVal}`);
+ tokenFound = true;
+
+ updateLocalToken(tokenName, saveToLocal ? newVal : null);
+ });
+
+ if (!tokenFound) {
+ console.warn(`CSS variable: ${tokenName} is not exist`);
+ }
+}
+
+export function getOptionFromLocal(optionName) {
+ const options = localStorage.getItem(CUSTOM_OPTIONS_ID);
+ if (!options) return;
+ const optionObj = JSON.parse(options);
+ return optionObj[optionName];
+}
+
+/**
+ * 如果不传入 `tokenName`,则返回所有的 `token` 对象
+ */
+export function getTokenFromLocal(tokenName) {
+ const tokens = localStorage.getItem(CUSTOM_TOKEN_ID);
+ if (!tokens) return;
+ const tokenObj = JSON.parse(tokens);
+ if (!tokenName) return tokenObj;
+ return tokenObj[tokenName];
+}
+
+/**
+ * @param {*} value 传入 `null` 或 `undefined`,则表示清除掉之前的存储
+ */
+export function updateLocalOption(optionName, value) {
+ if (value) {
+ const options = localStorage.getItem(CUSTOM_OPTIONS_ID) || '{}';
+ const optionObj = JSON.parse(options);
+ optionObj[optionName] = value;
+ localStorage.setItem(CUSTOM_OPTIONS_ID, JSON.stringify(optionObj));
+ } else {
+ clearLocalItem(CUSTOM_OPTIONS_ID, optionName);
+ }
+}
+
+/**
+ * @param {*} value 传入 `null` 或 `undefined`,则表示清除掉之前的存储
+ */
+export function updateLocalToken(tokenName, value) {
+ if (value) {
+ const tokens = localStorage.getItem(CUSTOM_TOKEN_ID) || '{}';
+ const tokenObj = JSON.parse(tokens);
+ tokenObj[tokenName] = value;
+ localStorage.setItem(CUSTOM_TOKEN_ID, JSON.stringify(tokenObj));
+ } else {
+ clearLocalItem(CUSTOM_TOKEN_ID, tokenName);
+ }
+}
+
+export function applyTokenFromLocal() {
+ const token = localStorage.getItem(CUSTOM_TOKEN_ID);
+ if (!token) return;
+
+ const tokenObj = JSON.parse(token);
+ Object.entries(tokenObj).forEach(([key, value]) => {
+ modifyToken(key, value);
+ });
+}
+
+export function clearLocalTheme() {
+ localStorage.removeItem(CUSTOM_OPTIONS_ID);
+ localStorage.removeItem(CUSTOM_TOKEN_ID);
+}
+
+export function convertFromHex(color, format) {
+ return `(${Color.colorTransform(color, 'hex', format).join(',')})`;
+}
+
+export function generateBrandPalette(hex, remainInput = false) {
+ const lowCaseHex = hex.toLowerCase();
+
+ const [{ colors, primary }] = Color.getColorGradations({
+ colors: [lowCaseHex],
+ step: 10,
+ remainInput,
+ });
+
+ const isTencentBlue = lowCaseHex === TENCENT_BLUE.toLowerCase();
+ const validPrimary = typeof primary === 'number' && !isNaN(primary) ? primary : 6;
+
+ const lightBrandIdx = isTencentBlue ? 7 : validPrimary + 1;
+ const lightPalette = [...colors];
+
+ const darkPalette = isTencentBlue ? TENCENT_BLUE_DARK_PALETTE : [...colors].reverse();
+ const darkBrandIdx = isTencentBlue ? 8 : 6;
+
+ return { lightPalette, lightBrandIdx, darkPalette, darkBrandIdx };
+}
+
+export function generateFunctionalPalette(hex, step = 10) {
+ const lowCaseHex = hex.toLowerCase();
+ const [{ colors }] = Color.getColorGradations({
+ colors: [lowCaseHex],
+ step,
+ });
+
+ const lightPalette = [...colors];
+ const darkPalette = [...colors].reverse();
+
+ return { lightPalette, darkPalette };
+}
+
+export function generateNeutralPalette(hex, isRelatedTheme) {
+ if (isRelatedTheme) {
+ return Color.getNeutralColor(hex);
+ } else {
+ return generateFunctionalPalette(hex, 14).lightPalette;
+ }
+}
+
+/**
+ * @param {'init' | 'update'} trigger - 触发类型
+ */
+export function updateStyleSheetColor(type, lightPalette, darkPalette, trigger) {
+ const styleSheet = appendStyleSheet(CUSTOM_THEME_ID);
+ const darkStyleSheet = appendStyleSheet(CUSTOM_DARK_ID);
+ const updateColorTokens = (styleSheet, palette) => {
+ palette.forEach((color, index) => {
+ const tokenName = `--td-${type}-color-${index + 1}`;
+ const regExp = new RegExp(`${tokenName}:.*?;`, 'g');
+ let replacement = `${tokenName}: ${color};`;
+ if (trigger === 'init') {
+ // 确保不覆盖用户本地自定义的值
+ replacement = `${tokenName}: ${getTokenFromLocal(tokenName) || color};`;
+ }
+ if (trigger === 'update') {
+ updateLocalToken(tokenName, null); // 清除本地存储的颜色 Token
+ }
+ styleSheet.textContent = styleSheet.textContent.replace(regExp, replacement);
+ });
+ };
+
+ updateColorTokens(styleSheet, lightPalette);
+ updateColorTokens(darkStyleSheet, darkPalette);
+}
+
+export function syncColorTokensToStyle(lightTokenMap, darkTokenMap) {
+ const styleSheet = appendStyleSheet(CUSTOM_THEME_ID);
+ const darkStyleSheet = appendStyleSheet(CUSTOM_DARK_ID);
+ const updateColorTokens = (styleSheet, tokenMap) => {
+ tokenMap.forEach(({ name, idx }) => {
+ const regExp = new RegExp(`${name}:.*?;`, 'g');
+ const replacement = `${name}: var(--td-brand-color-${idx});`;
+ styleSheet.textContent = styleSheet.textContent.replace(regExp, replacement);
+ });
+ };
+
+ updateColorTokens(styleSheet, lightTokenMap);
+ updateColorTokens(darkStyleSheet, darkTokenMap);
+}
+
+/**
+ * 根据 token 名称获取对应的索引
+ * 例如 `--td-brand-focus` -> 2
+ */
+export function collectTokenIndexes(tokenArr) {
+ const isDarkMode = document.documentElement.getAttribute('theme-mode') === 'dark';
+ const targetCss = document.querySelector(isDarkMode ? `#${CUSTOM_DARK_ID}` : `#${CUSTOM_THEME_ID}`);
+
+ return tokenArr
+ .map((token) => {
+ const reg = new RegExp(`${token}:\\s*var\\((--td-[\\w-]+)\\)`, 'i');
+ const match = targetCss?.textContent.match(reg);
+ if (match) {
+ return {
+ name: token,
+ idx: parseInt(match[1].match(/(\d+)$/)?.[1], 10),
+ };
+ }
+ return null;
+ })
+ .filter(Boolean)
+ .sort((a, b) => a.idx - b.idx);
+}
diff --git a/packages/theme-generator/src/common/Themes/iframe.js b/packages/theme-generator/src/common/themes/iframe.js
similarity index 91%
rename from packages/theme-generator/src/common/Themes/iframe.js
rename to packages/theme-generator/src/common/themes/iframe.js
index 84b5ecb2..05287a43 100644
--- a/packages/theme-generator/src/common/Themes/iframe.js
+++ b/packages/theme-generator/src/common/themes/iframe.js
@@ -1,5 +1,5 @@
-import { extractRootContent } from '../utils';
-import { CUSTOM_DARK_ID, CUSTOM_THEME_ID, isMiniProgram, isMobile } from './';
+import { extractRootContent, getThemeMode, setUpModeObserver } from '../utils';
+import { CUSTOM_DARK_ID, CUSTOM_THEME_ID, isMiniProgram, isMobile } from './core';
/* ----- 同步亮暗模式 ----- */
function handleMobileModeChange(iframe, mode) {
@@ -90,23 +90,11 @@ function watchThemeModeChange(iframe) {
};
// 初始化
- const mode = document.documentElement.getAttribute('theme-mode');
- handleModeChange(mode);
+ handleModeChange(getThemeMode());
- const observer = new MutationObserver((mutationsList) => {
- for (const mutation of mutationsList) {
- if (mutation.type === 'attributes' && mutation.attributeName === 'theme-mode') {
- const newMode = document.documentElement.getAttribute('theme-mode');
- handleModeChange(newMode);
- }
- }
+ const observer = setUpModeObserver((newMode) => {
+ handleModeChange(newMode);
});
-
- observer.observe(document.documentElement, {
- attributes: true,
- attributeFilter: ['theme-mode'],
- });
-
return observer;
}
diff --git a/packages/theme-generator/src/common/themes/index.js b/packages/theme-generator/src/common/themes/index.js
new file mode 100644
index 00000000..b5870fcf
--- /dev/null
+++ b/packages/theme-generator/src/common/themes/index.js
@@ -0,0 +1,4 @@
+export * from './built-in';
+export * from './core';
+export * from './iframe';
+export * from './store';
diff --git a/packages/theme-generator/src/common/themes/store.js b/packages/theme-generator/src/common/themes/store.js
new file mode 100644
index 00000000..630713f7
--- /dev/null
+++ b/packages/theme-generator/src/common/themes/store.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+
+import { DEFAULT_THEME_META, TDESIGN_WEB_THEME } from './built-in';
+import { clearLocalTheme, getDefaultTheme, getOptionFromLocal, initThemeStyleSheet, updateLocalOption } from './core';
+
+export const themeStore = Vue.observable({
+ device: 'web',
+ theme: TDESIGN_WEB_THEME,
+ brandColor: getOptionFromLocal('color') || DEFAULT_THEME_META.value,
+ refreshId: 0, // 用于强制刷新绑定了 key 的组件 UI
+ updateDevice(device) {
+ this.device = device;
+ this.theme = getInitialTheme(device);
+ },
+ updateTheme(theme) {
+ this.theme = theme;
+ initThemeStyleSheet(theme.enName, this.device);
+ clearLocalTheme();
+ updateLocalOption('theme', theme.enName !== DEFAULT_THEME_META.enName ? theme.enName : null);
+ this.updateBrandColor(theme.value);
+ this.incrementRefreshId();
+ },
+ resetTheme() {
+ this.updateTheme(getDefaultTheme(this.device));
+ },
+ updateBrandColor(color) {
+ this.brandColor = color;
+ document.documentElement.style.setProperty('--brand-main', color);
+ },
+ incrementRefreshId() {
+ this.refreshId++;
+ },
+});
+
+function getInitialTheme(device = 'web') {
+ const localThemeName = getOptionFromLocal('theme') || DEFAULT_THEME_META.enName;
+ const theme = initThemeStyleSheet(localThemeName, device);
+ return theme;
+}
diff --git a/packages/theme-generator/src/common/utils/index.js b/packages/theme-generator/src/common/utils/index.js
index 98e9efd1..cf8e0f8f 100644
--- a/packages/theme-generator/src/common/utils/index.js
+++ b/packages/theme-generator/src/common/utils/index.js
@@ -1,27 +1,35 @@
-export * from 'tdesign-vue/es/_common/js/color-picker';
+export * from './animation';
-import GENERATOR_VARIABLES from '!raw-loader!./vars.css';
-const GENERATOR_ID = 'TDESIGN_GENERATOR_SYMBOL';
+/**
+ * 获取指定 CSS Token 对应的数值
+ */
+export function getTokenValue(name) {
+ const isDarkMode = document.documentElement.getAttribute('theme-mode') === 'dark';
+ const rootElement = isDarkMode ? document.querySelector('[theme-mode="dark"]') : document.documentElement;
+ return window.getComputedStyle(rootElement).getPropertyValue(name).toLowerCase().trim();
+}
/**
- * 初始化给生成器本身使用的样式变量,避免和 TDesign 冲突
+ * 获取当前亮暗模式 (light / dark)
*/
-export function initGeneratorVars() {
- const siteStylesheet = appendStyleSheet(GENERATOR_ID);
- siteStylesheet.textContent = GENERATOR_VARIABLES;
+export function getThemeMode() {
+ return document.documentElement.getAttribute('theme-mode') || 'light';
}
/**
- * 同步亮暗模式给 Web Component
+ * 创建亮暗变化监听器
*/
-export function syncThemeToGenerator() {
+export function setUpModeObserver(handler) {
+ let mode = getThemeMode();
+
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
- if (mutation.type === 'attributes' && mutation.attributeName === 'theme-mode') {
- const generator = document.querySelector('td-theme-generator');
- if (!generator) return;
- const themeMode = document.documentElement.getAttribute('theme-mode');
- generator.setAttribute('theme-mode', themeMode);
+ if (mutation.type === 'attributes') {
+ const newMode = getThemeMode();
+ if (newMode !== mode) {
+ mode = newMode;
+ handler(mode);
+ }
}
}
});
@@ -30,6 +38,8 @@ export function syncThemeToGenerator() {
attributes: true,
attributeFilter: ['theme-mode'],
});
+
+ return observer;
}
/**
@@ -72,23 +82,6 @@ export function downloadFile(blob, fileName) {
a.click();
}
-/**
- * 从 CSS 文本中移除指定的 Token 字符串 / 数组
- * - e.g. `"--td-xxx-1"` or `["--td-xxx-2", "--td-xxx-3"]`
- */
-export function removeCssProperties(cssText, properties) {
- if (!Array.isArray(properties)) {
- properties = [properties];
- }
-
- properties.forEach((property) => {
- const reg = new RegExp(`${property}:\\s*.*?;`, 'g');
- cssText = cssText.replace(reg, '');
- });
-
- return cssText;
-}
-
/**
* 从 CSS 文本中提取 `:root` 中的内容
*/
diff --git a/packages/theme-generator/src/dock/svg/AdjustSvg.vue b/packages/theme-generator/src/dock/svg/AdjustSvg.vue
deleted file mode 100644
index 4d5b81cb..00000000
--- a/packages/theme-generator/src/dock/svg/AdjustSvg.vue
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/packages/theme-generator/src/recommend-themes/PickedSvg.vue b/packages/theme-generator/src/float-dock/components/RecommendThemes/PickedSvg.vue
similarity index 100%
rename from packages/theme-generator/src/recommend-themes/PickedSvg.vue
rename to packages/theme-generator/src/float-dock/components/RecommendThemes/PickedSvg.vue
diff --git a/packages/theme-generator/src/recommend-themes/index.vue b/packages/theme-generator/src/float-dock/components/RecommendThemes/index.vue
similarity index 77%
rename from packages/theme-generator/src/recommend-themes/index.vue
rename to packages/theme-generator/src/float-dock/components/RecommendThemes/index.vue
index 8c184b13..8e638b82 100644
--- a/packages/theme-generator/src/recommend-themes/index.vue
+++ b/packages/theme-generator/src/float-dock/components/RecommendThemes/index.vue
@@ -1,11 +1,12 @@
+
-
+
- {{ lang.dock.recommendTitle }}
+ {{ isEn ? type.enTitle : type.title }}