import LetterTracingDraw from './draw/letterTracingDraw'; import { createPage, type CanvasDataState } from '../../base/pageMixin'; import { defaultShareConfig } from '../../config/config'; import { generateLetterTracing, mergeLetterTracingConfig, normalizeLetter, type LetterTracingGeneratorConfig, type LetterTracingMode, } from './generators/letter-tracing-generator'; import { getModeInfo, getPublishMetaByMode, isValidMode, LETTER_TRACING_MODE_OPTIONS, } from './letterTracing.config'; import { LETTERS_UPPER, LETTERS_PAIRS } from '../shared/data/alphabet'; import { DEFAULT_LETTER_PROFILE, getFontProfile, type FontProfile, } from '../shared/data/fontProfiles'; import { loadLetterFont } from '../shared/draw/drawTools'; import type { DebugPublishMeta } from '../../utils/debugPublish'; import { addFavorite, removeFavorite, batchCheckFavorited } from '../../utils/favorites'; /** 三字母精练分组:26 字母每 3 个一组 */ const TRIPLE_GROUPS: { title: string; letters: string[] }[] = []; for (let i = 0; i < LETTERS_UPPER.length; i += 3) { const group = LETTERS_UPPER.slice(i, i + 3); const title = group.length > 1 ? `${group[0]}–${group[group.length - 1]}` : group[0]; TRIPLE_GROUPS.push({ title, letters: [...group] }); } /** 每日打卡分组:4 个字母一组 */ const DAILY_GROUPS: { title: string; letters: string[] }[] = []; for (let i = 0; i < LETTERS_UPPER.length; i += 4) { const group = LETTERS_UPPER.slice(i, i + 4); const title = group.length > 1 ? `${group[0]}–${group[group.length - 1]}` : group[0]; DAILY_GROUPS.push({ title, letters: [...group] }); } function getDailyGroupIndex(letters?: string[]): number { const first = normalizeLetter(letters?.[0] ?? 'A'); const idx = DAILY_GROUPS.findIndex((group) => group.letters[0] === first); return idx >= 0 ? idx : 0; } const pageInfoLookup = getModeInfo; type PageData = CanvasDataState & { worksheetId: string; traceMode: LetterTracingMode; selectedLetter: string; selectedLetterPair: string; letterCaseLower: boolean; showLetterPicker: boolean; showCaseToggle: boolean; showNextLetter: boolean; showTripleGroupPicker: boolean; showDailyGroupPicker: boolean; tripleGroups: { title: string; letters: string[] }[]; selectedTripleGroupIdx: number; dailyGroups: { title: string; letters: string[] }[]; selectedDailyGroupIdx: number; letterGrid: string[]; modeOptions: typeof LETTER_TRACING_MODE_OPTIONS; isPreviewFavorite: boolean; isDevEnv: boolean; debugPublishVisible: boolean; debugPublishLoading: boolean; debugPublishMeta: DebugPublishMeta | null; }; createPage( { canvas: null as Canvas | null, ctx: null as RenderingContext | null, boxHeight: 0, boxWidth: 0, drawService: null as LetterTracingDraw | null, fontProfile: DEFAULT_LETTER_PROFILE as FontProfile, _favoritedMap: {} as Record, data: { pageTitle: '字母描红', functionId: '', hasContent: false, showShareDialog: false, worksheetId: 'letter-tracing-single', traceMode: 'letter-tracing-single', selectedLetter: 'A', selectedLetterPair: 'Aa', letterCaseLower: false, showLetterPicker: true, showCaseToggle: false, showNextLetter: true, showTripleGroupPicker: false, showDailyGroupPicker: false, tripleGroups: TRIPLE_GROUPS, selectedTripleGroupIdx: 0, dailyGroups: DAILY_GROUPS, selectedDailyGroupIdx: 0, letterGrid: LETTERS_PAIRS, modeOptions: LETTER_TRACING_MODE_OPTIONS, isPreviewFavorite: false, isDevEnv: false, debugPublishVisible: false, debugPublishLoading: false, debugPublishMeta: null, } as unknown as PageData, /** 页面加载:从路由参数获取 worksheetId、letter、case、font 并应用,同时预加载字体 */ onLoad(options: { id?: string; letter?: string; case?: 'upper' | 'lower'; font?: string; }) { if (options.font) { this.fontProfile = getFontProfile(options.font); } loadLetterFont(this.fontProfile).catch(() => {}); const worksheetId = options.id && isValidMode(options.id) ? options.id : 'letter-tracing-single'; this.syncDebugPublishEnv(); this.applyWorksheet(worksheetId); const updates: Partial = {}; if (options.letter) { const normalized = normalizeLetter(options.letter); if (normalized) { updates.selectedLetter = normalized; updates.selectedLetterPair = `${normalized}${normalized.toLowerCase()}`; } } if (options.case === 'lower') { updates.letterCaseLower = true; } else if (options.case === 'upper') { updates.letterCaseLower = false; } if (Object.keys(updates).length > 0) { this.setData( updates as Partial & WechatMiniprogram.IAnyObject, ); } this.loadFavoritedMap(); }, /** 用户点击模式选择器时切换 worksheet */ onSelectMode(e: WechatMiniprogram.TouchEvent) { const id = e.currentTarget.dataset.id as string | undefined; if (!id || id === this.data.worksheetId) return; this.applyWorksheet(id, { preserveLetter: true, redraw: true, }); }, /** Canvas 组件就绪回调:创建 DrawService → 首次绘制 */ onCanvasReady(e: WechatMiniprogram.CustomEvent) { this.initCanvasFromComponent(e.detail, { createDrawService: ( canvas: Canvas, ctx: RenderingContext, options?: Record, ) => new LetterTracingDraw(canvas, ctx, options), drawServiceOptions: { title: this.data.pageTitle, }, onCanvasReady: () => { this.drawCanvas(); }, }); }, /** 构建运行时生成器配置:合并页面模式预设 + 当前页面状态 */ buildRuntimeConfig(): LetterTracingGeneratorConfig { const base: Partial = { mode: this.data.traceMode, }; const letterCase = this.data.traceMode === 'letter-tracing-single' ? (base.letterCase ?? 'upper') : this.data.letterCaseLower ? 'lower' : 'upper'; const needsHalf = this.data.traceMode === 'letter-tracing-case-pairing' || this.data.traceMode === 'letter-tracing-half'; const isTriple = this.data.traceMode === 'letter-tracing-three'; const isDaily = this.data.traceMode === 'letter-tracing-daily-checkin'; return mergeLetterTracingConfig(base, { selectedLetter: this.data.selectedLetter, letterCase, ...(needsHalf ? { alphabetHalf: this.data.letterCaseLower ? 'N-Z' : 'A-M', } : {}), ...(isTriple ? { tripleLetters: TRIPLE_GROUPS[this.data.selectedTripleGroupIdx] ?.letters, } : {}), ...(isDaily ? { dailyLetters: DAILY_GROUPS[this.data.selectedDailyGroupIdx] ?.letters, } : {}), }); }, /** 执行 Canvas 绘制:等待字体就绪 → 生成数据 → 调用 drawService 渲染 */ async drawCanvas() { if (!this.ctx || !this.drawService) return; try { await loadLetterFont(this.fontProfile); const config = this.buildRuntimeConfig(); const data = generateLetterTracing(config); console.log('drawCanvas data', data); await (this.drawService as LetterTracingDraw).draw(data); this.setData({ hasContent: true }); } catch (e) { console.error('letterTracing draw failed', e); this.setData({ hasContent: false }); } }, /** 用户在字母网格中选择了一个字母(item 为 "Aa" 格式,取首字母作为 selectedLetter) */ onSelectLetter(e: WechatMiniprogram.TouchEvent) { const raw = e.currentTarget.dataset.letter as string; if (!raw) return; const letter = raw.charAt(0).toUpperCase(); this.setData({ selectedLetter: letter, selectedLetterPair: `${letter}${letter.toLowerCase()}`, }); this.drawCanvas(); }, /** 切换为大写模式并重绘 */ onSelectCaseUpper() { this.setData({ letterCaseLower: false }); this.drawCanvas(); }, /** 切换为小写模式并重绘 */ onSelectCaseLower() { this.setData({ letterCaseLower: true }); this.drawCanvas(); }, /** 跳到下一个字母(循环 A→Z→A)并重绘 */ onNextLetter() { const list = LETTERS_UPPER; const i = list.indexOf(this.data.selectedLetter); const next = list[(i + 1) % list.length]; this.setData({ selectedLetter: next, selectedLetterPair: `${next}${next.toLowerCase()}`, }); this.drawCanvas(); }, async onPreviewFavorite() { const next = !this.data.isPreviewFavorite; this.setData({ isPreviewFavorite: next }); const id = this.data.worksheetId; if (id) { this._favoritedMap[id] = next; if (next) { addFavorite(id); } else { removeFavorite(id); } } wx.showToast({ title: next ? '收藏成功' : '已取消收藏', icon: 'none', }); }, async loadFavoritedMap() { const ids = LETTER_TRACING_MODE_OPTIONS.map((d) => d.id); this._favoritedMap = await batchCheckFavorited(ids); const currentId = this.data.worksheetId; if (this._favoritedMap[currentId]) { this.setData({ isPreviewFavorite: true }); } }, getPublishMeta(): DebugPublishMeta { const meta = getPublishMetaByMode(this.data.worksheetId); if (!meta) { throw new Error('当前题型配置不存在'); } return meta; }, /** 用户在三字母分组选择器中选择了一组 */ onSelectTripleGroup(e: WechatMiniprogram.TouchEvent) { const idx = Number(e.currentTarget.dataset.idx); if (isNaN(idx) || idx === this.data.selectedTripleGroupIdx) return; this.setData({ selectedTripleGroupIdx: idx }); this.drawCanvas(); }, /** 用户在每日打卡分组选择器中选择了一组 */ onSelectDailyGroup(e: WechatMiniprogram.TouchEvent) { const idx = Number(e.currentTarget.dataset.idx); if (isNaN(idx) || idx === this.data.selectedDailyGroupIdx) return; this.setData({ selectedDailyGroupIdx: idx }); this.drawCanvas(); }, /** * 应用指定的页面模式配置到页面状态 * 根据当前模式的运行时默认参数决定 UI 控件的显隐(字母选择器、大小写切换等) * @param worksheetId - 要应用的模式 ID * @param options.preserveLetter - 是否保留当前选中的字母(模式切换时使用) * @param options.redraw - 是否立即重绘 Canvas */ applyWorksheet( worksheetId: string, options?: { preserveLetter?: boolean; redraw?: boolean; }, ) { if (!isValidMode(worksheetId)) return; const merged = mergeLetterTracingConfig( { mode: worksheetId as LetterTracingMode }, {}, ); const traceMode = merged.mode; const showLetterPicker = traceMode === 'letter-tracing-single'; const showCaseToggle = traceMode === 'letter-tracing-case-pairing' || traceMode === 'letter-tracing-half'; const showNextLetter = traceMode === 'letter-tracing-single'; const showTripleGroupPicker = traceMode === 'letter-tracing-three'; const showDailyGroupPicker = traceMode === 'letter-tracing-daily-checkin'; const selectedLetter = options?.preserveLetter ? (this.data.selectedLetter ?? 'A') : normalizeLetter(merged.selectedLetter ?? ''); const selectedDailyGroupIdx = options?.preserveLetter && showDailyGroupPicker ? (this.data.selectedDailyGroupIdx ?? 0) : getDailyGroupIndex(merged.dailyLetters); const letterCaseLower = traceMode === 'letter-tracing-case-pairing' || traceMode === 'letter-tracing-half' ? merged.alphabetHalf === 'N-Z' : traceMode === 'letter-tracing-single' ? merged.letterCase === 'lower' : false; this.setData( { worksheetId, functionId: worksheetId, traceMode, isPreviewFavorite: !!this._favoritedMap[worksheetId], selectedLetter, selectedLetterPair: `${selectedLetter}${selectedLetter.toLowerCase()}`, letterCaseLower, showLetterPicker, showCaseToggle, showNextLetter, showTripleGroupPicker, showDailyGroupPicker, selectedDailyGroupIdx, }, () => { this.initPageInfo(worksheetId, '字母描红'); if (this.drawService) { this.drawService.options.title = this.data.pageTitle; } if (options?.redraw) { this.drawCanvas(); } }, ); }, }, { shareConfig: defaultShareConfig, pageInfoLookup, }, );