@dofy/native-emoji-motion 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/presets.js ADDED
@@ -0,0 +1,103 @@
1
+ // Motions transform the entire native glyph. Keyframes deliberately begin and end at rest.
2
+ export const motions = {
3
+ glide: { keyframes: [{ transform: 'translate(0, 0) rotate(0deg)' }, { transform: 'translate(-12%, 8%) rotate(-9deg)', offset: .25 }, { transform: 'translate(12%, -22%) rotate(9deg)', offset: .6 }, { transform: 'translate(0, 0) rotate(0deg)' }], options: { duration: 1050, easing: 'cubic-bezier(.22,1,.36,1)' } },
4
+ ride: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-15%) rotate(-6deg)', offset: .24 }, { transform: 'translateY(0) rotate(4deg)', offset: .48 }, { transform: 'translateY(-9%) rotate(-4deg)', offset: .68 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
5
+ wave: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(18deg)' }, { transform: 'rotate(-12deg)' }, { transform: 'rotate(16deg)' }, { transform: 'rotate(-7deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '70% 75%' },
6
+ bounce: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-22%) scale(1.07,.94)', offset: .38 }, { transform: 'translateY(0) scale(.93,1.07)', offset: .72 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 650, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
7
+ pulse: { keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.25)', offset: .28 }, { transform: 'scale(.96)', offset: .5 }, { transform: 'scale(1.16)', offset: .68 }, { transform: 'scale(1)' }], options: { duration: 750, easing: 'cubic-bezier(.22,1,.36,1)' } },
8
+ wobble: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-13deg)' }, { transform: 'rotate(10deg)' }, { transform: 'rotate(-6deg)' }, { transform: 'rotate(3deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 700, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
9
+ spin: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' } },
10
+ launch: { keyframes: [{ transform: 'translate(0,0) rotate(0deg)' }, { transform: 'translate(-5%,5%) rotate(-8deg)', offset: .22 }, { transform: 'translate(18%,-22%) rotate(8deg)', offset: .55 }, { transform: 'translate(0,0) rotate(0deg)' }], options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' } },
11
+ search: { keyframes: [{ transform: 'translate(0,0) rotate(0deg)' }, { transform: 'translate(-12%,-8%) rotate(-12deg)' }, { transform: 'translate(12%,6%) rotate(12deg)' }, { transform: 'translate(0,0) rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '65% 65%' },
12
+ swing: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(18deg)' }, { transform: 'rotate(-15deg)' }, { transform: 'rotate(9deg)' }, { transform: 'rotate(-4deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 10%' },
13
+ float: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-18%) rotate(-8deg)', offset: .4 }, { transform: 'translateY(-10%) rotate(6deg)', offset: .7 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
14
+ shake: { keyframes: [{ transform: 'translateX(0)' }, { transform: 'translateX(-10%) rotate(-8deg)' }, { transform: 'translateX(10%) rotate(8deg)' }, { transform: 'translateX(-6%) rotate(-5deg)' }, { transform: 'translateX(4%) rotate(3deg)' }, { transform: 'translateX(0)' }], options: { duration: 650, easing: 'cubic-bezier(.22,1,.36,1)' } }
15
+ };
16
+
17
+ // Groups describe semantic families; emoji entries may override the group's motion.
18
+ export const groups = {
19
+ gestures: { label: 'Gestures', motion: 'wave', description: 'Hands with a friendly wave.' },
20
+ faces: { label: 'Faces', motion: 'bounce', description: 'Expressions with a little bounce.' },
21
+ hearts: { label: 'Hearts', motion: 'pulse', description: 'A soft double heartbeat.' },
22
+ objects: { label: 'Objects', motion: 'wobble', description: 'Everyday things, gently brought to life.' },
23
+ nature: { label: 'Nature', motion: 'float', description: 'A light, drifting movement.' },
24
+ travel: { label: 'Travel', motion: 'launch', description: 'Things ready to take off.' },
25
+ symbols: { label: 'Symbols', motion: 'spin', description: 'A small turn for signs and shapes.' },
26
+ people: { label: 'People', motion: 'bounce', description: 'People and activities.' },
27
+ food: { label: 'Food', motion: 'bounce', description: 'Food and drink.' },
28
+ activities: { label: 'Activities', motion: 'bounce', description: 'Sports, arts and celebrations.' },
29
+ flags: { label: 'Flags', motion: 'float', description: 'A gentle flag motion.' },
30
+ other: { label: 'Other', motion: 'bounce', description: 'A gentle fallback.' }
31
+ };
32
+
33
+ // Explicit mappings avoid guessing at modifier placement in complex sequences.
34
+ const skinTones = glyph => Object.fromEntries(['light', 'medium-light', 'medium', 'medium-dark', 'dark'].map((tone, index) => [tone, glyph + String.fromCodePoint(0x1f3fb + index)]));
35
+ // Variants are explicit: unrelated sequences are never silently normalized.
36
+ export const emoji = {
37
+ '👋': { group: 'gestures', name: 'Waving hand', skinTones: skinTones('👋') },
38
+ '👍': { group: 'gestures', name: 'Thumbs up', motion: 'bounce', skinTones: skinTones('👍') },
39
+ '👏': { group: 'gestures', name: 'Clapping hands', motion: 'shake', skinTones: skinTones('👏') },
40
+ '👎': { group: 'gestures', name: 'Thumbs down', motion: 'wobble', skinTones: skinTones('👎') },
41
+ '✋': { group: 'gestures', name: 'Raised hand', skinTones: skinTones('✋') },
42
+ '🖐️': { group: 'gestures', name: 'Hand with fingers splayed', skinTones: skinTones('🖐') },
43
+ '🤚': { group: 'gestures', name: 'Raised back of hand', skinTones: skinTones('🤚') },
44
+ '🤘': { group: 'gestures', name: 'Sign of the horns', motion: 'bounce', skinTones: skinTones('🤘') },
45
+ '🤙': { group: 'gestures', name: 'Call me hand', motion: 'wobble', skinTones: skinTones('🤙') },
46
+ '👌': { group: 'gestures', name: 'OK hand', motion: 'pulse', skinTones: skinTones('👌') },
47
+ '🤞': { group: 'gestures', name: 'Crossed fingers', motion: 'wobble', skinTones: skinTones('🤞') },
48
+ '🫶': { group: 'gestures', name: 'Heart hands', motion: 'pulse', skinTones: skinTones('🫶') },
49
+ '🙌': { group: 'gestures', name: 'Raising hands', motion: 'bounce', skinTones: skinTones('🙌') },
50
+ '🙏': { group: 'gestures', name: 'Folded hands', motion: 'bounce', skinTones: skinTones('🙏') },
51
+ '💪': { group: 'gestures', name: 'Flexed biceps', motion: 'pulse', skinTones: skinTones('💪') },
52
+ '👆': { group: 'gestures', name: 'Pointing up', motion: 'bounce', skinTones: skinTones('👆') },
53
+ '👇': { group: 'gestures', name: 'Pointing down', motion: 'bounce', skinTones: skinTones('👇') },
54
+ '👉': { group: 'gestures', name: 'Pointing right', motion: 'wobble', skinTones: skinTones('👉') },
55
+ '😊': { group: 'faces', name: 'Smiling face' }, '😂': { group: 'faces', name: 'Tears of joy', motion: 'shake' }, '🥳': { group: 'faces', name: 'Party face', motion: 'wobble' },
56
+ '😍': { group: 'faces', name: 'Smiling face with heart eyes', motion: 'pulse' }, '😉': { group: 'faces', name: 'Winking face', motion: 'wobble' }, '😎': { group: 'faces', name: 'Sunglasses face', motion: 'wobble' }, '🤔': { group: 'faces', name: 'Thinking face', motion: 'wobble' }, '😴': { group: 'faces', name: 'Sleeping face', motion: 'float' }, '😭': { group: 'faces', name: 'Loudly crying face', motion: 'shake' },
57
+ '❤️': { group: 'hearts', name: 'Red heart', variants: ['❤'] }, '💛': { group: 'hearts', name: 'Yellow heart' }, '💖': { group: 'hearts', name: 'Sparkling heart' },
58
+ '💚': { group: 'hearts', name: 'Green heart' }, '💙': { group: 'hearts', name: 'Blue heart' }, '💜': { group: 'hearts', name: 'Purple heart' }, '💔': { group: 'hearts', name: 'Broken heart', motion: 'shake' },
59
+ '🔍': { group: 'objects', name: 'Magnifying glass', motion: 'search' }, '🔔': { group: 'objects', name: 'Bell', motion: 'swing', decoration: 'sound' }, '🎁': { group: 'objects', name: 'Gift', motion: 'shake' }, '📡': { group: 'objects', name: 'Satellite antenna', motion: 'wobble', decoration: 'signal' }, '⏱️': { group: 'objects', name: 'Stopwatch', motion: 'swing', variants: ['⏱'] },
60
+ '💡': { group: 'objects', name: 'Light bulb', motion: 'pulse' }, '📷': { group: 'objects', name: 'Camera', motion: 'bounce' }, '🔑': { group: 'objects', name: 'Key', motion: 'wobble' }, '📌': { group: 'objects', name: 'Pushpin', motion: 'bounce' }, '📦': { group: 'objects', name: 'Package', motion: 'shake' }, '🧭': { group: 'objects', name: 'Compass', motion: 'spin' },
61
+ '🌸': { group: 'nature', name: 'Blossom' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', variants: ['☀'] },
62
+ '🌻': { group: 'nature', name: 'Sunflower', motion: 'swing' }, '🌈': { group: 'nature', name: 'Rainbow', motion: 'float' }, '🌙': { group: 'nature', name: 'Crescent moon', motion: 'float' }, '⭐': { group: 'nature', name: 'Star', motion: 'pulse', decoration: 'sparkle' }, '🔥': { group: 'nature', name: 'Fire', motion: 'wobble' }, '🌊': { group: 'nature', name: 'Water wave', motion: 'float' },
63
+ '🚀': { group: 'travel', name: 'Rocket' }, '✈️': { group: 'travel', name: 'Airplane', motion: 'glide', variants: ['✈'] }, '🚲': { group: 'travel', name: 'Bicycle', motion: 'ride' },
64
+ '🚗': { group: 'travel', name: 'Car', motion: 'ride' }, '🚂': { group: 'travel', name: 'Locomotive', motion: 'ride' }, '🛸': { group: 'travel', name: 'Flying saucer', motion: 'float' }, '🚢': { group: 'travel', name: 'Ship', motion: 'wobble' },
65
+ '✨': { group: 'symbols', name: 'Sparkles', motion: 'pulse', decoration: 'sparkle' }, '⚙️': { group: 'symbols', name: 'Gear', variants: ['⚙'] }, '🎯': { group: 'symbols', name: 'Target', motion: 'bounce' }, '🎵': { group: 'symbols', name: 'Musical note', motion: 'swing', decoration: 'sound' }, '💤': { group: 'symbols', name: 'Sleeping symbol', motion: 'float' }, '💥': { group: 'symbols', name: 'Collision', motion: 'pulse' }, '❗': { group: 'symbols', name: 'Exclamation mark', motion: 'bounce' }, '❓': { group: 'symbols', name: 'Question mark', motion: 'wobble' }, '✅': { group: 'symbols', name: 'Check mark', motion: 'bounce' }
66
+ };
67
+
68
+ // Declarative SVG layers never alter the native glyph. Anchors are relative to
69
+ // the consumer's glyph container and may be overridden for a particular font.
70
+ const signalShape = { viewBox: '0 0 100 100', elements: [{ tag: 'circle', attrs: { cx: '50', cy: '50', r: '29', fill: 'none', stroke: '#7c8ee8', 'stroke-width': '2.5' } }] };
71
+ const notesShape = { viewBox: '0 0 100 100', elements: [
72
+ { tag: 'path', attrs: { d: 'M24 37c-7 3-7 8-3 10s9-1 9-5V23l12-3v15c-7 3-7 8-3 10s9-1 9-5V12l-24 6z', fill: '#b780c8' } },
73
+ { tag: 'path', attrs: { d: 'M75 67c-5 2-5 6-2 7s7-1 7-4V54l8-2v12c-5 2-5 6-2 7s7-1 7-4V46l-18 5z', fill: '#dc8f72' } }
74
+ ] };
75
+ const sparkleShape = { viewBox: '0 0 100 100', elements: [
76
+ { tag: 'path', attrs: { d: 'M18 9c1.5 9 3 10.5 12 12-9 1.5-10.5 3-12 12-1.5-9-3-10.5-12-12 9-1.5 10.5-3 12-12z', fill: '#e0a63e' } },
77
+ { tag: 'path', attrs: { d: 'M80 58c1 6 2 7 8 8-6 1-7 2-8 8-1-6-2-7-8-8 6-1 7-2 8-8z', fill: '#d787ad' } }
78
+ ] };
79
+ const centered = { width: '160%', height: '160%', overflow: 'visible' };
80
+ export const decorations = {
81
+ signal: {
82
+ anchor: { x: '50%', y: '50%' },
83
+ layers: [0, 180, 360].map(delay => ({
84
+ svg: signalShape, style: centered,
85
+ keyframes: [{ opacity: .75, transform: 'translate(-50%, -50%) scale(.55)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2)' }],
86
+ options: { duration: 700, delay, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'backwards' }
87
+ }))
88
+ },
89
+ sound: {
90
+ anchor: { x: '50%', y: '50%' },
91
+ layers: [{ svg: notesShape, style: centered,
92
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -45%) scale(.75)' }, { opacity: .9, transform: 'translate(-50%, -55%) scale(1)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -70%) scale(1.1)' }],
93
+ options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
94
+ }]
95
+ },
96
+ sparkle: {
97
+ anchor: { x: '50%', y: '50%' },
98
+ layers: [{ svg: sparkleShape, style: centered,
99
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.6) rotate(-15deg)' }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1)', offset: .42 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2) rotate(10deg)' }],
100
+ options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
101
+ }]
102
+ }
103
+ };
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import { createElement, useEffect, useRef } from 'react';
3
+
4
+ const defaultEvents = ['pointerenter', 'focus', 'click'];
5
+ /** A native emoji with optional motion and decorations. */
6
+ export function createEmojiMotionComponent(emojiMotion) {
7
+ return function EmojiMotion({ emoji, skinTone, registry = emojiMotion, decoration = true, events = defaultEvents, triggerRef, display = 'inline', label, className, style, ...attributes }) {
8
+ const wrapper = useRef(null);
9
+ const glyph = useRef(null);
10
+ const visual = useRef(null);
11
+ const result = registry.resolve(emoji, { skinTone });
12
+ useEffect(() => {
13
+ if (!wrapper.current || !glyph.current) return;
14
+ return registry.bind(glyph.current, emoji, { trigger: triggerRef?.current ?? wrapper.current, events, skinTone, ...(decoration ? { decoration: { container: visual.current } } : {}) });
15
+ }, [registry, emoji, skinTone, decoration, events, triggerRef]);
16
+ return createElement('span', { ...attributes, className, ref: wrapper, role: 'img', 'aria-label': label ?? result?.name ?? emoji, tabIndex: attributes.tabIndex ?? (triggerRef ? -1 : 0), style: { ...style, display: display === 'block' ? 'block' : 'inline-block' } },
17
+ createElement('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
18
+ createElement('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? emoji)));
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { emojiMotion } from './lite.js';
3
+ import { createEmojiMotionComponent } from './react-core.js';
4
+ export const EmojiMotion = createEmojiMotionComponent(emojiMotion);
package/src/react.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { CSSProperties, HTMLAttributes, ReactElement, RefObject } from 'react';
2
+ import type { EmojiMotion as Registry, SkinTone } from './index.js';
3
+ export interface EmojiMotionProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
4
+ emoji: string;
5
+ skinTone?: SkinTone;
6
+ registry?: Registry;
7
+ decoration?: boolean;
8
+ display?: 'inline' | 'block';
9
+ events?: string[];
10
+ label?: string;
11
+ triggerRef?: RefObject<HTMLElement | null>;
12
+ style?: CSSProperties;
13
+ }
14
+ export declare function EmojiMotion(props: EmojiMotionProps): ReactElement;
package/src/react.js ADDED
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { emojiMotion } from './index.js';
3
+ import { createEmojiMotionComponent } from './react-core.js';
4
+ export const EmojiMotion = createEmojiMotionComponent(emojiMotion);
@@ -0,0 +1,142 @@
1
+ import { motions as defaultMotions, groups as defaultGroups, emoji as defaultEmoji, decorations as defaultDecorations } from './presets.js';
2
+
3
+ const toneNames = ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'];
4
+ function validateTone(tone) { if (!toneNames.includes(tone)) throw new Error(`Unknown skin tone "${tone}"`); }
5
+ function defaultClassification(info) {
6
+ const group = info.unicodeGroup; const sub = info.unicodeSubgroup;
7
+ if (group === 'Smileys & Emotion') return { group: sub === 'heart' ? 'hearts' : 'faces' };
8
+ if (group === 'People & Body') return { group: sub.startsWith('hand') ? 'gestures' : 'people', ...(sub.startsWith('hand') && sub !== 'hand-fingers-open' ? { motion: 'wobble' } : {}) };
9
+ if (group === 'Animals & Nature') return { group: 'nature', ...(sub.startsWith('animal-') ? { motion: 'bounce' } : {}) };
10
+ if (group === 'Food & Drink') return { group: 'food' };
11
+ if (group === 'Travel & Places') return { group: 'travel', motion: sub === 'transport-ground' ? 'ride' : sub === 'transport-air' ? 'glide' : sub === 'transport-water' ? 'wobble' : 'float' };
12
+ if (group === 'Activities') return { group: 'activities' };
13
+ if (group === 'Objects') return { group: 'objects' };
14
+ if (group === 'Symbols') return { group: 'symbols', motion: 'pulse' };
15
+ if (group === 'Flags') return { group: 'flags' };
16
+ return { group: 'other' };
17
+ }
18
+
19
+ /** Build a registry with an optional classifier. User definitions always win. */
20
+ export function createRegistry(config = {}, classifier) {
21
+ const useDefaults = config.defaults !== false;
22
+ const motions = { ...(useDefaults ? defaultMotions : {}), ...config.motions };
23
+ const decorations = { ...(useDefaults ? defaultDecorations : {}), ...config.decorations };
24
+ const groups = { ...(useDefaults ? defaultGroups : {}), ...config.groups };
25
+ const builtin = useDefaults ? defaultEmoji : {};
26
+ const user = config.emoji ?? {};
27
+ const entries = { ...builtin, ...user };
28
+ if (config.autoDetect === true && !classifier) throw new Error('Automatic classification is not included in the lite entry');
29
+ const autoDetect = (config.autoDetect ?? useDefaults) && Boolean(classifier);
30
+ const rules = config.classifications ?? {};
31
+ let defaultSkinTone = config.defaultSkinTone ?? 'default'; validateTone(defaultSkinTone);
32
+ function makeAliases(definitions) {
33
+ const result = new Map();
34
+ for (const [glyph, entry] of Object.entries(definitions)) {
35
+ if (!groups[entry.group]) throw new Error(`Unknown group "${entry.group}" for ${glyph}`);
36
+ const name = entry.motion ?? groups[entry.group].motion;
37
+ if (!motions[name]) throw new Error(`Unknown motion "${name}" for ${glyph}`);
38
+ if (entry.decoration && !decorations[entry.decoration]) throw new Error(`Unknown decoration "${entry.decoration}" for ${glyph}`);
39
+ for (const tone of Object.keys(entry.skinTones ?? {})) { validateTone(tone); if (tone === 'default') throw new Error('Use the canonical glyph for the default skin tone'); }
40
+ for (const alias of [...(entry.variants ?? []), ...Object.values(entry.skinTones ?? {})]) {
41
+ if (Object.hasOwn(definitions, alias) || result.has(alias)) throw new Error(`Duplicate emoji variant: ${alias}`);
42
+ result.set(alias, glyph);
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ const builtinAliases = makeAliases(builtin); const userAliases = makeAliases(user);
48
+ for (const [id, group] of Object.entries(groups)) if (!motions[group.motion]) throw new Error(`Unknown motion "${group.motion}" for group ${id}`);
49
+ for (const rule of [...Object.values(rules.groups ?? {}), ...Object.values(rules.subgroups ?? {}), ...(rules.fallback ? [rules.fallback] : [])]) {
50
+ if (!groups[rule.group]) throw new Error(`Unknown classification group "${rule.group}"`);
51
+ if (rule.motion && !motions[rule.motion]) throw new Error(`Unknown classification motion "${rule.motion}"`);
52
+ }
53
+ const active = new WeakMap();
54
+ function resolve(glyph, options = {}) {
55
+ if (typeof glyph !== 'string') return undefined;
56
+ if (options.skinTone !== undefined) validateTone(options.skinTone);
57
+ const info = autoDetect ? classifier.lookup(glyph) : undefined;
58
+ const desiredTone = options.skinTone ?? info?.tone ?? defaultSkinTone;
59
+ const requestedVariant = info && !info.multiTone && desiredTone !== 'default' ? info.skinTones[desiredTone] : undefined;
60
+ const candidates = [requestedVariant, glyph, userAliases.get(requestedVariant), userAliases.get(glyph), info?.canonical, builtinAliases.get(glyph)].filter(Boolean);
61
+ let canonical = candidates.find(key => Object.hasOwn(user, key));
62
+ let source = canonical ? 'user' : undefined;
63
+ const userRule = info && (rules.subgroups?.[info.unicodeSubgroup] ?? rules.groups?.[info.unicodeGroup]);
64
+ // A custom emoji wins first, followed by custom classification rules.
65
+ if (!canonical && !userRule) { canonical = candidates.find(key => Object.hasOwn(builtin, key)); if (canonical) source = 'preset'; }
66
+ let entry;
67
+ let autoRule;
68
+ if (canonical) entry = source === 'user' ? user[canonical] : builtin[canonical];
69
+ else if (info) {
70
+ canonical = info.canonical; source = userRule ? 'user-classification' : 'unicode';
71
+ autoRule = userRule ?? defaultClassification(info);
72
+ // A user-defined group overrides the default automatic motion too.
73
+ if (!userRule && config.groups?.[autoRule.group]) autoRule = { group: autoRule.group };
74
+ entry = { group: autoRule.group, name: info.name, motion: autoRule.motion };
75
+ } else if (autoDetect && classifier.looksLikeEmoji(glyph)) {
76
+ canonical = glyph; source = 'fallback'; autoRule = rules.fallback ?? { group: 'other' };
77
+ entry = { group: autoRule.group, name: 'Emoji', motion: autoRule.motion };
78
+ } else return undefined;
79
+ const group = groups[entry.group];
80
+ if (!group) throw new Error(`Unknown classification group "${entry.group}"`);
81
+ // An explicitly supplied group default outranks handcrafted preset motions.
82
+ // A user-defined emoji or classification remains more specific.
83
+ const motionId = source === 'preset' && Object.hasOwn(config.groups ?? {}, entry.group) ? group.motion : entry.motion ?? group.motion;
84
+ if (!motions[motionId]) throw new Error(`Unknown classification motion "${motionId}"`);
85
+ const mappedTones = entry.skinTones ?? info?.skinTones;
86
+ const explicitTone = entry.skinTones ? Object.entries(entry.skinTones).find(([, variant]) => variant === glyph)?.[0] : info?.tone;
87
+ const skinTone = options.skinTone ?? explicitTone ?? defaultSkinTone;
88
+ let renderedGlyph = glyph;
89
+ // Mixed-tone sequences are retained: no single requested tone can safely rewrite them.
90
+ if (mappedTones && !info?.multiTone) {
91
+ if (skinTone !== 'default') renderedGlyph = mappedTones[skinTone] ?? glyph;
92
+ else if (explicitTone) renderedGlyph = canonical;
93
+ }
94
+ return { ...entry, glyph: renderedGlyph, canonical, skinTone, groupId: entry.group, group, motionId, motion: motions[motionId], decorationPreset: entry.decoration ? decorations[entry.decoration] : undefined, source, ...(info ? { unicodeGroup: info.unicodeGroup, unicodeSubgroup: info.unicodeSubgroup } : {}) };
95
+ }
96
+ function play(element, glyph, options = {}) {
97
+ const result = resolve(glyph, { skinTone: options.skinTone });
98
+ if (!result || typeof element?.animate !== 'function') return null;
99
+ if (!options.ignoreReducedMotion && typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches) return null;
100
+ active.get(element)?.dispose();
101
+ const { motion } = result;
102
+ // transformOrigin belongs in keyframes so it never mutates the host element's styles.
103
+ const keyframes = motion.keyframes.map(frame => motion.origin ? { ...frame, transformOrigin: motion.origin } : { ...frame });
104
+ const animation = element.animate(keyframes, { ...motion.options, ...options.animation });
105
+ let decorationRoot;
106
+ const decorationAnimations = [];
107
+ const decorationOptions = options.decoration;
108
+ const preset = result.decorationPreset;
109
+ if (decorationOptions && preset) {
110
+ const { container, anchor = preset.anchor, flipX = false, rotate = 0 } = decorationOptions;
111
+ decorationRoot = container.ownerDocument.createElement('span');
112
+ decorationRoot.setAttribute('aria-hidden', 'true');
113
+ Object.assign(decorationRoot.style, { position: 'absolute', pointerEvents: 'none', left: anchor.x, top: anchor.y, width: '100%', height: '100%', transform: `scaleX(${flipX ? -1 : 1}) rotate(${rotate}deg)` });
114
+ container.appendChild(decorationRoot);
115
+ for (const layer of preset.layers) {
116
+ const node = layer.svg ? container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg') : container.ownerDocument.createElement('span');
117
+ if (layer.svg) {
118
+ node.setAttribute('viewBox', layer.svg.viewBox);
119
+ for (const shape of layer.svg.elements) {
120
+ const child = container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', shape.tag);
121
+ for (const [key, value] of Object.entries(shape.attrs)) child.setAttribute(key, value);
122
+ node.appendChild(child);
123
+ }
124
+ }
125
+ Object.assign(node.style, { position: 'absolute', left: '0', top: '0', opacity: '0', ...layer.style });
126
+ decorationRoot.appendChild(node);
127
+ decorationAnimations.push(node.animate(layer.keyframes, layer.options));
128
+ }
129
+ Promise.allSettled(decorationAnimations.map(item => item.finished)).then(() => decorationRoot.remove());
130
+ }
131
+ active.set(element, { dispose() { animation.cancel(); decorationAnimations.forEach(item => item.cancel()); decorationRoot?.remove(); } });
132
+ return animation;
133
+ }
134
+ function bind(element, glyph, options = {}) {
135
+ const trigger = options.trigger ?? element;
136
+ const events = options.events ?? ['pointerenter', 'focus'];
137
+ const handler = () => play(element, glyph, options);
138
+ for (const event of events) trigger.addEventListener(event, handler);
139
+ return () => { for (const event of events) trigger.removeEventListener(event, handler); active.get(element)?.dispose(); active.delete(element); };
140
+ }
141
+ return { resolve, play, bind, setDefaultSkinTone(tone) { validateTone(tone); defaultSkinTone = tone; }, getDefaultSkinTone: () => defaultSkinTone, list: () => Object.keys(entries), groups: () => Object.entries(groups).map(([id, group]) => ({ id, ...group })) };
142
+ }
@@ -0,0 +1,15 @@
1
+
2
+ /** Svelte action. Apply it to a span dedicated to the emoji. */
3
+ export function createAnimateEmoji(emojiMotion) {
4
+ return function animateEmoji(node, initial) {
5
+ let dispose = () => {};
6
+ function update(options) {
7
+ dispose();
8
+ const { emoji, skinTone, registry = emojiMotion, trigger = node, decoration = false, events = ['pointerenter', 'focus', 'click'] } = options;
9
+ node.textContent = registry.resolve(emoji, { skinTone })?.glyph ?? emoji;
10
+ dispose = registry.bind(node, emoji, { trigger, events, skinTone, ...(decoration && node.parentElement ? { decoration: { container: node.parentElement } } : {}) });
11
+ }
12
+ update(initial);
13
+ return { update, destroy() { dispose(); } };
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ import { emojiMotion } from './lite.js';
2
+ import { createAnimateEmoji } from './svelte-core.js';
3
+ export const animateEmoji = createAnimateEmoji(emojiMotion);
@@ -0,0 +1,3 @@
1
+ import type { EmojiMotion as Registry, SkinTone } from './index.js';
2
+ export interface AnimateEmojiOptions { emoji: string; skinTone?: SkinTone; registry?: Registry; trigger?: EventTarget; decoration?: boolean; events?: string[] }
3
+ export declare function animateEmoji(node: HTMLElement, options: AnimateEmojiOptions): { update(options: AnimateEmojiOptions): void; destroy(): void };
package/src/svelte.js ADDED
@@ -0,0 +1,3 @@
1
+ import { emojiMotion } from './index.js';
2
+ import { createAnimateEmoji } from './svelte-core.js';
3
+ export const animateEmoji = createAnimateEmoji(emojiMotion);