diff --git a/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch b/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch new file mode 100644 index 000000000..21e698b46 Binary files /dev/null and b/.yarn/patches/emoji-mart-npm-5.5.2-10ed58131e.patch differ diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index d51de7bf5..0cc1bf1c8 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -1140,6 +1140,8 @@ export class Group2d extends Geometry2d { // (undocumented) getArea(): number; // (undocumented) + getLabel(): Geometry2d; + // (undocumented) getVertices(): Vec[]; // (undocumented) hitTestLineSegment(A: Vec, B: Vec, zoom: number): boolean; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 3f93b8fe8..c02a72e28 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -22688,6 +22688,38 @@ "isAbstract": false, "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", "canonicalReference": "@tldraw/editor!Group2d#getVertices:member(1)", diff --git a/packages/editor/src/lib/primitives/geometry/Group2d.ts b/packages/editor/src/lib/primitives/geometry/Group2d.ts index 3caeabfc8..e5807c23f 100644 --- a/packages/editor/src/lib/primitives/geometry/Group2d.ts +++ b/packages/editor/src/lib/primitives/geometry/Group2d.ts @@ -70,6 +70,10 @@ export class Group2d extends Geometry2d { return this.children[0].area } + getLabel() { + return this.children.filter((c) => c.isLabel)[0] + } + toSimpleSvgPath() { let path = '' for (const child of this.children) { diff --git a/packages/editor/src/lib/utils/debug-flags.ts b/packages/editor/src/lib/utils/debug-flags.ts index 589f5ee23..5a8873fe0 100644 --- a/packages/editor/src/lib/utils/debug-flags.ts +++ b/packages/editor/src/lib/utils/debug-flags.ts @@ -8,7 +8,7 @@ import { Atom, atom, react } from '@tldraw/state' // `true` by default in development and staging, and `false` in production. /** @internal */ export const featureFlags: Record> = { - // canMoveArrowLabel: createFeatureFlag('canMoveArrowLabel'), + emojiMenu: createFeatureFlag('emojiMenu'), } /** @internal */ @@ -111,16 +111,16 @@ function createDebugValue( }) } -// function createFeatureFlag( -// name: string, -// defaults: Defaults = { all: true, production: false } -// ) { -// return createDebugValueBase({ -// name, -// defaults, -// shouldStoreForSession: true, -// }) -// } +function createFeatureFlag( + name: string, + defaults: Defaults = { all: true, production: false } +) { + return createDebugValueBase({ + name, + defaults, + shouldStoreForSession: true, + }) +} function createDebugValueBase(def: DebugFlagDef): DebugFlag { const defaultValue = getDefaultValue(def) diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 1f6049415..d375e79be 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -1420,8 +1420,12 @@ export interface TLUiDialog { // (undocumented) component: (props: TLUiDialogProps) => any; // (undocumented) + dialogProps?: any; + // (undocumented) id: string; // (undocumented) + isCustomDialog?: boolean; + // (undocumented) onClose?: () => void; } diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index 94620ac9f..cbda8f010 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -16213,6 +16213,33 @@ "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", "canonicalReference": "@tldraw/tldraw!TLUiDialog#id:member", @@ -16240,6 +16267,33 @@ "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", "canonicalReference": "@tldraw/tldraw!TLUiDialog#onClose:member", diff --git a/packages/tldraw/package.json b/packages/tldraw/package.json index 3b1cb92be..c608444c7 100644 --- a/packages/tldraw/package.json +++ b/packages/tldraw/package.json @@ -45,6 +45,7 @@ "tldraw.css" ], "dependencies": { + "@emoji-mart/data": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dialog": "^1.0.5", @@ -56,6 +57,7 @@ "@tldraw/editor": "workspace:*", "canvas-size": "^1.2.6", "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", "lz-string": "^1.4.4" }, diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx index 3d02e939b..017c81adb 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx @@ -32,6 +32,7 @@ import { TextLabel } from '../shared/TextLabel' import { FONT_FAMILIES, LABEL_FONT_SIZES, + LABEL_PADDING, STROKE_SIZES, TEXT_PROPS, } from '../shared/default-shape-constants' @@ -59,7 +60,6 @@ import { } from './components/SolidStyleOval' import { SolidStylePolygon, SolidStylePolygonSvg } from './components/SolidStylePolygon' -const LABEL_PADDING = 16 const MIN_SIZE_WITH_LABEL = 17 * 3 /** @public */ diff --git a/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts b/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts index 6cd0b20e6..559f19cdd 100644 --- a/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts +++ b/packages/tldraw/src/lib/shapes/shared/default-shape-constants.ts @@ -58,4 +58,6 @@ export const LABEL_TO_ARROW_PADDING = 20 /** @internal */ export const ARROW_LABEL_PADDING = 4.25 /** @internal */ +export const LABEL_PADDING = 16 +/** @internal */ export const WAY_TOO_BIG_ARROW_BEND_FACTOR = 10 diff --git a/packages/tldraw/src/lib/shapes/shared/emojis/EmojiDialog.tsx b/packages/tldraw/src/lib/shapes/shared/emojis/EmojiDialog.tsx new file mode 100644 index 000000000..273d19757 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/shared/emojis/EmojiDialog.tsx @@ -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(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 ( +
+ ) +}) + +export let EmojiDialogSingleton: { component: any } | null = null diff --git a/packages/tldraw/src/lib/shapes/shared/emojis/index.tsx b/packages/tldraw/src/lib/shapes/shared/emojis/index.tsx new file mode 100644 index 000000000..99a1f06bd --- /dev/null +++ b/packages/tldraw/src/lib/shapes/shared/emojis/index.tsx @@ -0,0 +1,11 @@ +import { Suspense, lazy } from 'react' + +const EmojiDialog = lazy(() => import('./EmojiDialog')) + +export default function EmojiDialogLazy(props: any) { + return ( + }> + + + ) +} diff --git a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts index 40a776cdf..2fd431e9e 100644 --- a/packages/tldraw/src/lib/shapes/shared/useEditableText.ts +++ b/packages/tldraw/src/lib/shapes/shared/useEditableText.ts @@ -1,8 +1,15 @@ /* eslint-disable no-inner-declarations */ import { + Editor, + Group2d, + Rectangle2d, + TLArrowShape, + TLGeoShape, TLShape, + TLTextShape, TLUnknownShape, + featureFlags, getPointerInfo, preventDefault, stopEventPropagation, @@ -11,6 +18,16 @@ import { } from '@tldraw/editor' import React, { useCallback, useEffect, useRef } from 'react' 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>( id: T['id'], @@ -18,8 +35,12 @@ export function useEditableText(null) + const { onKeyDown: onEmojiKeyDown } = useEmojis(rInput.current, (text: string) => { + editor.updateShapes([ + { id, type, props: { text } }, + ]) + }) const rSkipSelectOnFocus = useRef(false) const rSelectionRanges = useRef() @@ -101,6 +122,14 @@ export function useEditableText) => { 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) { case 'Enter': { if (e.ctrlKey || e.metaKey) { @@ -119,7 +148,7 @@ export function useEditableText 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, + 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 } +} diff --git a/packages/tldraw/src/lib/ui/components/Dialogs.tsx b/packages/tldraw/src/lib/ui/components/Dialogs.tsx index bebbef94d..de4a8e801 100644 --- a/packages/tldraw/src/lib/ui/components/Dialogs.tsx +++ b/packages/tldraw/src/lib/ui/components/Dialogs.tsx @@ -3,7 +3,13 @@ import { useContainer } from '@tldraw/editor' import React, { useCallback } from 'react' 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 container = useContainer() @@ -24,6 +30,10 @@ const Dialog = ({ id, component: ModalContent, onClose }: TLUiDialog) => { [id, onClose, removeDialog] ) + if (isCustomDialog) { + return handleOpenChange(false)} {...dialogProps} /> + } + return ( <_Dialog.Root onOpenChange={handleOpenChange} defaultOpen> <_Dialog.Portal container={container}> diff --git a/packages/tldraw/src/lib/ui/hooks/useDialogsProvider.tsx b/packages/tldraw/src/lib/ui/hooks/useDialogsProvider.tsx index a7323865c..1ccb708cc 100644 --- a/packages/tldraw/src/lib/ui/hooks/useDialogsProvider.tsx +++ b/packages/tldraw/src/lib/ui/hooks/useDialogsProvider.tsx @@ -12,6 +12,8 @@ export interface TLUiDialog { id: string onClose?: () => void component: (props: TLUiDialogProps) => any + isCustomDialog?: boolean + dialogProps?: any } /** @public */ diff --git a/yarn.lock b/yarn.lock index 1717c326a..2406bc417 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2608,6 +2608,13 @@ __metadata: languageName: node 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": version: 0.9.1 resolution: "@emotion/hash@npm:0.9.1" @@ -7477,6 +7484,7 @@ __metadata: version: 0.0.0-use.local resolution: "@tldraw/tldraw@workspace:packages/tldraw" dependencies: + "@emoji-mart/data": "npm:^1.1.2" "@peculiar/webcrypto": "npm:^1.4.0" "@radix-ui/react-alert-dialog": "npm:^1.0.5" "@radix-ui/react-context-menu": "npm:^2.1.5" @@ -7495,6 +7503,7 @@ __metadata: canvas-size: "npm:^1.2.6" chokidar-cli: "npm:^3.0.0" 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" jest-canvas-mock: "npm:^2.5.2" jest-environment-jsdom: "npm:^29.4.3" @@ -12078,6 +12087,20 @@ __metadata: languageName: node 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": version: 10.3.0 resolution: "emoji-regex@npm:10.3.0"