Skip to content

Commit

Permalink
feat: add support for custom emoji fonts (#308)
Browse files Browse the repository at this point in the history
Fixes #82
  • Loading branch information
nolanlawson committed Dec 29, 2022
1 parent af58a92 commit da524c2
Show file tree
Hide file tree
Showing 14 changed files with 258 additions and 128 deletions.
136 changes: 97 additions & 39 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion bin/bundlesize.js
Expand Up @@ -5,7 +5,7 @@ import { promisify } from 'util'
import prettyBytes from 'pretty-bytes'
import fs from 'fs/promises'

const MAX_SIZE_MIN = '42 kB'
const MAX_SIZE_MIN = '42.5 kB'
const MAX_SIZE_MINGZ = '15 kB'

const FILENAME = './bundle.js'
Expand Down
10 changes: 9 additions & 1 deletion bin/generateCssDocs.js
Expand Up @@ -4,9 +4,17 @@ import { markdownTable as table } from 'markdown-table'
import { readFile, writeFile } from './fs.js'
import { replaceInReadme } from './replaceInReadme.js'
import postcss from 'postcss'
import { FONT_FAMILY } from '../src/picker/constants.js'

const __dirname = path.dirname(new URL(import.meta.url).pathname)

// To avoid code duplication, we could not declare this in variables.scss
const MANUAL_VARS = [{
name: '--emoji-font-family',
value: FONT_FAMILY,
comment: 'Font family for a custom emoji font (as opposed to native emoji)'
}]

const START_MARKER = '<!-- CSS variable options start -->'
const END_MARKER = '<!-- CSS variable options end -->'

Expand All @@ -33,7 +41,7 @@ async function generateCssVariablesData (css) {
const ast = postcss.parse(css)
const hosts = ast.nodes.filter(({ selector }) => ([':host', ':host,\n:host(.light)'].includes(selector)))
const darkHosts = ast.nodes.filter(({ selector }) => selector === ':host(.dark)')
const vars = hosts.map(extractCSSVariables).flat()
const vars = hosts.map(extractCSSVariables).flat().concat(MANUAL_VARS)
const darkVars = darkHosts.map(extractCSSVariables).flat()

const sortedVars = vars.sort((a, b) => a.name < b.name ? -1 : 1)
Expand Down
16 changes: 16 additions & 0 deletions custom-elements.json
Expand Up @@ -33,6 +33,12 @@
"description": "The emoji to use for the skin tone picker",
"type": "string",
"default": "\"🖐\""
},
{
"name": "emoji-version",
"description": "Maximum supported emoji version as a number (e.g. `14.0` or `13.1`). Setting this disables the default emoji support detection.",
"type": "string",
"default": null
}
],
"members": [
Expand Down Expand Up @@ -68,6 +74,11 @@
"name": "customCategorySorting",
"description": "Function to sort custom category strings (sorted alphabetically by default)",
"kind": "field"
},
{
"name": "emojiVersion",
"description": "Maximum supported emoji version as a number (e.g. `14.0` or `13.1`). Setting this disables the default emoji support detection.",
"kind": "field"
}
],
"events": [
Expand Down Expand Up @@ -126,6 +137,11 @@
"description": "Font size of custom emoji category headings (default: `1rem`)",
"default": "\"1rem\""
},
{
"name": "--emoji-font-family",
"description": "Font family for a custom emoji font (as opposed to native emoji) (default: `\"Twemoji Mozilla\",\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\",\"EmojiOne Color\",\"Android Emoji\",sans-serif`)",
"default": "\"\\\"Twemoji Mozilla\\\",\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\",\\\"Segoe UI Symbol\\\",\\\"Noto Color Emoji\\\",\\\"EmojiOne Color\\\",\\\"Android Emoji\\\",sans-serif\""
},
{
"name": "--emoji-padding",
"description": "Vertical and horizontal padding on emoji (default: `0.5rem`)",
Expand Down
27 changes: 27 additions & 0 deletions docs/demos/twemoji-mozilla/index.html
@@ -0,0 +1,27 @@
<!doctype html>
<html lang=en>
<head>
<title>emoji-picker-element: using Twemoji Mozilla COLR font</title>
<style>
@font-face {
font-family: "MozillaTwemojiColr";
src: url("https://cdn.jsdelivr.net/npm/twemoji-colr-font@^14/twemoji.woff2") format("woff2");
}

emoji-picker {
--emoji-font-family: MozillaTwemojiColr;
}
</style>
</head>
<body>
<h1>emoji-picker-element: using Twemoji Mozilla COLR font</h1>
<p>
This demo shows how to use emoji-picker-element with the <a href="https://github.com/mozilla/twemoji-colr">Twemoji Mozilla COLR font</a> as a custom emoji font.
Note that this carries a performance cost due to downloading the additional font file. Also note that alignment may be off in Safari due to <a href="https://bugs.webkit.org/show_bug.cgi?id=249943">a WebKit bug</a>, and
that <a href="https://caniuse.com/colr">not all browsers support COLR fonts</a>.
Use this approach with care.
</p>
<emoji-picker emoji-version="14.0"></emoji-picker>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
</body>
</html>
44 changes: 0 additions & 44 deletions docs/demos/twemoji/index.html

This file was deleted.

15 changes: 8 additions & 7 deletions picker.d.ts
Expand Up @@ -8,14 +8,15 @@ export default class Picker extends HTMLElement {
customCategorySorting?: (a: string, b: string) => number;
/**
*
* @param dataSource - URL to fetch the emoji data from (`data-source` when used as an attribute)
* @param locale - Locale string
* @param i18n - i18n object (see below for details)
* @param skinToneEmoji - The emoji to use for the skin tone picker (`skin-tone-emoji` when used as an attribute)
* @param customEmoji - Array of custom emoji
* @param customCategorySorting - Function to sort custom category strings (sorted alphabetically by default)
* @param dataSource - URL to fetch the emoji data from (`data-source` when used as an attribute).
* @param locale - Locale string.
* @param i18n - i18n object (see below for details).
* @param skinToneEmoji - The emoji to use for the skin tone picker (`skin-tone-emoji` when used as an attribute).
* @param customEmoji - Array of custom emoji.
* @param customCategorySorting - Function to sort custom category strings (sorted alphabetically by default).
* @param emojiVersion - Maximum supported emoji version as a number (e.g. `14.0` or `13.1`). Setting this disables the default emoji support detection.
*/
constructor({ dataSource, locale, i18n, skinToneEmoji, customEmoji, customCategorySorting }?: PickerConstructorOptions);
constructor({ dataSource, locale, i18n, skinToneEmoji, customEmoji, customCategorySorting, emojiVersion }?: PickerConstructorOptions);

addEventListener<K extends keyof EmojiPickerEventMap>(type: K, listener: (this: Picker, ev: EmojiPickerEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
Expand Down
1 change: 1 addition & 0 deletions shared.d.ts
Expand Up @@ -35,6 +35,7 @@ export interface PickerConstructorOptions {
skinToneEmoji?: string;
customEmoji?: CustomEmoji[];
customCategorySorting?: (a: string, b: string) => number;
emojiVersion?: number;
}
export interface I18n {
emojiUnsupportedMessage: string;
Expand Down
20 changes: 13 additions & 7 deletions src/picker/PickerElement.js
@@ -1,6 +1,6 @@
import SveltePicker from './components/Picker/Picker.svelte'
import { DEFAULT_DATA_SOURCE, DEFAULT_LOCALE } from '../database/constants'
import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI } from './constants'
import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI, FONT_FAMILY } from './constants'
import enI18n from './i18n/en.js'
import Database from './ImportedDatabase'

Expand All @@ -11,16 +11,20 @@ const PROPS = [
'dataSource',
'i18n',
'locale',
'skinToneEmoji'
'skinToneEmoji',
'emojiVersion'
]

// Styles injected ourselves, so we can declare the FONT_FAMILY variable in one place
const EXTRA_STYLES = `:host{--emoji-font-family:${FONT_FAMILY}}`

export default class PickerElement extends HTMLElement {
constructor (props) {
performance.mark('initialLoad')
super()
this.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = process.env.STYLES
style.textContent = process.env.STYLES + EXTRA_STYLES
this.shadowRoot.appendChild(style)
this._ctx = {
// Set defaults
Expand All @@ -30,6 +34,7 @@ export default class PickerElement extends HTMLElement {
customCategorySorting: DEFAULT_CATEGORY_SORTING,
customEmoji: null,
i18n: enI18n,
emojiVersion: null,
...props
}
// Handle properties set before the element was upgraded
Expand Down Expand Up @@ -62,15 +67,16 @@ export default class PickerElement extends HTMLElement {
}

static get observedAttributes () {
return ['locale', 'data-source', 'skin-tone-emoji'] // complex objects aren't supported, also use kebab-case
return ['locale', 'data-source', 'skin-tone-emoji', 'emoji-version'] // complex objects aren't supported, also use kebab-case
}

attributeChangedCallback (attrName, oldValue, newValue) {
// convert from kebab-case to camelcase
// see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
this._set(
// convert from kebab-case to camelcase
// see https://github.com/sveltejs/svelte/issues/3852#issuecomment-665037015
attrName.replace(/-([a-z])/g, (_, up) => up.toUpperCase()),
newValue
// convert string attribute to float if necessary
attrName === 'emoji-version' ? parseFloat(newValue) : newValue
)
}

Expand Down
30 changes: 16 additions & 14 deletions src/picker/components/Picker/Picker.js
@@ -1,16 +1,15 @@
/* eslint-disable prefer-const,no-labels,no-inner-declarations */

import { onMount, tick } from 'svelte'
import { groups as defaultGroups, customGroup } from '../../groups'
import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants'
import { requestIdleCallback } from '../../utils/requestIdleCallback'
import { hasZwj } from '../../utils/hasZwj'
import { emojiSupportLevelPromise, supportedZwjEmojis } from '../../utils/emojiSupport'
import { detectEmojiSupportLevel, supportedZwjEmojis } from '../../utils/emojiSupport'
import { applySkinTone } from '../../utils/applySkinTone'
import { halt } from '../../utils/halt'
import { incrementOrDecrement } from '../../utils/incrementOrDecrement'
import {
DEFAULT_NUM_COLUMNS,
FONT_FAMILY,
MOST_COMMONLY_USED_EMOJI,
TIMEOUT_BEFORE_LOADING_MESSAGE
} from '../../constants'
Expand All @@ -19,7 +18,6 @@ import { summarizeEmojisForUI } from '../../utils/summarizeEmojisForUI'
import * as widthCalculator from '../../utils/widthCalculator'
import { checkZwjSupport } from '../../utils/checkZwjSupport'
import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame'
import { tick } from 'svelte'
import { requestAnimationFrame } from '../../utils/requestAnimationFrame'
import { uniq } from '../../../shared/uniq'
import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js'
Expand All @@ -30,6 +28,7 @@ export let i18n
export let database
export let customEmoji
export let customCategorySorting
export let emojiVersion

// private
let initialLoad = true
Expand Down Expand Up @@ -97,11 +96,15 @@ const isSkinToneOption = element => /^skintone-/.test(element.id)
// Determine the emoji support level (in requestIdleCallback)
//

emojiSupportLevelPromise.then(level => {
// Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo
/* istanbul ignore next */
if (!level) {
message = i18n.emojiUnsupportedMessage
onMount(() => {
if (!emojiVersion) {
detectEmojiSupportLevel().then(level => {
// Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo
/* istanbul ignore next */
if (!level) {
message = i18n.emojiUnsupportedMessage
}
})
}
})

Expand Down Expand Up @@ -142,7 +145,6 @@ $: {

/* eslint-disable no-unused-vars */
$: pickerStyle = `
--font-family: ${FONT_FAMILY};
--num-groups: ${groups.length};
--indicator-opacity: ${searchMode ? 0 : 1};
--num-skintones: ${NUM_SKIN_TONES};`
Expand Down Expand Up @@ -298,11 +300,11 @@ $: {
const zwjEmojisToCheck = currentEmojis
.filter(emoji => emoji.unicode) // filter custom emoji
.filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode))
if (zwjEmojisToCheck.length) {
if (!emojiVersion && zwjEmojisToCheck.length) {
// render now, check their length later
requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck))
} else {
currentEmojis = currentEmojis.filter(isZwjSupported)
currentEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported)
// Reset scroll top to 0 when emojis change
requestAnimationFrame(() => resetScrollTopIfPossible(tabpanelElement))
}
Expand All @@ -321,13 +323,13 @@ function isZwjSupported (emoji) {
}

async function filterEmojisByVersion (emojis) {
const emojiSupportLevel = await emojiSupportLevelPromise
const emojiSupportLevel = emojiVersion || await detectEmojiSupportLevel()
// !version corresponds to custom emoji
return emojis.filter(({ version }) => !version || version <= emojiSupportLevel)
}

async function summarizeEmojis (emojis) {
return summarizeEmojisForUI(emojis, await emojiSupportLevelPromise)
return summarizeEmojisForUI(emojis, emojiVersion || await detectEmojiSupportLevel())
}

async function getEmojisByGroup (group) {
Expand Down
2 changes: 1 addition & 1 deletion src/picker/constants.js
Expand Up @@ -24,7 +24,7 @@ export const MOST_COMMONLY_USED_EMOJI = [
]

// It's important to list Twemoji Mozilla before everything else, because Mozilla bundles their
// own font on some platforms (notably Windows and Linux as of this writing). Typically Mozilla
// own font on some platforms (notably Windows and Linux as of this writing). Typically, Mozilla
// updates faster than the underlying OS, and we don't want to render older emoji in one font and
// newer emoji in another font:
// https://github.com/nolanlawson/emoji-picker-element/pull/268#issuecomment-1073347283
Expand Down
2 changes: 1 addition & 1 deletion src/picker/styles/picker.scss
Expand Up @@ -89,7 +89,7 @@ button.emoji,
width: var(--total-emoji-size);
line-height: 1;
overflow: hidden;
font-family: var(--font-family);
font-family: var(--emoji-font-family);
cursor: pointer;

// see https://css-tricks.com/solving-sticky-hover-states-with-media-hover-hover/
Expand Down
36 changes: 23 additions & 13 deletions src/picker/utils/emojiSupport.js
@@ -1,18 +1,28 @@
import { determineEmojiSupportLevel } from './determineEmojiSupportLevel'
import { requestIdleCallback } from './requestIdleCallback'
import { requestIdleCallback } from './requestIdleCallback.js'

// Check which emojis we know for sure aren't supported, based on Unicode version level
export const emojiSupportLevelPromise = new Promise(resolve => (
requestIdleCallback(() => (
resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
))
))
let promise
export const detectEmojiSupportLevel = () => {
if (!promise) {
// Delay so it can run while the IDB database is being created by the browser (on another thread).
// This helps especially with first load – we want to start pre-populating the database on the main thread,
// and then wait for IDB to commit everything, and while waiting we run this check.
promise = new Promise(resolve => (
requestIdleCallback(() => (
resolve(determineEmojiSupportLevel()) // delay so ideally this can run while IDB is first populating
))
))

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
promise.then(emojiSupportLevel => {
console.log('emoji support level', emojiSupportLevel)
})
}
}
return promise
}
// determine which emojis containing ZWJ (zero width joiner) characters
// are supported (rendered as one glyph) rather than unsupported (rendered as two or more glyphs)
export const supportedZwjEmojis = new Map()

/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
emojiSupportLevelPromise.then(emojiSupportLevel => {
console.log('emoji support level', emojiSupportLevel)
})
}

0 comments on commit da524c2

Please sign in to comment.