update at 2026-02-09 16:09:44

This commit is contained in:
douboer
2026-02-09 16:09:44 +08:00
parent ffb7367d3a
commit 917f210dae
20 changed files with 790 additions and 184 deletions

View File

@@ -1,4 +1,4 @@
const { loadFontsManifest } = require('../../utils/mp/font-loader')
const { loadFontsManifest, loadDefaultConfig } = require('../../utils/mp/font-loader')
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
const {
shareSvgFromUserTap,
@@ -11,8 +11,11 @@ const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#
const FONT_SIZE_MIN = 20
const FONT_SIZE_MAX = 120
const PREVIEW_RENDER_FONT_SIZE = 120
const PREVIEW_MAX_CHARS_PER_LINE = 45
const PREVIEW_CACHE_LIMIT = 300
const MIN_PREVIEW_IMAGE_WIDTH = 24
const MAX_PREVIEW_IMAGE_WIDTH = 2400
const FEEDBACK_EMAIL = 'douboer@gmail.com'
// 临时使用本地图标 - 根据Figma annotation配置
const LOCAL_ICON_PATHS = {
@@ -58,6 +61,31 @@ function clampFontSize(value, fallback = PREVIEW_RENDER_FONT_SIZE) {
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(base)))
}
function normalizeSelectedFontIds(fontIds) {
if (!Array.isArray(fontIds)) {
return []
}
return Array.from(
new Set(
fontIds
.map((id) => String(id || '').trim())
.filter(Boolean),
),
)
}
function buildLegacyFontId(font) {
if (!font || typeof font !== 'object') {
return ''
}
const category = String(font.category || '').trim()
const name = String(font.name || '').trim()
if (!category || !name) {
return ''
}
return `${category}/${name}`
}
function extractErrorMessage(error, fallback) {
if (!error) {
return fallback
@@ -84,6 +112,25 @@ function writePngBufferToTempFile(pngBuffer, fontName) {
return filePath
}
function buildPreviewCacheKey(fontId, text, letterSpacing, maxCharsPerLine = PREVIEW_MAX_CHARS_PER_LINE) {
const safeFontId = String(fontId || '')
const safeText = String(text || '')
const spacingNumber = Number(letterSpacing)
const safeSpacing = Number.isFinite(spacingNumber) ? spacingNumber.toFixed(4) : '0.0000'
return [safeFontId, safeSpacing, String(maxCharsPerLine), safeText].join('::')
}
function setPreviewCache(previewCache, key, value) {
previewCache.set(key, value)
if (previewCache.size <= PREVIEW_CACHE_LIMIT) {
return
}
const oldestKey = previewCache.keys().next().value
if (oldestKey !== undefined) {
previewCache.delete(oldestKey)
}
}
function formatSvgNumber(value) {
const text = String(Number(value).toFixed(2))
return text.replace(/\.?0+$/, '')
@@ -146,9 +193,9 @@ function applyLocalStyleToFontItem(font, fontSize, color) {
Page({
data: {
inputText: '星程字体转换',
fontSize: FONT_SIZE_MAX,
fontSize: 50,
letterSpacingInput: '0',
textColor: '#000000',
textColor: '#dc2626',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
@@ -160,6 +207,7 @@ Page({
// 搜索功能
searchKeyword: '',
showSearch: true,
feedbackEmail: FEEDBACK_EMAIL,
},
async onLoad() {
@@ -170,6 +218,8 @@ Page({
console.log('============================')
this.fontMap = new Map()
this.legacyFontIdMap = new Map()
this.previewCache = new Map()
this.generateTimer = null
this.categoryExpandedMap = {}
@@ -177,7 +227,11 @@ Page({
},
onShow() {
const favorites = loadFavorites()
const rawFavorites = loadFavorites()
const favorites = this.normalizeFontIdList(rawFavorites)
if (normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',')) {
saveFavorites(favorites)
}
this.setData({ favorites })
this.updateFontTrees()
},
@@ -196,35 +250,126 @@ Page({
this.setData({ selectedFonts })
},
buildFontMaps(fonts) {
this.fontMap = new Map()
this.legacyFontIdMap = new Map()
if (!Array.isArray(fonts)) {
return
}
fonts.forEach((font) => {
const fontId = String(font.id || '').trim()
if (!fontId) {
return
}
this.fontMap.set(fontId, font)
const legacyId = buildLegacyFontId(font)
if (legacyId && !this.legacyFontIdMap.has(legacyId)) {
this.legacyFontIdMap.set(legacyId, fontId)
}
})
},
resolveFontId(fontId) {
const normalizedId = String(fontId || '').trim()
if (!normalizedId) {
return ''
}
const hasFontMap = this.fontMap instanceof Map && this.fontMap.size > 0
if (!hasFontMap) {
return normalizedId
}
if (this.fontMap.has(normalizedId)) {
return normalizedId
}
if (this.legacyFontIdMap instanceof Map && this.legacyFontIdMap.has(normalizedId)) {
return this.legacyFontIdMap.get(normalizedId) || ''
}
return ''
},
normalizeFontIdList(fontIds) {
const normalized = normalizeSelectedFontIds(fontIds)
return Array.from(
new Set(
normalized
.map((fontId) => this.resolveFontId(fontId))
.filter(Boolean),
),
)
},
async bootstrap() {
wx.showLoading({ title: '加载中', mask: true })
try {
const state = loadAppState()
const fonts = await loadFontsManifest()
const favorites = loadFavorites()
const isFirstLaunch = !state || !state.updatedAt
const [fonts, defaultConfig] = await Promise.all([
loadFontsManifest(),
loadDefaultConfig(),
])
this.buildFontMaps(fonts)
for (const font of fonts) {
this.fontMap.set(font.id, font)
const rawFavorites = loadFavorites()
const normalizedStoredFavorites = this.normalizeFontIdList(rawFavorites)
const normalizedDefaultFavorites = this.normalizeFontIdList(defaultConfig.favoriteFontIds)
const favorites = isFirstLaunch ? normalizedDefaultFavorites : normalizedStoredFavorites
if (
normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',') ||
(isFirstLaunch && favorites.length > 0)
) {
saveFavorites(favorites)
}
const initialInputText = isFirstLaunch
? ((typeof defaultConfig.inputText === 'string' && defaultConfig.inputText) || this.data.inputText)
: (state.inputText || this.data.inputText)
const initialFontSize = isFirstLaunch
? clampFontSize(defaultConfig.fontSize, this.data.fontSize)
: clampFontSize(state.fontSize, this.data.fontSize)
const initialLetterSpacingInput = isFirstLaunch
? (
typeof defaultConfig.letterSpacing === 'number'
? String(defaultConfig.letterSpacing)
: this.data.letterSpacingInput
)
: (
typeof state.letterSpacing === 'number'
? String(state.letterSpacing)
: this.data.letterSpacingInput
)
const initialTextColor = isFirstLaunch
? normalizeHexColor(defaultConfig.textColor || this.data.textColor)
: normalizeHexColor(state.textColor || this.data.textColor)
this.setData({
inputText: state.inputText || this.data.inputText,
fontSize: clampFontSize(state.fontSize, this.data.fontSize),
letterSpacingInput:
typeof state.letterSpacing === 'number' ? String(state.letterSpacing) : this.data.letterSpacingInput,
textColor: normalizeHexColor(state.textColor || this.data.textColor),
inputText: initialInputText,
fontSize: initialFontSize,
letterSpacingInput: initialLetterSpacingInput,
textColor: initialTextColor,
favorites,
})
this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
this.categoryExpandedMap = !isFirstLaunch && state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
? { ...state.categoryExpandedMap }
: {}
// 构建字体树
this.updateFontTrees()
// 如果有保存的选中字体,恢复它们
if (state.selectedFontIds && state.selectedFontIds.length > 0) {
const selectedFonts = state.selectedFontIds
// 恢复选中字体(首次使用走 default.json后续走本地用户配置
const rawSelectedFontIds = isFirstLaunch ? defaultConfig.selectedFontIds : state.selectedFontIds
const normalizedStoredSelectedIds = normalizeSelectedFontIds(rawSelectedFontIds)
const initialSelectedFontIds = this.normalizeFontIdList(normalizedStoredSelectedIds)
if (initialSelectedFontIds.length > 0) {
const selectedFonts = initialSelectedFontIds
.map(id => this.fontMap.get(id))
.filter(font => font)
.map(font => ({
@@ -240,6 +385,21 @@ Page({
this.updateFontTrees()
await this.generateAllPreviews()
}
const migratedSelectedIdsChanged = !isFirstLaunch &&
normalizedStoredSelectedIds.join(',') !== initialSelectedFontIds.join(',')
// 首次加载后立即固化配置,后续全部以用户配置为准
if (isFirstLaunch || migratedSelectedIdsChanged) {
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(font => font.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
categoryExpandedMap: this.categoryExpandedMap,
})
}
} catch (error) {
const message = error && error.message ? error.message : '初始化失败'
wx.showToast({ title: message, icon: 'none', duration: 2200 })
@@ -317,7 +477,7 @@ Page({
cat.allSelected = false
}
})
favoriteFonts.sort((a, b) => {
const categoryCompare = String(a.category || '').localeCompare(String(b.category || ''))
if (categoryCompare !== 0) return categoryCompare
@@ -430,7 +590,7 @@ Page({
},
// 切换分类全选/取消全选
onToggleSelectAllInCategory(e) {
async onToggleSelectAllInCategory(e) {
const category = e.currentTarget.dataset.category
if (!category) return
@@ -438,8 +598,9 @@ Page({
if (!categoryFonts || categoryFonts.fonts.length === 0) return
const allSelected = categoryFonts.allSelected
const selectedFonts = [...this.data.selectedFonts]
const selectedIdSet = new Set(selectedFonts.map(f => f.id))
const previousSelectedMap = new Map(this.data.selectedFonts.map(font => [font.id, font]))
const selectedIdSet = new Set(previousSelectedMap.keys())
const addedFontIds = []
if (allSelected) {
// 取消全选:移除该分类下的所有字体
@@ -449,6 +610,9 @@ Page({
} else {
// 全选:添加该分类下的所有字体
categoryFonts.fonts.forEach(font => {
if (!selectedIdSet.has(font.id)) {
addedFontIds.push(font.id)
}
selectedIdSet.add(font.id)
})
}
@@ -456,19 +620,32 @@ Page({
const newSelectedFonts = []
this.fontMap.forEach(font => {
if (selectedIdSet.has(font.id)) {
newSelectedFonts.push({
id: font.id,
name: font.name,
category: font.category,
showInPreview: true,
previewSrc: '',
})
const existingFont = previousSelectedMap.get(font.id)
if (existingFont) {
newSelectedFonts.push({
...existingFont,
showInPreview: typeof existingFont.showInPreview === 'boolean' ? existingFont.showInPreview : true,
})
} else {
newSelectedFonts.push({
id: font.id,
name: font.name,
category: font.category,
showInPreview: true,
previewSrc: '',
})
}
}
})
this.setData({ selectedFonts: newSelectedFonts })
this.updateFontTrees()
this.scheduleGenerate()
if (addedFontIds.length > 0) {
for (const fontId of addedFontIds) {
await this.generatePreviewForFont(fontId)
}
}
saveAppState({
inputText: this.data.inputText,
@@ -488,6 +665,26 @@ Page({
try {
const letterSpacing = Number(this.data.letterSpacingInput || 0)
const cacheKey = buildPreviewCacheKey(fontId, text, letterSpacing, PREVIEW_MAX_CHARS_PER_LINE)
const cached = this.previewCache.get(cacheKey)
if (cached) {
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].baseSvg = cached.svg
selectedFonts[index].baseWidth = cached.width
selectedFonts[index].baseHeight = cached.height
selectedFonts[index].renderFontSize = PREVIEW_RENDER_FONT_SIZE
selectedFonts[index] = applyLocalStyleToFontItem(
selectedFonts[index],
Number(this.data.fontSize),
this.data.textColor,
)
this.setData({ selectedFonts })
}
return
}
const result = await renderSvgByApi({
fontId,
@@ -496,7 +693,12 @@ Page({
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing,
maxCharsPerLine: 45,
maxCharsPerLine: PREVIEW_MAX_CHARS_PER_LINE,
})
setPreviewCache(this.previewCache, cacheKey, {
svg: result.svg,
width: result.width,
height: result.height,
})
// 更新对应字体的预览
@@ -850,4 +1052,42 @@ Page({
this.setData({ searchKeyword: keyword })
this.updateFontTrees()
},
onTapFeedbackEmail() {
const email = this.data.feedbackEmail || FEEDBACK_EMAIL
const mailtoUrl = `mailto:${email}`
// 微信环境对 mailto 支持不稳定:优先尝试跳转,失败时自动复制邮箱
if (typeof wx.openUrl === 'function') {
wx.openUrl({
url: mailtoUrl,
fail: () => {
this.copyFeedbackEmail(email)
},
})
return
}
this.copyFeedbackEmail(email)
},
copyFeedbackEmail(email) {
wx.setClipboardData({
data: String(email || FEEDBACK_EMAIL),
success: () => {
wx.showModal({
title: '已复制邮箱',
content: '请打开邮件应用并粘贴收件人地址发送反馈。',
showCancel: false,
confirmText: '知道了',
})
},
fail: () => {
wx.showToast({
title: '邮箱复制失败',
icon: 'none',
})
},
})
},
})