1110 lines
34 KiB
JavaScript
1110 lines
34 KiB
JavaScript
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 toPngSaveError(saveResult) {
|
||
if (!saveResult || saveResult.success) {
|
||
return null
|
||
}
|
||
|
||
if (saveResult.reason === 'private_api_banned') {
|
||
return new Error('当前小程序账号不支持“保存到相册”(private api banned)。请改用“导出 SVG”分享,或更换支持该能力的账号/版本。')
|
||
}
|
||
|
||
if (saveResult.reason === 'auth_denied') {
|
||
return saveResult.error || new Error('保存失败,请检查相册权限')
|
||
}
|
||
|
||
return saveResult.error || new Error('保存 PNG 失败')
|
||
}
|
||
|
||
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 toPngSaveError(saveResult)
|
||
}
|
||
}
|
||
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 失败', toPngSaveError(saveResult), '保存 PNG 失败')
|
||
}
|
||
} 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 失败', toPngSaveError(saveResult), '保存 PNG 失败')
|
||
}
|
||
} 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',
|
||
})
|
||
},
|
||
})
|
||
},
|
||
})
|