Files
font2pic/miniprogram/pages/index/index.js
2026-02-08 18:28:39 +08:00

606 lines
18 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 { saveSvgToUserPath, shareLocalFile, buildFilename } = require('../../utils/mp/file-export')
const { exportSvgToPngByCanvas, savePngToAlbum } = require('../../utils/mp/canvas-export')
const { renderSvgByApi } = require('../../utils/mp/render-api')
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
// 临时使用本地图标
const LOCAL_ICON_PATHS = {
logo: '/assets/icons/webicon.png',
fontSizeDecrease: '/assets/icons/font-size-decrease.png',
fontSizeIncrease: '/assets/icons/font-size-increase.png',
chooseColor: '/assets/icons/choose-color.png',
export: '/assets/icons/export.png',
exportSvg: '/assets/icons/export-svg.png',
exportPng: '/assets/icons/export-png.png',
// 字体树图标参考web项目
fontIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_18.svg', // 字体item图标
expandIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_12.svg', // 展开分类图标
collapseIcon: 'https://fonts.biboer.cn/assets/icons/zhedie.svg', // 折叠分类图标
favoriteIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_19.svg', // 收藏图标
checkbox: '/assets/icons/checkbox.png', // 预览checkbox
search: 'https://fonts.biboer.cn/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
}
Page({
data: {
inputText: '星程字体转换',
fontSize: 120,
letterSpacingInput: '0',
textColor: '#000000',
colorPalette: COLOR_PALETTE,
selectedFonts: [], // 当前已选中的字体列表
fontCategories: [], // 字体分类树
favoriteCategories: [], // 已收藏字体分类树
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
await this.bootstrap()
},
onShow() {
const favorites = loadFavorites()
this.setData({ favorites })
this.updateFontTrees()
},
onUnload() {
if (this.generateTimer) {
clearTimeout(this.generateTimer)
this.generateTimer = null
}
},
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: Number(state.fontSize) > 0 ? Number(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.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 favoriteCategoryMap = new Map()
const favorites = this.data.favorites
const keyword = (this.data.searchKeyword || '').trim().toLowerCase()
this.fontMap.forEach(font => {
const category = font.category || '其他'
const isFavorite = favorites.includes(font.id)
// 应用搜索过滤
if (keyword) {
const matchesSearch = font.name.toLowerCase().includes(keyword) ||
category.toLowerCase().includes(keyword)
if (!matchesSearch) return
}
// 所有字体树
if (!categoryMap.has(category)) {
categoryMap.set(category, {
category,
expanded: true,
fonts: [],
})
}
const selectedIds = this.data.selectedFonts.map(f => f.id)
categoryMap.get(category).fonts.push({
...font,
selected: selectedIds.includes(font.id),
isFavorite,
})
// 已收藏字体树
if (isFavorite) {
if (!favoriteCategoryMap.has(category)) {
favoriteCategoryMap.set(category, {
category,
expanded: true,
fonts: [],
})
}
favoriteCategoryMap.get(category).fonts.push({
...font,
selected: selectedIds.includes(font.id),
isFavorite: true,
})
}
})
const fontCategories = Array.from(categoryMap.values())
const favoriteCategories = Array.from(favoriteCategoryMap.values())
this.setData({
fontCategories,
favoriteCategories,
})
},
// 切换字体选择
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 })
}
},
// 切换收藏分类展开/收起
onToggleFavoriteCategory(e) {
const category = e.currentTarget.dataset.category
if (!category) return
const favoriteCategories = this.data.favoriteCategories
const index = favoriteCategories.findIndex(c => c.category === category)
if (index >= 0) {
favoriteCategories[index].expanded = !favoriteCategories[index].expanded
this.setData({ favoriteCategories })
}
},
// 生成单个字体的预览
async generatePreviewForFont(fontId) {
const text = String(this.data.inputText || '')
if (!text.trim()) {
return
}
try {
const letterSpacing = Number(this.data.letterSpacingInput || 0)
const fillColor = normalizeHexColor(this.data.textColor)
const result = await renderSvgByApi({
fontId,
text,
fontSize: Number(this.data.fontSize),
fillColor,
letterSpacing,
maxCharsPerLine: 45,
})
const previewImageSrc = toSvgDataUri(result.svg)
// 更新对应字体的预览
const selectedFonts = this.data.selectedFonts
const index = selectedFonts.findIndex(f => f.id === fontId)
if (index >= 0) {
selectedFonts[index].previewSrc = previewImageSrc
selectedFonts[index].svg = result.svg
selectedFonts[index].width = result.width
selectedFonts[index].height = result.height
this.setData({ selectedFonts })
}
} catch (error) {
console.error('生成预览失败', error)
}
},
// 生成所有选中字体的预览
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: Number(event.detail.value) || this.data.fontSize })
},
onFontSizeChange(event) {
this.setData({ fontSize: Number(event.detail.value) || this.data.fontSize })
saveAppState({
inputText: this.data.inputText,
selectedFontIds: this.data.selectedFonts.map(f => f.id),
fontSize: Number(event.detail.value),
letterSpacing: Number(this.data.letterSpacingInput || 0),
textColor: this.data.textColor,
})
this.scheduleGenerate()
},
onDecreaseFontSize() {
const newSize = Math.max(24, 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.scheduleGenerate()
},
onIncreaseFontSize() {
const newSize = Math.min(320, 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.scheduleGenerate()
},
onShowColorPicker() {
this.setData({ showColorPicker: true })
},
onHideColorPicker() {
this.setData({ showColorPicker: false })
},
onStopPropagation() {
// 阻止事件冒泡
},
onColorInput(event) {
this.setData({ textColor: event.detail.value || '' })
this.scheduleGenerate()
},
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.scheduleGenerate()
},
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.showLoading({ title: '导出 SVG 中', mask: true })
try {
for (const font of selectedFonts) {
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
const filename = buildFilename(font.name, this.data.inputText, 'svg')
await shareLocalFile(filePath, filename)
}
wx.showToast({ title: 'SVG 导出完成', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
} finally {
wx.hideLoading()
}
},
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 width = Math.max(64, Math.round(font.width || 1024))
const height = Math.max(64, Math.round(font.height || 1024))
const pngPath = await exportSvgToPngByCanvas(this, {
svgString: font.svg,
width,
height,
})
await savePngToAlbum(pngPath)
}
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
} 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]
wx.showLoading({ title: '导出 SVG 中', mask: true })
try {
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
const filename = buildFilename(font.name, this.data.inputText, 'svg')
await shareLocalFile(filePath, filename)
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
} finally {
wx.hideLoading()
}
} 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 width = Math.max(64, Math.round(font.width || 1024))
const height = Math.max(64, Math.round(font.height || 1024))
const pngPath = await exportSvgToPngByCanvas(this, {
svgString: font.svg,
width,
height,
})
const saveResult = await savePngToAlbum(pngPath)
if (saveResult.success) {
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} else {
wx.showToast({ title: '保存失败,请重试', icon: 'none' })
}
} catch (error) {
const message = error && error.errMsg ? error.errMsg : error.message
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
} finally {
wx.hideLoading()
}
} else {
this.exportAllPng()
}
},
// 搜索功能
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()
},
})