emojis! 🧑‍🎨 🎨 ✏️ (#2814)

everyone ❤️'s emojis:
https://dropbox.tech/application/dropbox-paper-emojis-and-exformation


https://github.com/tldraw/tldraw/assets/469604/8f99f485-de98-44d1-93cb-6eb9c2d87d99




### Change Type

- [x] `minor` — New feature

### Test Plan

1. Test adding lots of emojis!

### Release Notes

- Adds emoji picker to text fields.
This commit is contained in:
Mime Čuvalo 2024-02-13 14:46:55 +00:00 committed by GitHub
parent c4ffa05b12
commit 32f641c1d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 426 additions and 15 deletions

Binary file not shown.

View file

@ -1140,6 +1140,8 @@ export class Group2d extends Geometry2d {
// (undocumented) // (undocumented)
getArea(): number; getArea(): number;
// (undocumented) // (undocumented)
getLabel(): Geometry2d;
// (undocumented)
getVertices(): Vec[]; getVertices(): Vec[];
// (undocumented) // (undocumented)
hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean;

View file

@ -22688,6 +22688,38 @@
"isAbstract": false, "isAbstract": false,
"name": "getArea" "name": "getArea"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Group2d#getLabel:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getLabel(): "
},
{
"kind": "Reference",
"text": "Geometry2d",
"canonicalReference": "@tldraw/editor!Geometry2d:class"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [],
"isOptional": false,
"isAbstract": false,
"name": "getLabel"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Group2d#getVertices:member(1)", "canonicalReference": "@tldraw/editor!Group2d#getVertices:member(1)",

View file

@ -70,6 +70,10 @@ export class Group2d extends Geometry2d {
return this.children[0].area return this.children[0].area
} }
getLabel() {
return this.children.filter((c) => c.isLabel)[0]
}
toSimpleSvgPath() { toSimpleSvgPath() {
let path = '' let path = ''
for (const child of this.children) { for (const child of this.children) {

View file

@ -8,7 +8,7 @@ import { Atom, atom, react } from '@tldraw/state'
// `true` by default in development and staging, and `false` in production. // `true` by default in development and staging, and `false` in production.
/** @internal */ /** @internal */
export const featureFlags: Record<string, DebugFlag<boolean>> = { export const featureFlags: Record<string, DebugFlag<boolean>> = {
// canMoveArrowLabel: createFeatureFlag('canMoveArrowLabel'), emojiMenu: createFeatureFlag('emojiMenu'),
} }
/** @internal */ /** @internal */
@ -111,16 +111,16 @@ function createDebugValue<T>(
}) })
} }
// function createFeatureFlag( function createFeatureFlag(
// name: string, name: string,
// defaults: Defaults<boolean> = { all: true, production: false } defaults: Defaults<boolean> = { all: true, production: false }
// ) { ) {
// return createDebugValueBase({ return createDebugValueBase({
// name, name,
// defaults, defaults,
// shouldStoreForSession: true, shouldStoreForSession: true,
// }) })
// } }
function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> { function createDebugValueBase<T>(def: DebugFlagDef<T>): DebugFlag<T> {
const defaultValue = getDefaultValue(def) const defaultValue = getDefaultValue(def)

View file

@ -1420,8 +1420,12 @@ export interface TLUiDialog {
// (undocumented) // (undocumented)
component: (props: TLUiDialogProps) => any; component: (props: TLUiDialogProps) => any;
// (undocumented) // (undocumented)
dialogProps?: any;
// (undocumented)
id: string; id: string;
// (undocumented) // (undocumented)
isCustomDialog?: boolean;
// (undocumented)
onClose?: () => void; onClose?: () => void;
} }

View file

@ -16213,6 +16213,33 @@
"endIndex": 4 "endIndex": 4
} }
}, },
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiDialog#dialogProps:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "dialogProps?: "
},
{
"kind": "Content",
"text": "any"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "dialogProps",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiDialog#id:member", "canonicalReference": "@tldraw/tldraw!TLUiDialog#id:member",
@ -16240,6 +16267,33 @@
"endIndex": 2 "endIndex": 2
} }
}, },
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiDialog#isCustomDialog:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "isCustomDialog?: "
},
{
"kind": "Content",
"text": "boolean"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "isCustomDialog",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/tldraw!TLUiDialog#onClose:member", "canonicalReference": "@tldraw/tldraw!TLUiDialog#onClose:member",

View file

@ -45,6 +45,7 @@
"tldraw.css" "tldraw.css"
], ],
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
@ -56,6 +57,7 @@
"@tldraw/editor": "workspace:*", "@tldraw/editor": "workspace:*",
"canvas-size": "^1.2.6", "canvas-size": "^1.2.6",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"emoji-mart": "patch:emoji-mart@npm%3A5.5.2#~/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch",
"hotkeys-js": "^3.11.2", "hotkeys-js": "^3.11.2",
"lz-string": "^1.4.4" "lz-string": "^1.4.4"
}, },

View file

@ -32,6 +32,7 @@ import { TextLabel } from '../shared/TextLabel'
import { import {
FONT_FAMILIES, FONT_FAMILIES,
LABEL_FONT_SIZES, LABEL_FONT_SIZES,
LABEL_PADDING,
STROKE_SIZES, STROKE_SIZES,
TEXT_PROPS, TEXT_PROPS,
} from '../shared/default-shape-constants' } from '../shared/default-shape-constants'
@ -59,7 +60,6 @@ import {
} from './components/SolidStyleOval' } from './components/SolidStyleOval'
import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon' import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon'
const LABEL_PADDING = 16
const MIN_SIZE_WITH_LABEL = 17 * 3 const MIN_SIZE_WITH_LABEL = 17 * 3
/** @public */ /** @public */

View file

@ -58,4 +58,6 @@ export const LABEL_TO_ARROW_PADDING = 20
/** @internal */ /** @internal */
export const ARROW_LABEL_PADDING = 4.25 export const ARROW_LABEL_PADDING = 4.25
/** @internal */ /** @internal */
export const LABEL_PADDING = 16
/** @internal */
export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10 export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10

View file

@ -0,0 +1,68 @@
import { TLEventInfo, track, useEditor } from '@tldraw/editor'
import { Picker } from 'emoji-mart'
import { useEffect, useRef } from 'react'
import { useDefaultColorTheme } from '../ShapeFill'
export type EmojiDialogProps = {
onClose: () => void
text: string
top: number
left: number
onEmojiSelect: (emoji: any) => void
onClickOutside: () => void
}
export default track(function EmojiDialog({
top,
left,
onEmojiSelect,
onClickOutside,
}: EmojiDialogProps) {
const editor = useEditor()
const theme = useDefaultColorTheme()
const ref = useRef(null)
const instance = useRef<any>(null)
useEffect(() => {
const eventListener = (event: TLEventInfo) => {
if (event.name === 'pointer_down') {
onClickOutside()
}
}
editor.on('event', eventListener)
instance.current = new Picker({
maxFrequentRows: 0,
onEmojiSelect,
onClickOutside,
theme: theme.id,
searchPosition: 'static',
previewPosition: 'none',
ref,
})
EmojiDialogSingleton = instance.current
return () => {
instance.current = null
EmojiDialogSingleton = null
editor.off('event', eventListener)
}
}, [editor, theme.id, onEmojiSelect, onClickOutside])
return (
<div
ref={ref}
style={{
position: 'absolute',
top,
left,
zIndex: 400,
pointerEvents: 'all',
transformOrigin: '0 0',
transform: 'scale(0.85)',
}}
/>
)
})
export let EmojiDialogSingleton: { component: any } | null = null

View file

@ -0,0 +1,11 @@
import { Suspense, lazy } from 'react'
const EmojiDialog = lazy(() => import('./EmojiDialog'))
export default function EmojiDialogLazy(props: any) {
return (
<Suspense fallback={<div />}>
<EmojiDialog {...props} />
</Suspense>
)
}

View file

@ -1,8 +1,15 @@
/* eslint-disable no-inner-declarations */ /* eslint-disable no-inner-declarations */
import { import {
Editor,
Group2d,
Rectangle2d,
TLArrowShape,
TLGeoShape,
TLShape, TLShape,
TLTextShape,
TLUnknownShape, TLUnknownShape,
featureFlags,
getPointerInfo, getPointerInfo,
preventDefault, preventDefault,
stopEventPropagation, stopEventPropagation,
@ -11,6 +18,16 @@ import {
} from '@tldraw/editor' } from '@tldraw/editor'
import React, { useCallback, useEffect, useRef } from 'react' import React, { useCallback, useEffect, useRef } from 'react'
import { INDENT, TextHelpers } from './TextHelpers' import { INDENT, TextHelpers } from './TextHelpers'
import {
ARROW_LABEL_FONT_SIZES,
ARROW_LABEL_PADDING,
FONT_FAMILIES,
FONT_SIZES,
LABEL_FONT_SIZES,
LABEL_PADDING,
TEXT_PROPS,
} from './default-shape-constants'
import { useEmojis } from './useEmojis'
export function useEditableText<T extends Extract<TLShape, { props: { text: string } }>>( export function useEditableText<T extends Extract<TLShape, { props: { text: string } }>>(
id: T['id'], id: T['id'],
@ -18,8 +35,12 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
text: string text: string
) { ) {
const editor = useEditor() const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null) const rInput = useRef<HTMLTextAreaElement>(null)
const { onKeyDown: onEmojiKeyDown } = useEmojis(rInput.current, (text: string) => {
editor.updateShapes<TLUnknownShape & { props: { text: string } }>([
{ id, type, props: { text } },
])
})
const rSkipSelectOnFocus = useRef(false) const rSkipSelectOnFocus = useRef(false)
const rSelectionRanges = useRef<Range[] | null>() const rSelectionRanges = useRef<Range[] | null>()
@ -101,6 +122,14 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!isEditing) return if (!isEditing) return
if (featureFlags.emojiMenu.get()) {
const coords = getCaretPosition(editor, e.target as HTMLTextAreaElement)
const isHandledByEmoji = onEmojiKeyDown(e, coords)
if (isHandledByEmoji) {
return
}
}
switch (e.key) { switch (e.key) {
case 'Enter': { case 'Enter': {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
@ -119,7 +148,7 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
} }
} }
}, },
[editor, isEditing] [editor, isEditing, onEmojiKeyDown]
) )
// When the text changes, update the text value. // When the text changes, update the text value.
@ -207,3 +236,46 @@ export function useEditableText<T extends Extract<TLShape, { props: { text: stri
isEmpty, isEmpty,
} }
} }
function getCaretPosition(editor: Editor, inputEl: HTMLTextAreaElement | null) {
const selectedShape = editor.getOnlySelectedShape() as
| TLTextShape
| TLArrowShape
| TLGeoShape
| undefined
if (!selectedShape) return null
let labelX, labelY
if (selectedShape.type === 'text') {
labelX = selectedShape.x
labelY = selectedShape.y
} else {
const geometry = editor.getShapeGeometry(selectedShape)
if (!(geometry instanceof Group2d)) return null
const labelGeometry = geometry.getLabel() as Rectangle2d
const padding = selectedShape.type === 'arrow' ? ARROW_LABEL_PADDING : LABEL_PADDING
labelX = selectedShape.x + labelGeometry.x + padding
labelY = selectedShape.y + labelGeometry.y + padding
}
const sizeSet =
selectedShape.type === 'arrow'
? ARROW_LABEL_FONT_SIZES
: selectedShape.type === 'text'
? FONT_SIZES
: LABEL_FONT_SIZES
const substring = !inputEl ? '' : inputEl.value.substring(0, inputEl.selectionStart)
const { w, h } = editor.textMeasure.measureText(substring, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[selectedShape.props.font],
fontSize: sizeSet[selectedShape.props.size],
maxWidth: null,
})
const { x, y } = editor.pageToScreen({ x: labelX, y: labelY })
const zoomLevel = editor.getZoomLevel()
const top = y + h * zoomLevel
const left = x + w * zoomLevel
return { top, left }
}

View file

@ -0,0 +1,125 @@
import { useState } from 'react'
import { useDialogs } from '../../ui/hooks/useDialogsProvider'
import EmojiDialog from './emojis'
import { EmojiDialogSingleton } from './emojis/EmojiDialog'
export function useEmojis(inputEl: HTMLTextAreaElement | null, onComplete: (text: string) => void) {
const { addDialog, removeDialog } = useDialogs()
const [emojiSearchText, setEmojiSearchText] = useState('')
const [isEmojiMenuOpen, setIsEmojiMenuOpen] = useState(false)
const closeMenu = () => {
setIsEmojiMenuOpen(false)
removeDialog('emoji')
setEmojiSearchText('')
}
const onEmojiSelect = (emoji: any) => {
if (!inputEl) return
const searchText = EmojiDialogSingleton?.component.refs.searchInput.current.value
inputEl.focus()
inputEl.setSelectionRange(
inputEl.selectionStart - searchText.length - 1,
inputEl.selectionStart
)
inputEl.setRangeText(emoji.native)
inputEl.setSelectionRange(inputEl.selectionStart + 1, inputEl.selectionStart + 1)
onComplete(inputEl.value)
closeMenu()
}
const onKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
coords: { top: number; left: number } | null
) => {
const emojiPicker = EmojiDialogSingleton?.component
switch (e.key) {
case ':': {
if (isEmojiMenuOpen) {
closeMenu()
return false
}
setEmojiSearchText('')
addDialog({
id: 'emoji',
component: EmojiDialog,
isCustomDialog: true,
dialogProps: {
onEmojiSelect,
onClickOutside: closeMenu,
top: coords?.top,
left: coords?.left,
},
})
setIsEmojiMenuOpen(true)
return true
}
case ' ':
// fall-through
case 'Escape': {
if (isEmojiMenuOpen) {
closeMenu()
return true
}
return false
}
case 'Enter':
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown': {
if (isEmojiMenuOpen) {
emojiPicker.handleSearchKeyDown({
...e,
preventDefault: () => {
/* shim */
},
stopImmediatePropagation: () => {
/* shim */
},
})
e.preventDefault()
return true
}
return false
}
case 'Backspace':
if (isEmojiMenuOpen) {
if (!emojiSearchText) {
closeMenu()
return true
}
const text = emojiSearchText.slice(0, -1)
emojiPicker.refs.searchInput.current.value = text
emojiPicker.handleSearchInput()
setEmojiSearchText(text)
return true
}
return false
default:
if (isEmojiMenuOpen && e.key.length === 1 && e.key.match(/[a-zA-Z0-9]/)) {
emojiPicker.refs.searchInput.current.value = emojiSearchText + e.key
emojiPicker.handleSearchInput()
setEmojiSearchText(emojiSearchText + e.key)
return true
}
return isEmojiMenuOpen
}
}
return { onKeyDown }
}

View file

@ -3,7 +3,13 @@ import { useContainer } from '@tldraw/editor'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { TLUiDialog, useDialogs } from '../hooks/useDialogsProvider' import { TLUiDialog, useDialogs } from '../hooks/useDialogsProvider'
const Dialog = ({ id, component: ModalContent, onClose }: TLUiDialog) => { const Dialog = ({
id,
component: ModalContent,
onClose,
isCustomDialog,
dialogProps,
}: TLUiDialog) => {
const { removeDialog } = useDialogs() const { removeDialog } = useDialogs()
const container = useContainer() const container = useContainer()
@ -24,6 +30,10 @@ const Dialog = ({ id, component: ModalContent, onClose }: TLUiDialog) => {
[id, onClose, removeDialog] [id, onClose, removeDialog]
) )
if (isCustomDialog) {
return <ModalContent onClose={() => handleOpenChange(false)} {...dialogProps} />
}
return ( return (
<_Dialog.Root onOpenChange={handleOpenChange} defaultOpen> <_Dialog.Root onOpenChange={handleOpenChange} defaultOpen>
<_Dialog.Portal container={container}> <_Dialog.Portal container={container}>

View file

@ -12,6 +12,8 @@ export interface TLUiDialog {
id: string id: string
onClose?: () => void onClose?: () => void
component: (props: TLUiDialogProps) => any component: (props: TLUiDialogProps) => any
isCustomDialog?: boolean
dialogProps?: any
} }
/** @public */ /** @public */

View file

@ -2608,6 +2608,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@emoji-mart/data@npm:^1.1.2":
version: 1.1.2
resolution: "@emoji-mart/data@npm:1.1.2"
checksum: c285aa159b00b728d37bc2c6a30ad007fd0d468982c80b16bc8ef6a982c615e9811d297a11ff485ee981bd9a41a63988ad34e8d4a65fb807c4a1bf74f3e92443
languageName: node
linkType: hard
"@emotion/hash@npm:^0.9.0": "@emotion/hash@npm:^0.9.0":
version: 0.9.1 version: 0.9.1
resolution: "@emotion/hash@npm:0.9.1" resolution: "@emotion/hash@npm:0.9.1"
@ -7477,6 +7484,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@tldraw/tldraw@workspace:packages/tldraw" resolution: "@tldraw/tldraw@workspace:packages/tldraw"
dependencies: dependencies:
"@emoji-mart/data": "npm:^1.1.2"
"@peculiar/webcrypto": "npm:^1.4.0" "@peculiar/webcrypto": "npm:^1.4.0"
"@radix-ui/react-alert-dialog": "npm:^1.0.5" "@radix-ui/react-alert-dialog": "npm:^1.0.5"
"@radix-ui/react-context-menu": "npm:^2.1.5" "@radix-ui/react-context-menu": "npm:^2.1.5"
@ -7495,6 +7503,7 @@ __metadata:
canvas-size: "npm:^1.2.6" canvas-size: "npm:^1.2.6"
chokidar-cli: "npm:^3.0.0" chokidar-cli: "npm:^3.0.0"
classnames: "npm:^2.3.2" classnames: "npm:^2.3.2"
emoji-mart: "patch:emoji-mart@npm%3A5.5.2#~/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch"
hotkeys-js: "npm:^3.11.2" hotkeys-js: "npm:^3.11.2"
jest-canvas-mock: "npm:^2.5.2" jest-canvas-mock: "npm:^2.5.2"
jest-environment-jsdom: "npm:^29.4.3" jest-environment-jsdom: "npm:^29.4.3"
@ -12078,6 +12087,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"emoji-mart@npm:5.5.2":
version: 5.5.2
resolution: "emoji-mart@npm:5.5.2"
checksum: 3b891f7940b25fbe83eb784b655bca9fa1b552ddf1c76bf631d51c4b2adef7072834f58b08ed63dc3fccd635d63de7a38f40cd0320fa7c24d97b9ced60dec957
languageName: node
linkType: hard
"emoji-mart@patch:emoji-mart@npm%3A5.5.2#~/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch":
version: 5.5.2
resolution: "emoji-mart@patch:emoji-mart@npm%3A5.5.2#~/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch::version=5.5.2&hash=61a502"
checksum: 219b8f4b146f439564d40b0b25bb2090c307e7225890e585aea9d949be405d09526090d80d096fd1eebb778d43f4b354bf3c511ed6919d92780bed8735348d40
languageName: node
linkType: hard
"emoji-regex@npm:^10.3.0": "emoji-regex@npm:^10.3.0":
version: 10.3.0 version: 10.3.0
resolution: "emoji-regex@npm:10.3.0" resolution: "emoji-regex@npm:10.3.0"