Files
font2pic/miniprogram/pages/index/index.js
2026-02-08 23:38:19 +08:00

789 lines
24 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 } = 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 MIN_PREVIEW_IMAGE_WIDTH = 24
const MAX_PREVIEW_IMAGE_WIDTH = 2400
// 临时使用本地图标 - 根据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', // 收藏图标(未收藏白色底,收藏红色底)
checkbox: '/assets/icons/checkbox.svg', // 复选框(未选中)
checkboxChecked: '/assets/icons/checkbox-no.svg', // 复选框(已选中)
search: '/assets/icons/search.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 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 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: FONT_SIZE_MAX,
letterSpacingInput: '0',
textColor: '#000000',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
favoriteFonts: [], // 已收藏字体平铺列表
showColorPicker: false,
favorites: [],
// 使用本地图标路径
icons: LOCAL_ICON_PATHS,
// 搜索功能
searchKeyword: '',
showSearch: false,
},
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.generateTimer = null
this.categoryExpandedMap = {}
await this.bootstrap()
},
onShow() {
const favorites = loadFavorites()
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 })
},
async bootstrap() {
wx.showLoading({ title: '加载中', mask: true })
try {
const state = loadAppState()
const fonts = await loadFontsManifest()
const favorites = loadFavorites()
for (const font of fonts) {
this.fontMap.set(font.id, font)
}
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),
favorites,
})
this.categoryExpandedMap = state.categoryExpandedMap && typeof state.categoryExpandedMap === 'object'
? { ...state.categoryExpandedMap }
: {}
// 构建字体树
this.updateFontTrees()
// 如果有保存的选中字体,恢复它们
if (state.selectedFontIds && state.selectedFontIds.length > 0) {
const selectedFonts = state.selectedFontIds
.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 })
await this.generateAllPreviews()
}
} 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: [],
})
}
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))
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 generatePreviewForFont(fontId) {
const text = String(this.data.inputText || '')
if (!text.trim()) {
return
}
try {
const letterSpacing = Number(this.data.letterSpacingInput || 0)
const result = await renderSvgByApi({
fontId,
text,
// 预览固定基础字号,避免字号/颜色调整时重复请求服务端
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing,
maxCharsPerLine: 45,
})
// 更新对应字体的预览
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()
},
})