diff --git a/docs/src/cookbook/customize/emoji.md b/docs/src/cookbook/customize/emoji.md index 2125c83b12f..61f618f46a8 100644 --- a/docs/src/cookbook/customize/emoji.md +++ b/docs/src/cookbook/customize/emoji.md @@ -123,3 +123,92 @@ https://example.com/my-emoji/ ``` 这里,我们额外添加了 `folder` 属性来告知 Waline 图片的存放位置。 + +## 使用工厂函数 + +除了直接传入配置对象外,你还可以使用工厂函数来动态创建表情预设。工厂函数支持返回单个或多个 `WalineEmojiInfo` 对象。 + +### 返回单个预设 + +你可以通过工厂函数动态生成一个表情预设: + +```js +emoji: [ + () => ({ + name: "动态表情", + folder: "https://example.com/my-emoji", + prefix: "my_", + type: "png", + icon: "cute", + items: ["laugh", "sob", "rage", "cute"], + }), +], +``` + +工厂函数可以是**同步的**,也可以是**异步的**。这在你需要从远程加载表情配置时非常有用: + +```js +emoji: [ + async () => { + const config = await fetch('https://example.com/emoji-config.json') + .then(res => res.json()); + + return { + name: '远程表情', + folder: 'https://example.com/emoji', + ...config, + }; + }, +], +``` + +### 返回多个预设 + +一个工厂函数也可以一次返回多个表情预设列表: + +```js +emoji: [ + () => [ + { + name: "预设 1", + folder: "https://example.com/pack1", + icon: "icon1", + items: ["a", "b", "c"], + }, + { + name: "预设 2", + folder: "https://example.com/pack2", + icon: "icon2", + items: ["x", "y", "z"], + }, + ], +], +``` + +### 混用多种类型 + +你可以将工厂函数、预设地址和配置对象混合在同一个数组中: + +```js +emoji: [ + // 字符串预设地址 + 'https://unpkg.com/@waline/emojis@1.1.0/weibo', + // 工厂函数 + async () => { + const config = await fetch('https://example.com/my-emoji/info.json') + .then(res => res.json()); + + return { + folder: 'https://example.com/my-emoji', + ...config, + }; + }, + // 配置对象 + { + name: '自定义', + folder: 'https://example.com/custom', + icon: 'smile', + items: ['laugh', 'sob'], + }, +], +``` diff --git a/docs/src/en/cookbook/customize/emoji.md b/docs/src/en/cookbook/customize/emoji.md index 5d4e5d6dc59..c88da648a5a 100644 --- a/docs/src/en/cookbook/customize/emoji.md +++ b/docs/src/en/cookbook/customize/emoji.md @@ -125,3 +125,92 @@ In addition to creating an `info.json` upload and using a link as a preset, you ``` Here, we additionally add the `folder` property to tell Waline where the images are stored. + +## Using factory functions + +In addition to config objects, you can also use factory functions to dynamically create emoji presets. Factory functions can return one or more `WalineEmojiInfo` objects. + +### Return a single preset + +You can dynamically generate an emoji preset through factory functions: + +```js +emoji: [ + () => ({ + name: "Dynamic Emoji", + folder: "https://example.com/my-emoji", + prefix: "my_", + type: "png", + icon: "cute", + items: ["laugh", "sob", "rage", "cute"], + }), +], +``` + +Factory functions can be **sync** or **async**. This is useful when you need to load emoji configurations from a remote source: + +```js +emoji: [ + async () => { + const config = await fetch('https://example.com/emoji-config.json') + .then(res => res.json()); + + return { + name: 'Remote Emoji', + folder: 'https://example.com/emoji', + ...config, + }; + }, +], +``` + +### Return multiple presets + +A single factory function can also return multiple emoji preset lists at once: + +```js +emoji: [ + () => [ + { + name: "Pack 1", + folder: "https://example.com/pack1", + icon: "icon1", + items: ["a", "b", "c"], + }, + { + name: "Pack 2", + folder: "https://example.com/pack2", + icon: "icon2", + items: ["x", "y", "z"], + }, + ], +], +``` + +### Mixing types + +You can mix factory functions, preset addresses, and config objects in the same array: + +```js +emoji: [ + // string preset address + 'https://unpkg.com/@waline/emojis@1.1.0/weibo', + // factory function + async () => { + const config = await fetch('https://example.com/my-emoji/info.json') + .then(res => res.json()); + + return { + folder: 'https://example.com/my-emoji', + ...config, + }; + }, + // config object + { + name: 'Custom', + folder: 'https://example.com/custom', + icon: 'smile', + items: ['laugh', 'sob'], + }, +], +``` diff --git a/docs/src/en/reference/client/props.md b/docs/src/en/reference/client/props.md index 92662da852f..aaeed850157 100644 --- a/docs/src/en/reference/client/props.md +++ b/docs/src/en/reference/client/props.md @@ -63,7 +63,7 @@ Waline Locales. ## emoji -- Type: `(string | WalineEmojiInfo)[] | boolean` +- Type: `WalineEmojiOptions | boolean` ```ts type WalineEmojiPresets = `http://${string}` | `https://${string}`; @@ -94,6 +94,13 @@ Waline Locales. */ items: string[]; } + + type WalineEmojiFactory = () => + | WalineEmojiInfo + | WalineEmojiInfo[] + | Promise; + + type WalineEmojiOptions = (WalineEmojiFactory | WalineEmojiInfo | WalineEmojiPresets)[]; ``` - Default: `['//unpkg.com/@waline/emojis@1.1.0/weibo']` diff --git a/docs/src/reference/client/props.md b/docs/src/reference/client/props.md index acc78c89744..8a70c2af0f2 100644 --- a/docs/src/reference/client/props.md +++ b/docs/src/reference/client/props.md @@ -63,7 +63,7 @@ Waline 多语言配置。 ## emoji -- 类型: `(WalineEmojiInfo | WalineEmojiPresets)[] | boolean` +- 类型: `WalineEmojiOptions | boolean` ```ts type WalineEmojiPresets = `http://${string}` | `https://${string}`; @@ -94,6 +94,13 @@ Waline 多语言配置。 */ items: string[]; } + + type WalineEmojiFactory = () => + | WalineEmojiInfo + | WalineEmojiInfo[] + | Promise; + + type WalineEmojiOptions = (WalineEmojiFactory | WalineEmojiInfo | WalineEmojiPresets)[]; ``` - 默认值: `['//unpkg.com/@waline/emojis@1.1.0/weibo']` diff --git a/packages/client/__tests__/emoji.spec.ts b/packages/client/__tests__/emoji.spec.ts index 38cfab90e04..3a86f9bbba7 100644 --- a/packages/client/__tests__/emoji.spec.ts +++ b/packages/client/__tests__/emoji.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { WalineEmojiInfo } from '../src/typings/index.js'; +import { getEmojisInfo } from '../src/utils/emoji.js'; import { parseEmoji } from '../src/utils/markdown.js'; import { emojiMaps } from './__fixtures__/emojiMap.js'; @@ -27,3 +29,209 @@ describe(parseEmoji, () => { expect(() => parseEmoji('')).not.toThrow(); }); }); + +const setupLocalStorage = (): void => { + const store = new Map(); + + vi.stubGlobal('localStorage', { + getItem(key: string): string | null { + return store.get(key) ?? null; + }, + setItem(key: string, value: string): void { + store.set(key, value); + }, + removeItem(key: string): void { + store.delete(key); + }, + clear(): void { + store.clear(); + }, + }); +}; + +const mockFetch = (info: Omit): void => { + vi.stubGlobal('fetch', () => + Promise.resolve({ + json: () => Promise.resolve(info), + }), + ); +}; + +describe(getEmojisInfo, () => { + it('should handle null input', async () => { + await expect(getEmojisInfo(null)).resolves.toStrictEqual({ + tabs: [], + map: {}, + }); + }); + + it('should handle WalineEmojiInfo objects', async () => { + const result = await getEmojisInfo([ + { + name: 'Custom', + icon: 'smile', + prefix: 'custom_', + type: 'png', + folder: 'https://example.com/emoji', + items: ['laugh', 'sob'], + }, + ]); + + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0].name).toBe('Custom'); + expect(result.tabs[0].items).toStrictEqual(['custom_laugh', 'custom_sob']); + expect(result.map.custom_laugh).toBe('https://example.com/emoji/custom_laugh.png'); + }); + + it('should fetch emoji info from string presets', async () => { + setupLocalStorage(); + mockFetch({ + name: 'Weibo', + icon: 'weibo_icon', + prefix: 'weibo_', + type: 'png', + items: ['laugh', 'cry'], + }); + + const result = await getEmojisInfo(['https://example.com/weibo']); + + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0].name).toBe('Weibo'); + expect(result.map.weibo_laugh).toBe('https://example.com/weibo/weibo_laugh.png'); + }); + + it('should call factory function returning WalineEmojiInfo', async () => { + const factory = vi.fn<() => WalineEmojiInfo>(() => ({ + name: 'Factory', + icon: 'icon', + items: ['a', 'b'], + prefix: 'f_', + type: 'gif', + folder: 'https://example.com/factory', + })); + + const result = await getEmojisInfo([factory]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0].name).toBe('Factory'); + expect(result.tabs[0].items).toStrictEqual(['f_a', 'f_b']); + }); + + it('should call factory function returning WalineEmojiInfo[]', async () => { + const factory = vi.fn<() => WalineEmojiInfo[]>(() => [ + { + name: 'Tab1', + icon: 'a', + items: ['a1', 'a2'], + folder: 'https://example.com/tab1', + }, + { + name: 'Tab2', + icon: 'b', + items: ['b1'], + folder: 'https://example.com/tab2', + }, + ]); + + const result = await getEmojisInfo([factory]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.tabs).toHaveLength(2); + expect(result.tabs[0].name).toBe('Tab1'); + expect(result.tabs[1].name).toBe('Tab2'); + expect(result.map.a1).toBe('https://example.com/tab1/a1'); + expect(result.map.b1).toBe('https://example.com/tab2/b1'); + }); + + it('should call factory function returning Promise', async () => { + const factory = vi.fn<() => Promise>(() => + Promise.resolve({ + name: 'Async', + icon: 'icon', + items: ['x', 'y'], + folder: 'https://example.com/async', + }), + ); + + const result = await getEmojisInfo([factory]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.tabs).toHaveLength(1); + expect(result.tabs[0].name).toBe('Async'); + expect(result.tabs[0].items).toStrictEqual(['x', 'y']); + }); + + it('should call factory function returning Promise', async () => { + const factory = vi.fn<() => Promise>(() => + Promise.resolve([ + { + name: 'AsyncTab1', + icon: 'a1', + items: ['aa'], + folder: 'https://example.com/at1', + }, + { + name: 'AsyncTab2', + icon: 'a2', + items: ['bb'], + folder: 'https://example.com/at2', + }, + ]), + ); + + const result = await getEmojisInfo([factory]); + + expect(factory).toHaveBeenCalledTimes(1); + expect(result.tabs).toHaveLength(2); + expect(result.tabs[0].name).toBe('AsyncTab1'); + expect(result.tabs[1].name).toBe('AsyncTab2'); + }); + + it('should handle mixed array of all types', async () => { + setupLocalStorage(); + mockFetch({ + name: 'Weibo', + icon: 'w_icon', + items: ['w1'], + }); + + const factory = vi.fn<() => WalineEmojiInfo>(() => ({ + name: 'FactoryItem', + icon: 'f_icon', + items: ['f1'], + folder: 'https://example.com/f', + })); + + const result = await getEmojisInfo([ + 'https://example.com/weibo', + factory, + { + name: 'Direct', + icon: 'd_icon', + items: ['d1'], + folder: 'https://example.com/d', + }, + ]); + + expect(result.tabs).toHaveLength(3); + expect(factory).toHaveBeenCalledTimes(1); + }); + + it('should flatten arrays returned by factories', async () => { + const factory = vi.fn<() => WalineEmojiInfo[]>(() => [ + { name: 'F1', icon: 'a', items: ['x'], folder: 'https://example.com/f1' }, + { name: 'F2', icon: 'b', items: ['y'], folder: 'https://example.com/f2' }, + ]); + + const result = await getEmojisInfo([ + factory, + { name: 'Direct', icon: 'c', items: ['z'], folder: 'https://example.com/dir' }, + ]); + + expect(result.tabs).toHaveLength(3); + expect(result.tabs[0].name).toBe('F1'); + expect(result.tabs[1].name).toBe('F2'); + expect(result.tabs[2].name).toBe('Direct'); + }); +}); diff --git a/packages/client/src/typings/base.d.ts b/packages/client/src/typings/base.d.ts index d26fd1671d9..d25feb7c783 100644 --- a/packages/client/src/typings/base.d.ts +++ b/packages/client/src/typings/base.d.ts @@ -41,6 +41,13 @@ export interface WalineEmojiInfo { items: string[]; } +export type WalineEmojiFactory = () => + | WalineEmojiInfo + | WalineEmojiInfo[] + | Promise; + +export type WalineEmojiOptions = (WalineEmojiFactory | WalineEmojiInfo | WalineEmojiPresets)[]; + export type WalineEmojiMaps = Record; export type WalineLoginStatus = 'enable' | 'disable' | 'force'; diff --git a/packages/client/src/typings/options.d.ts b/packages/client/src/typings/options.d.ts index 1fee6b4c19e..bbc57ff6a56 100644 --- a/packages/client/src/typings/options.d.ts +++ b/packages/client/src/typings/options.d.ts @@ -1,6 +1,5 @@ import type { - WalineEmojiInfo, - WalineEmojiPresets, + WalineEmojiOptions, WalineHighlighter, WalineImageUploader, WalineSearchOptions, @@ -59,7 +58,7 @@ export interface WalineInitOptions extends Omit< * * @default ['//unpkg.com/@waline/emojis@1.1.0/weibo'] */ - emoji?: (WalineEmojiInfo | WalineEmojiPresets)[] | boolean; + emoji?: WalineEmojiOptions | boolean; /** * 设置搜索功能 diff --git a/packages/client/src/typings/waline.d.ts b/packages/client/src/typings/waline.d.ts index b6189bf2ec2..4841035d62e 100644 --- a/packages/client/src/typings/waline.d.ts +++ b/packages/client/src/typings/waline.d.ts @@ -1,7 +1,6 @@ import type { WalineCommentSorting, - WalineEmojiInfo, - WalineEmojiPresets, + WalineEmojiOptions, WalineHighlighter, WalineImageUploader, WalineLoginStatus, @@ -225,7 +224,7 @@ export interface WalineProps { * * @default ['//unpkg.com/@waline/emojis@1.1.0/weibo'] */ - emoji?: (WalineEmojiInfo | WalineEmojiPresets)[]; + emoji?: WalineEmojiOptions; /** * 设置搜索功能 diff --git a/packages/client/src/utils/config.ts b/packages/client/src/utils/config.ts index c3f8cb58a0b..b1078b058e7 100644 --- a/packages/client/src/utils/config.ts +++ b/packages/client/src/utils/config.ts @@ -12,7 +12,7 @@ import { import type { WalineEmojiInfo, WalineEmojiMaps, - WalineEmojiPresets, + WalineEmojiOptions, WalineHighlighter, WalineImageUploader, WalineLocale, @@ -35,7 +35,7 @@ export interface WalineConfig extends Required< > { locale: WalineLocale; wordLimit: [number, number] | false; - emoji: (WalineEmojiInfo | WalineEmojiPresets)[] | null; + emoji: WalineEmojiOptions | null; highlighter: WalineHighlighter | null; imageUploader: WalineImageUploader | null; texRenderer: WalineTeXRenderer | null; diff --git a/packages/client/src/utils/emoji.ts b/packages/client/src/utils/emoji.ts index dcb910b5b43..5f8a0729fdc 100644 --- a/packages/client/src/utils/emoji.ts +++ b/packages/client/src/utils/emoji.ts @@ -1,6 +1,6 @@ import { useStorage } from '@vueuse/core'; -import type { WalineEmojiInfo } from '../typings/index.js'; +import type { WalineEmojiInfo, WalineEmojiOptions } from '../typings/index.js'; import type { WalineEmojiConfig } from './config.js'; import { removeEndingSplash } from './path.js'; import { isString } from './type.js'; @@ -39,22 +39,22 @@ const fetchEmoji = (link: string): Promise => { const getLink = (name: string, folder = '', prefix = '', type = ''): string => `${folder ? `${folder}/` : ''}${prefix}${name}${type ? `.${type}` : ''}`; -export const getEmojisInfo = ( - emojis: (string | WalineEmojiInfo)[] | null, -): Promise => +export const getEmojisInfo = (emojis: WalineEmojiOptions | null): Promise => Promise.all( - emojis - ? emojis.map((emoji) => - isString(emoji) ? fetchEmoji(removeEndingSplash(emoji)) : Promise.resolve(emoji), - ) - : [], + emojis?.map(async (emoji) => + typeof emoji === 'function' + ? emoji() + : isString(emoji) + ? fetchEmoji(removeEndingSplash(emoji)) + : emoji, + ) ?? [], ).then((emojiInfos) => { const emojisConfig: WalineEmojiConfig = { tabs: [], map: {}, }; - emojiInfos.forEach((emojiInfo) => { + emojiInfos.flat().forEach((emojiInfo) => { const { name, folder, icon, prefix = '', type, items } = emojiInfo; emojisConfig.tabs.push({