Files
font2pic/miniprogram/pages/index/index.js
2026-02-09 16:09:44 +08:00

1094 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { loadFontsManifest, loadDefaultConfig } = require('../../utils/mp/font-loader')
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
const {
shareSvgFromUserTap,
} = require('../../utils/mp/file-export')
const { savePngToAlbum } = require('../../utils/mp/canvas-export')
const { renderSvgByApi, renderPngByApi } = require('../../utils/mp/render-api')
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
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 = {
logo: '/assets/icons/webicon.png',
fontSizeDecrease: '/assets/icons/font-size-decrease.svg',
fontSizeIncrease: '/assets/icons/font-size-increase.svg',
chooseColor: '/assets/icons/choose-color.svg',
// 导出按钮根据Figma annotation
exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮
exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮
download: '/assets/icons/download.svg', // 下载图标
// 输入框图标
content: '/assets/icons/content.svg', // 输入框左侧绿色图标
// 字体树图标根据Figma annotation
fontIcon: '/assets/icons/font-icon.svg', // 字体item图标
expandIcon: '/assets/icons/expand.svg', // 展开分类图标
collapseIcon: '/assets/icons/expand.svg', // 折叠使用同一图标,通过旋转实现
favoriteIcon: '/assets/icons/favorite.svg', // 收藏图标(未收藏)
favoriteRedIcon: '/assets/icons/favorite-red.svg', // 已收藏图标(红色)
checkbox: '/assets/icons/checkbox-no.svg', // 复选框(未选中)
checkboxChecked: '/assets/icons/checkbox.svg', // 复选框(已选中)
search: '/assets/icons/search.svg', // 搜索图标
selectAll: '/assets/icons/selectall.svg', // 全选图标
unselectAll: '/assets/icons/unselectall.svg', // 取消全选图标
}
function toSvgDataUri(svg) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}
function normalizeHexColor(input) {
const fallback = '#000000'
const value = String(input || '').trim()
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return value
}
return fallback
}
function clampFontSize(value, fallback = PREVIEW_RENDER_FONT_SIZE) {
const parsed = Number(value)
const base = Number.isFinite(parsed) ? parsed : Number(fallback)
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
}
const errMsg = error.errMsg || error.message || String(error)
return errMsg || fallback
}
function showExportError(title, error, fallback) {
const message = extractErrorMessage(error, fallback)
wx.showModal({
title,
content: message,
showCancel: false,
confirmText: '知道了',
})
}
function writePngBufferToTempFile(pngBuffer, fontName) {
const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font'
const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png`
const fs = wx.getFileSystemManager()
fs.writeFileSync(filePath, pngBuffer)
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+$/, '')
}
function replaceSvgFillColor(svg, color) {
const normalizedColor = normalizeHexColor(color)
const source = String(svg || '')
if (!source) return ''
if (/<g\b[^>]*\sfill="[^"]*"/.test(source)) {
return source.replace(/(<g\b[^>]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
}
return source.replace(/<g\b([^>]*)>/, `<g$1 fill="${normalizedColor}">`)
}
function scaleSvgDimensions(svg, scale) {
const source = String(svg || '')
const safeScale = Number(scale)
if (!source || !Number.isFinite(safeScale) || safeScale <= 0) {
return source
}
return source
.replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (match, width) => {
const scaledWidth = Number(width) * safeScale
return `width="${formatSvgNumber(scaledWidth)}"`
})
.replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (match, height) => {
const scaledHeight = Number(height) * safeScale
return `height="${formatSvgNumber(scaledHeight)}"`
})
}
function applyLocalStyleToFontItem(font, fontSize, color) {
if (!font || !font.baseSvg) {
return font
}
const targetSize = clampFontSize(fontSize, PREVIEW_RENDER_FONT_SIZE)
const renderSize = Number(font.renderFontSize) > 0 ? Number(font.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
const scale = targetSize / renderSize
const styledSvg = scaleSvgDimensions(replaceSvgFillColor(font.baseSvg, color), scale)
const scaledWidth = Number(font.baseWidth) > 0 ? Number(font.baseWidth) * scale : 0
const previewImageWidth = Math.max(
MIN_PREVIEW_IMAGE_WIDTH,
Math.min(MAX_PREVIEW_IMAGE_WIDTH, Math.round(scaledWidth || MIN_PREVIEW_IMAGE_WIDTH)),
)
return {
...font,
svg: styledSvg,
previewSrc: toSvgDataUri(styledSvg),
previewImageStyle: `width:${previewImageWidth}px;max-width:100%;`,
previewError: '',
}
}
Page({
data: {
inputText: '星程字体转换',
fontSize: 50,
letterSpacingInput: '0',
textColor: '#dc2626',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
favoriteFonts: [], // 已收藏字体平铺列表
showColorPicker: false,
favorites: [],
// 使用本地图标路径
icons: LOCAL_ICON_PATHS,
// 搜索功能
searchKeyword: '',
showSearch: true,
feedbackEmail: FEEDBACK_EMAIL,
},
async onLoad() {
// 调试:打印图标配置
console.log('========== 图标配置 ==========')
console.log('icons:', this.data.icons)
console.log('logo URL:', this.data.icons.logo)
console.log('============================')
this.fontMap = new Map()
this.legacyFontIdMap = new Map()
this.previewCache = new Map()
this.generateTimer = null
this.categoryExpandedMap = {}
await this.bootstrap()
},
onShow() {
const rawFavorites = loadFavorites()
const favorites = this.normalizeFontIdList(rawFavorites)
if (normalizeSelectedFontIds(rawFavorites).join(',') !== favorites.join(',')) {
saveFavorites(favorites)
}
this.setData({ favorites })
this.updateFontTrees()
},
onUnload() {
if (this.generateTimer) {
clearTimeout(this.generateTimer)
this.generateTimer = null
}
},
applyLocalPreviewStyles() {
const fontSize = clampFontSize(this.data.fontSize, PREVIEW_RENDER_FONT_SIZE)
const textColor = normalizeHexColor(this.data.textColor)
const selectedFonts = this.data.selectedFonts.map(font => applyLocalStyleToFontItem(font, fontSize, textColor))
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 isFirstLaunch = !state || !state.updatedAt
const [fonts, defaultConfig] = await Promise.all([
loadFontsManifest(),
loadDefaultConfig(),
])
this.buildFontMaps(fonts)
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: initialInputText,
fontSize: initialFontSize,
letterSpacingInput: initialLetterSpacingInput,
textColor: initialTextColor,
favorites,
})
this.categoryExpandedMap = !isFirstLaunch && state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
? { ...state.categoryExpandedMap }
: {}
// 构建字体树
this.updateFontTrees()
// 恢复选中字体(首次使用走 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 => ({
id: font.id,
name: font.name,
category: font.category,
showInPreview: true,
previewSrc: '',
}))
this.setData({ selectedFonts })
// 更新字体树以反映选中状态
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 })
} finally {
wx.hideLoading()
}
},
// 构建字体分类树
updateFontTrees() {
const categoryMap = new Map()
const favorites = this.data.favorites
const selectedIdSet = new Set(this.data.selectedFonts.map(f => f.id))
const normalizedKeyword = (this.data.searchKeyword || '').trim().toLowerCase()
const selectedOnlyMode =
normalizedKeyword.includes('选中') ||
normalizedKeyword.includes('选择') ||
normalizedKeyword.includes('已选') ||
normalizedKeyword.includes('xuan')
const nameKeyword = selectedOnlyMode ? '' : normalizedKeyword
const isSearchMode = normalizedKeyword.length > 0
const favoriteFonts = []
this.fontMap.forEach(font => {
const category = font.category || '其他'
const isFavorite = favorites.includes(font.id)
const isSelected = selectedIdSet.has(font.id)
// “选中/选择/已选/xuan” 搜索模式:仅保留已选字体
if (selectedOnlyMode && !isSelected) {
return
}
// 应用搜索过滤
if (nameKeyword) {
const matchesSearch = font.name.toLowerCase().includes(nameKeyword) ||
category.toLowerCase().includes(nameKeyword)
if (!matchesSearch) return
}
// 所有字体树
if (!categoryMap.has(category)) {
const expandedFromState = this.categoryExpandedMap[category]
categoryMap.set(category, {
category,
expanded: isSearchMode
? true
: (typeof expandedFromState === 'boolean' ? expandedFromState : false),
fonts: [],
allSelected: false,
})
}
categoryMap.get(category).fonts.push({
...font,
selected: isSelected,
isFavorite,
})
// 已收藏字体平铺列表
if (isFavorite) {
favoriteFonts.push({
...font,
selected: isSelected,
isFavorite: true,
})
}
})
const fontCategories = Array.from(categoryMap.values()).sort((a, b) => a.category.localeCompare(b.category))
// 计算每个分类的allSelected状态
fontCategories.forEach(cat => {
if (cat.fonts.length > 0) {
cat.allSelected = cat.fonts.every(f => f.selected)
} else {
cat.allSelected = false
}
})
favoriteFonts.sort((a, b) => {
const categoryCompare = String(a.category || '').localeCompare(String(b.category || ''))
if (categoryCompare !== 0) return categoryCompare
return String(a.name || '').localeCompare(String(b.name || ''))
})
this.setData({
fontCategories,
favoriteFonts,
})
},
// 切换字体选择
async onToggleFont(e) {
const fontId = e.currentTarget.dataset.fontId
if (!fontId) return
const font = this.fontMap.get(fontId)
if (!font) return
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
// 取消选择
selectedFonts.splice(index, 1)
} else {
// 添加选择
selectedFonts.push({
id: font.id,
name: font.name,
category: font.category,
showInPreview: true,
previewSrc: '',
})
}
this.setData({ selectedFonts })
this.updateFontTrees()
// 保存状态
saveAppState({
inputText: this.data.inputText,
selectedFontIds: selectedFonts.map(f => f.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
// 如果是新选中的字体,生成预览
if (index < 0) {
await this.generatePreviewForFont(fontId)
}
},
// 切换字体在预览中的显示
onTogglePreviewFont(e) {
const fontId = e.currentTarget.dataset.fontId
if (!fontId) return
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].showInPreview = !selectedFonts[index].showInPreview
this.setData({ selectedFonts })
}
},
// 切换收藏
onToggleFavorite(e) {
const fontId = e.currentTarget.dataset.fontId
if (!fontId) return
let favorites = [...this.data.favorites]
const index = favorites.indexOf(fontId)
if (index >= 0) {
favorites.splice(index, 1)
} else {
favorites.push(fontId)
}
this.setData({ favorites })
saveFavorites(favorites)
this.updateFontTrees()
},
// 切换分类展开/收起
onToggleCategory(e) {
const category = e.currentTarget.dataset.category
if (!category) return
const fontCategories = this.data.fontCategories
const index = fontCategories.findIndex(c => c.category === category)
if (index >= 0) {
fontCategories[index].expanded = !fontCategories[index].expanded
this.setData({ fontCategories })
this.categoryExpandedMap[category] = fontCategories[index].expanded
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
categoryExpandedMap: this.categoryExpandedMap,
})
}
},
// 切换分类全选/取消全选
async onToggleSelectAllInCategory(e) {
const category = e.currentTarget.dataset.category
if (!category) return
const categoryFonts = this.data.fontCategories.find(c => c.category === category)
if (!categoryFonts || categoryFonts.fonts.length === 0) return
const allSelected = categoryFonts.allSelected
const previousSelectedMap = new Map(this.data.selectedFonts.map(font => [font.id, font]))
const selectedIdSet = new Set(previousSelectedMap.keys())
const addedFontIds = []
if (allSelected) {
// 取消全选:移除该分类下的所有字体
categoryFonts.fonts.forEach(font => {
selectedIdSet.delete(font.id)
})
} else {
// 全选:添加该分类下的所有字体
categoryFonts.fonts.forEach(font => {
if (!selectedIdSet.has(font.id)) {
addedFontIds.push(font.id)
}
selectedIdSet.add(font.id)
})
}
const newSelectedFonts = []
this.fontMap.forEach(font => {
if (selectedIdSet.has(font.id)) {
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()
if (addedFontIds.length > 0) {
for (const fontId of addedFontIds) {
await this.generatePreviewForFont(fontId)
}
}
saveAppState({
inputText: this.data.inputText,
selectedFontIds: newSelectedFonts.map(f => f.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
},
// 生成单个字体的预览
async generatePreviewForFont(fontId) {
const text = String(this.data.inputText || '')
if (!text.trim()) {
return
}
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,
text,
// 预览固定基础字号,避免字号/颜色调整时重复请求服务端
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing,
maxCharsPerLine: PREVIEW_MAX_CHARS_PER_LINE,
})
setPreviewCache(this.previewCache, cacheKey, {
svg: result.svg,
width: result.width,
height: result.height,
})
// 更新对应字体的预览
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].baseSvg = result.svg
selectedFonts[index].baseWidth = result.width
selectedFonts[index].baseHeight = result.height
selectedFonts[index].renderFontSize = PREVIEW_RENDER_FONT_SIZE
selectedFonts[index] = applyLocalStyleToFontItem(
selectedFonts[index],
Number(this.data.fontSize),
this.data.textColor,
)
this.setData({ selectedFonts })
}
} catch (error) {
console.error('生成预览失败', error)
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].previewSrc = ''
selectedFonts[index].svg = ''
selectedFonts[index].baseSvg = ''
selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败'
this.setData({ selectedFonts })
}
}
},
// 生成所有选中字体的预览
async generateAllPreviews() {
const text = String(this.data.inputText || '')
if (!text.trim()) {
return
}
wx.showLoading({ title: '生成预览中', mask: true })
try {
for (const font of this.data.selectedFonts) {
await this.generatePreviewForFont(font.id)
}
} finally {
wx.hideLoading()
}
},
scheduleGenerate() {
if (this.generateTimer) {
clearTimeout(this.generateTimer)
}
this.generateTimer = setTimeout(() => {
this.generateAllPreviews()
}, 260)
},
onInputText(event) {
this.setData({ inputText: event.detail.value || '' })
saveAppState({
inputText: event.detail.value || '',
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
this.scheduleGenerate()
},
onFontSizeChanging(event) {
this.setData({ fontSize: clampFontSize(event.detail.value, this.data.fontSize) })
},
onFontSizeChange(event) {
const newSize = clampFontSize(event.detail.value, this.data.fontSize)
this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: newSize,
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
this.applyLocalPreviewStyles()
},
onDecreaseFontSize() {
const newSize = Math.max(FONT_SIZE_MIN, this.data.fontSize - 10)
this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: newSize,
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
this.applyLocalPreviewStyles()
},
onIncreaseFontSize() {
const newSize = Math.min(FONT_SIZE_MAX, this.data.fontSize + 10)
this.setData({ fontSize: newSize })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: newSize,
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
this.applyLocalPreviewStyles()
},
onShowColorPicker() {
this.setData({ showColorPicker: true })
},
onHideColorPicker() {
this.setData({ showColorPicker: false })
},
onStopPropagation() {
// 阻止事件冒泡
},
onColorInput(event) {
this.setData({ textColor: event.detail.value || '' })
this.applyLocalPreviewStyles()
},
onPickColor(event) {
const color = event.currentTarget.dataset.color
if (!color) {
return
}
this.setData({ textColor: color })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: Number(this.data.fontSize),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: color,
})
this.applyLocalPreviewStyles()
},
onRegenerate() {
this.generateAllPreviews()
},
onShowExportOptions() {
wx.showActionSheet({
itemList: ['导出全部为 SVG', '导出全部为 PNG'],
success: (res) => {
if (res.tapIndex === 0) {
this.exportAllSvg()
} else if (res.tapIndex === 1) {
this.exportAllPng()
}
},
})
},
async exportAllSvg() {
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
if (selectedFonts.length === 0) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
wx.showModal({
title: '微信限制说明',
content: '微信要求 shareFileMessage 必须由单次点击直接触发,暂不支持批量自动分享 SVG请逐个字体导出。',
showCancel: false,
confirmText: '知道了',
})
return
},
async exportAllPng() {
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
if (selectedFonts.length === 0) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
for (const font of selectedFonts) {
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
const saveResult = await savePngToAlbum(pngPath)
if (!saveResult.success) {
throw saveResult.error || new Error('保存 PNG 失败')
}
}
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
},
async onExportSvg() {
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
if (selectedFonts.length === 0) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
// 如果只有一个字体,直接导出
if (selectedFonts.length === 1) {
const font = selectedFonts[0]
try {
const exportSvg = font.baseSvg
? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg
: font.svg
await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
showExportError('导出 SVG 失败', error, '请稍后重试')
}
} else {
this.exportAllSvg()
}
},
async onExportPng() {
const selectedFonts = this.data.selectedFonts.filter(f => f.showInPreview && f.svg)
if (selectedFonts.length === 0) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
// 如果只有一个字体,直接导出
if (selectedFonts.length === 1) {
const font = selectedFonts[0]
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
const saveResult = await savePngToAlbum(pngPath)
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
}
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
} else {
this.exportAllPng()
}
},
// 单个字体导出
async onExportSingleSvg(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
try {
const exportSvg = font.baseSvg
? applyLocalStyleToFontItem(font, Number(this.data.fontSize), this.data.textColor).svg
: font.svg
await shareSvgFromUserTap(exportSvg, font.name, this.data.inputText)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
showExportError('导出 SVG 失败', error, '请稍后重试')
}
},
async onExportSinglePng(e) {
const fontId = e.currentTarget.dataset.fontId
const font = this.data.selectedFonts.find(f => f.id === fontId)
if (!font || !font.svg) {
wx.showToast({ title: '请先生成预览', icon: 'none' })
return
}
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
const pngBuffer = await renderPngByApi({
fontId: font.id,
text: this.data.inputText,
fontSize: Number(this.data.fontSize),
fillColor: normalizeHexColor(this.data.textColor),
letterSpacing: Number(this.data.letterSpacingInput || 0),
maxCharsPerLine: 45,
})
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
const saveResult = await savePngToAlbum(pngPath)
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
}
} catch (error) {
showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
},
// 搜索功能
onToggleSearch() {
this.setData({ showSearch: !this.data.showSearch })
if (!this.data.showSearch) {
this.setData({ searchKeyword: '' })
this.updateFontTrees()
}
},
onSearchInput(e) {
const keyword = e.detail.value || ''
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',
})
},
})
},
})