diff --git a/apps/playground/src/editor.tsx b/apps/playground/src/editor.tsx index 116006247..926c92878 100644 --- a/apps/playground/src/editor.tsx +++ b/apps/playground/src/editor.tsx @@ -16,7 +16,6 @@ import { } from '@portabletext/editor' import { createCodeEditorBehaviors, - createEmojiPickerBehaviors, createLinkBehaviors, createMarkdownBehaviors, } from '@portabletext/editor/behaviors' @@ -35,8 +34,7 @@ import {Toolbar} from './components/toolbar' import {Tooltip} from './components/tooltip' import {EditorPatchesPreview} from './editor-patches-preview' import './editor.css' -import {EmojiListBox} from './emoji-picker' -import {matchEmojis, type EmojiMatch} from './emoji-search' +import {EmojiPickerPlugin} from './emoji-picker' import type {EditorActorRef} from './playground-machine' import {PortableTextToolbar} from './portable-text-toolbar' import { @@ -307,39 +305,6 @@ function MarkdownPlugin() { return null } -function EmojiPickerPlugin() { - const editor = useEditor() - const [emojiMatches, setEmojiMatches] = useState>([]) - const [selectedEmojiIndex, setSelectedEmojiIndex] = useState(0) - - useEffect(() => { - const behaviors = createEmojiPickerBehaviors({ - matchEmojis: ({keyword}) => matchEmojis(keyword), - onMatchesChanged: ({matches}) => { - setEmojiMatches(matches) - }, - onSelectedIndexChanged: ({selectedIndex}) => { - setSelectedEmojiIndex(selectedIndex) - }, - parseMatch: ({match}) => match.emoji, - }) - - const unregisterBehaviors = behaviors.map((behavior) => - editor.registerBehavior({behavior}), - ) - - return () => { - for (const unregisterBehavior of unregisterBehaviors) { - unregisterBehavior() - } - } - }, [editor]) - - return ( - - ) -} - function CodeEditorPlugin() { const editor = useEditor() diff --git a/apps/playground/src/emoji-picker.tsx b/apps/playground/src/emoji-picker.tsx index b5df86dcd..4849273ab 100644 --- a/apps/playground/src/emoji-picker.tsx +++ b/apps/playground/src/emoji-picker.tsx @@ -1,28 +1,846 @@ +import { + useEditor, + type BlockOffset, + type Editor, + type EditorSelectionPoint, + type EditorSnapshot, +} from '@portabletext/editor' +import {defineBehavior, raise} from '@portabletext/editor/behaviors' +import * as selectors from '@portabletext/editor/selectors' +import * as utils from '@portabletext/editor/utils' +import {useActorRef, useSelector} from '@xstate/react' import {useEffect, useRef} from 'react' -import type {EmojiMatch} from './emoji-search' +import { + assertEvent, + assign, + fromCallback, + not, + or, + sendTo, + setup, + type AnyEventObject, + type CallbackLogicFunction, +} from 'xstate' +import {Button} from './components/button' +import {matchEmojis, type EmojiMatch} from './emoji-search' + +type EmojiPickerContext = { + editor: Editor + matches: Array + selectedIndex: number + keywordAnchor?: { + point: EditorSelectionPoint + blockOffset: BlockOffset + } + keywordFocus?: BlockOffset + incompleteKeywordRegex: RegExp + keyword: string +} + +type EmojiPickerEvent = + | { + type: 'colon inserted' + point: EditorSelectionPoint + blockOffset: BlockOffset + } + | { + type: 'selection changed' + snapshot: EditorSnapshot + } + | { + type: 'insert.text' + focus: EditorSelectionPoint + text: string + } + | { + type: 'delete.backward' + focus: EditorSelectionPoint + } + | { + type: 'delete.forward' + focus: EditorSelectionPoint + } + | { + type: 'dismiss' + } + | { + type: 'navigate down' + } + | { + type: 'navigate up' + } + | { + type: 'navigate to' + index: number + } + | { + type: 'insert selected match' + } + +const colonListenerCallback: CallbackLogicFunction< + AnyEventObject, + EmojiPickerEvent, + {editor: Editor} +> = ({sendBack, input}) => { + return input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'insert.text', + guard: ({context, event}) => { + if (event.text !== ':' || !context.selection) { + return false + } + + const blockOffset = utils.spanSelectionPointToBlockOffset({ + value: context.value, + selectionPoint: context.selection.focus, + }) + + return blockOffset + ? { + point: context.selection.focus, + blockOffset, + } + : false + }, + actions: [ + (_, {point, blockOffset}) => [ + { + type: 'insert.text', + text: ':', + }, + { + type: 'effect', + effect: () => { + sendBack({type: 'colon inserted', point, blockOffset}) + }, + }, + ], + ], + }), + }) +} + +const escapeListenerCallback: CallbackLogicFunction< + AnyEventObject, + EmojiPickerEvent, + {editor: Editor} +> = ({sendBack, input}) => { + return input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'key.down', + guard: ({event}) => event.keyboardEvent.key === 'Escape', + actions: [ + () => [ + { + type: 'noop', + }, + { + type: 'effect', + effect: () => { + sendBack({type: 'dismiss'}) + }, + }, + ], + ], + }), + }) +} + +const arrowListenerCallback: CallbackLogicFunction< + AnyEventObject, + EmojiPickerEvent, + {editor: Editor} +> = ({sendBack, input}) => { + const unregisterBehaviors = [ + input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'key.down', + guard: ({event}) => event.keyboardEvent.key === 'ArrowDown', + actions: [ + () => [ + { + type: 'noop', + }, + { + type: 'effect', + effect: () => { + sendBack({type: 'navigate down'}) + }, + }, + ], + ], + }), + }), + input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'key.down', + guard: ({event}) => event.keyboardEvent.key === 'ArrowUp', + actions: [ + () => [ + { + type: 'noop', + }, + { + type: 'effect', + effect: () => { + sendBack({type: 'navigate up'}) + }, + }, + ], + ], + }), + }), + ] + + return () => { + for (const unregister of unregisterBehaviors) { + unregister() + } + } +} + +const emojiInsertListener: CallbackLogicFunction< + {type: 'context changed'; context: EmojiPickerContext}, + EmojiPickerEvent, + {context: EmojiPickerContext} +> = ({sendBack, input, receive}) => { + let context = input.context + + receive((event) => { + context = event.context + }) + + const unregisterBehaviors = [ + input.context.editor.registerBehavior({ + behavior: defineBehavior<{ + emoji: string + anchor: BlockOffset + focus: BlockOffset + }>({ + on: 'custom.insert emoji', + actions: [ + ({event}) => [ + { + type: 'effect', + effect: () => { + sendBack({type: 'dismiss'}) + }, + }, + { + type: 'delete.text', + anchor: event.anchor, + focus: event.focus, + }, + { + type: 'insert.text', + text: event.emoji, + }, + ], + ], + }), + }), + input.context.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'insert.text', + guard: ({event}) => { + if (event.text !== ':') { + return false + } + + const anchor = context.keywordAnchor?.blockOffset + const focus = context.keywordFocus + const match = context.matches[context.selectedIndex] + + return match && match.type === 'exact' && anchor && focus + ? {anchor, focus, emoji: match.emoji} + : false + }, + actions: [ + (_, {anchor, focus, emoji}) => [ + raise({ + type: 'custom.insert emoji', + emoji, + anchor, + focus, + }), + ], + ], + }), + }), + input.context.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'key.down', + guard: ({event}) => { + if ( + event.keyboardEvent.key !== 'Enter' && + event.keyboardEvent.key !== 'Tab' + ) { + return false + } + + const anchor = context.keywordAnchor?.blockOffset + const focus = context.keywordFocus + const match = context.matches[context.selectedIndex] + + return match && anchor && focus + ? {anchor, focus, emoji: match.emoji} + : false + }, + actions: [ + (_, {anchor, focus, emoji}) => [ + raise({ + type: 'custom.insert emoji', + emoji, + anchor, + focus, + }), + ], + ], + }), + }), + input.context.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'key.down', + guard: ({event}) => + event.keyboardEvent.key === 'Enter' || + event.keyboardEvent.key === 'Tab', + actions: [ + () => [ + { + type: 'noop', + }, + { + type: 'effect', + effect: () => { + sendBack({type: 'dismiss'}) + }, + }, + ], + ], + }), + }), + ] + + return () => { + for (const unregister of unregisterBehaviors) { + unregister() + } + } +} + +const selectionListenerCallback: CallbackLogicFunction< + AnyEventObject, + EmojiPickerEvent, + {editor: Editor} +> = ({sendBack, input}) => { + const subscription = input.editor.on('selection', () => { + const snapshot = input.editor.getSnapshot() + sendBack({type: 'selection changed', snapshot}) + }) + + return subscription.unsubscribe +} + +const textChangeListener: CallbackLogicFunction< + AnyEventObject, + EmojiPickerEvent, + {editor: Editor} +> = ({sendBack, input}) => { + const unregisterBehaviors = [ + input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'insert.text', + guard: ({context}) => + context.selection ? {focus: context.selection.focus} : false, + actions: [ + ({event}, {focus}) => [ + { + type: 'effect', + effect: () => { + sendBack({ + ...event, + focus, + }) + }, + }, + event, + ], + ], + }), + }), + input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'delete.backward', + guard: ({context, event}) => + event.unit === 'character' && context.selection + ? {focus: context.selection.focus} + : false, + actions: [ + ({event}, {focus}) => [ + { + type: 'effect', + effect: () => { + sendBack({ + type: 'delete.backward', + focus, + }) + }, + }, + event, + ], + ], + }), + }), + input.editor.registerBehavior({ + behavior: defineBehavior({ + on: 'delete.forward', + guard: ({context, event}) => + event.unit === 'character' && context.selection + ? {focus: context.selection.focus} + : false, + actions: [ + ({event}, {focus}) => [ + { + type: 'effect', + effect: () => { + sendBack({ + type: 'delete.forward', + focus, + }) + }, + }, + event, + ], + ], + }), + }), + ] + + return () => { + for (const unregister of unregisterBehaviors) { + unregister() + } + } +} + +const emojiPickerMachine = setup({ + types: { + context: {} as EmojiPickerContext, + input: {} as { + editor: Editor + }, + events: {} as EmojiPickerEvent, + }, + actors: { + 'emoji insert listener': fromCallback(emojiInsertListener), + 'arrow listener': fromCallback(arrowListenerCallback), + 'colon listener': fromCallback(colonListenerCallback), + 'escape listener': fromCallback(escapeListenerCallback), + 'selection listener': fromCallback(selectionListenerCallback), + 'text change listener': fromCallback(textChangeListener), + }, + actions: { + 'init keyword': assign({ + keyword: ':', + }), + 'set keyword anchor': assign({ + keywordAnchor: ({event}) => { + assertEvent(event, 'colon inserted') + + return { + point: event.point, + blockOffset: event.blockOffset, + } + }, + }), + 'set keyword focus': assign({ + keywordFocus: ({event}) => { + assertEvent(event, 'colon inserted') + + return { + path: event.blockOffset.path, + offset: event.blockOffset.offset + 1, + } + }, + }), + 'update keyword focus': assign({ + keywordFocus: ({context, event}) => { + assertEvent(event, ['insert.text', 'delete.backward', 'delete.forward']) + + if (!context.keywordFocus) { + return context.keywordFocus + } + + return { + path: context.keywordFocus.path, + offset: + event.type === 'insert.text' + ? context.keywordFocus.offset + event.text.length + : event.type === 'delete.backward' || + event.type === 'delete.forward' + ? context.keywordFocus.offset - 1 + : event.focus.offset, + } + }, + }), + 'update keyword': assign({ + keyword: ({context, event}) => { + assertEvent(event, 'selection changed') + + if (!context.keywordAnchor || !context.keywordFocus) { + return '' + } + + const keywordFocusPoint = utils.blockOffsetToSpanSelectionPoint({ + value: event.snapshot.context.value, + blockOffset: context.keywordFocus, + }) + + if (!keywordFocusPoint) { + return '' + } + + return selectors.getSelectionText({ + context: { + ...event.snapshot.context, + selection: { + anchor: context.keywordAnchor.point, + focus: keywordFocusPoint, + }, + }, + }) + }, + }), + 'update matches': assign({ + matches: ({context}) => { + const rawKeyword = context.keyword.match( + context.incompleteKeywordRegex, + )?.[1] + + if (rawKeyword === undefined) { + return [] + } + + return matchEmojis(rawKeyword) + }, + }), + 'reset selected index': assign({ + selectedIndex: 0, + }), + 'increment selected index': assign({ + selectedIndex: ({context}) => { + if (context.selectedIndex === context.matches.length - 1) { + return 0 + } + return context.selectedIndex + 1 + }, + }), + 'decrement selected index': assign({ + selectedIndex: ({context}) => { + if (context.selectedIndex === 0) { + return context.matches.length - 1 + } + return context.selectedIndex - 1 + }, + }), + 'set selected index': assign({ + selectedIndex: ({event}) => { + assertEvent(event, 'navigate to') + + return event.index + }, + }), + 'update emoji insert listener context': sendTo( + 'emoji insert listener', + ({context}) => ({ + type: 'context changed', + context, + }), + ), + 'insert selected match': ({context}) => { + const match = context.matches[context.selectedIndex] + + if (!match || !context.keywordAnchor || !context.keywordFocus) { + return + } + + context.editor.send({ + type: 'custom.insert emoji', + emoji: match.emoji, + anchor: context.keywordAnchor.blockOffset, + focus: context.keywordFocus, + }) + }, + 'reset': assign({ + keywordAnchor: undefined, + keywordFocus: undefined, + keyword: '', + matches: [], + selectedIndex: 0, + }), + }, + guards: { + 'has matches': ({context}) => { + return context.matches.length > 0 + }, + 'no matches': not('has matches'), + 'keyword is wel-formed': ({context}) => { + return context.incompleteKeywordRegex.test(context.keyword) + }, + 'keyword is malformed': not('keyword is wel-formed'), + 'selection is before keyword': ({context, event}) => { + assertEvent(event, 'selection changed') + + if (!context.keywordAnchor) { + return true + } + + return selectors.isPointAfterSelection(context.keywordAnchor.point)( + event.snapshot, + ) + }, + 'selection is after keyword': ({context, event}) => { + assertEvent(event, 'selection changed') + + if (context.keywordFocus === undefined) { + return true + } + + const keywordFocusPoint = utils.blockOffsetToSpanSelectionPoint({ + value: event.snapshot.context.value, + blockOffset: context.keywordFocus, + }) + + if (!keywordFocusPoint) { + return true + } + + return selectors.isPointBeforeSelection(keywordFocusPoint)(event.snapshot) + }, + 'selection is expanded': ({event}) => { + assertEvent(event, 'selection changed') + + return selectors.isSelectionExpanded(event.snapshot) + }, + 'selection moved unexpectedly': or([ + 'selection is before keyword', + 'selection is after keyword', + 'selection is expanded', + ]), + }, +}).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5RgLYHsBWBLABABywGMBrMAJwDosIAbMAYkLRrQDsctXZyAXSAbQAMAXUSg8aWFh5Y2YkAA9EARmUB2AJwUAzADYNADgCs65YO1q1ygDQgAnojUG1O5c+W7tAJmdfdRgF8A21RMXAIScgpuAEMyQgALTih6Tm4yHgo+BR4hUSQQCSkZOQKlBABaZW0DHSMAFksNQUF6oyNzL1sHBC9vCnrGtS8GtQaNNt0gkPRsfCJSSlj4pNYUiDA6PgoAMzQyAHc4iDz5IulZVnlyr3MKE30LA31DZoNuxD6vAaHdP29vOo1NNwLNwgsostEsl6BstmAKAAjGIkI5kE4iM6SC6lUDlerabT3arDfS3eoaPQfXr9QaWEaNcaTEGhOYRRbRMBxaFrWFYWAofmwU4Fc4lK5lFTqWqDAwUjReeoGbwaNTU1TPCh-QxNNS6XQGHwssHzSJLLkrGHcOiEcU4RIxNYCTGi7Hi66IfxE1oeZX1EbeZXUhUUZSKjxOOV6bTmY1hU0cqGrFLWsC2y72hKOmAnZT5cRuy4ehDVXQUVXDIyWGpmAzveyfLRKwQaVRVwR1ixeONsiHm7nJ+gigvFIuSkvKVuhgwaEwKmMKwbqkaCAaverqVuvKbBUHx9mQi08qAUVhoHAoGI8RJwHCwBJoA4w4eFQu4xSfaoDA3KOmCVSTn06r-i4-hGH0ZiEoI4H1D24JmpyA7JNED5PmsF5XjesD0KwMQAG5YFAV5gDgECPqwL5imOeKIIM9ShsoRh1i2erPB41L6C40GqISUb6n8cEJoeSFrChj7JBh14JHAOH4YRxE4AArnglFvhKNEIGoq4+E0yraPULa6PUHGqhQ3HVDUBL8dogkHv2lqife4noZeUkybhBFEXwOA8Ggqmju+5RGPqFBqP6ujDD4v4tjYDYIMqLjVKo5gdM4TGBLurLwYmR7JmJaFQJJWGpFwvB3psaZ8BARUJP5OLqR+CD1Loq5ymYfyeP4spGOqv70fpv7NBSBKtBlMz7n2iEOSeTkFTVMl1e645eB49wGP+K0UpBbjaMBzQUF4IzBYq7TNa2QS7meGzwAUWVCWQWIBQ15RVJq2ijJoLRtB03jUhUVYUHKtz-gqeoxkYGi2ZN1B0I99XFqobTlh4-5-Ot4H1j0ZirbcENhR4RmKrBmUmnZU3HnDS0aVUjHlh2cqtkqfTBdSSr0RMGieBS7htASUMIUmyFnvNsB3qhySU9RjUVBFdN1ltTPvbowZOGZEWHe9xgTHo-M5SJM3iy5mHSTdI7w+OlnlgY6heJzbgUv4ytxaqtSCBFg1eJFM4XQEQA */ + id: 'emoji picker', + context: ({input}) => ({ + editor: input.editor, + keyword: '', + incompleteKeywordRegex: /:([a-zA-Z-_0-9:]*)$/, + matches: [], + selectedIndex: 0, + }), + initial: 'idle', + states: { + idle: { + entry: ['reset'], + invoke: { + src: 'colon listener', + input: ({context}) => ({editor: context.editor}), + }, + on: { + 'colon inserted': { + target: 'searching', + actions: ['set keyword anchor', 'set keyword focus', 'init keyword'], + }, + }, + }, + searching: { + invoke: [ + { + src: 'emoji insert listener', + id: 'emoji insert listener', + input: ({context}) => ({context}), + }, + { + src: 'escape listener', + input: ({context}) => ({editor: context.editor}), + }, + { + src: 'selection listener', + input: ({context}) => ({editor: context.editor}), + }, + { + src: 'text change listener', + input: ({context}) => ({editor: context.editor}), + }, + ], + on: { + 'insert.text': { + actions: ['update keyword focus'], + }, + 'delete.forward': { + actions: ['update keyword focus'], + }, + 'delete.backward': { + actions: ['update keyword focus'], + }, + 'dismiss': { + target: 'idle', + }, + 'selection changed': [ + { + guard: 'selection moved unexpectedly', + target: 'idle', + }, + { + actions: [ + 'update keyword', + 'update matches', + 'reset selected index', + 'update emoji insert listener context', + ], + }, + ], + }, + always: [ + { + guard: 'keyword is malformed', + target: 'idle', + }, + ], + initial: 'no matches showing', + states: { + 'no matches showing': { + entry: ['reset selected index'], + always: { + guard: 'has matches', + target: 'showing matches', + }, + }, + 'showing matches': { + invoke: { + src: 'arrow listener', + input: ({context}) => ({editor: context.editor}), + }, + always: [ + { + guard: 'no matches', + target: 'no matches showing', + }, + ], + on: { + 'navigate down': { + actions: [ + 'increment selected index', + 'update emoji insert listener context', + ], + }, + 'navigate up': { + actions: [ + 'decrement selected index', + 'update emoji insert listener context', + ], + }, + 'navigate to': { + actions: [ + 'set selected index', + 'update emoji insert listener context', + ], + }, + 'insert selected match': { + actions: ['insert selected match'], + }, + }, + }, + }, + }, + }, +}) + +export function EmojiPickerPlugin() { + const editor = useEditor() + const emojiPickerActor = useActorRef(emojiPickerMachine, {input: {editor}}) + const keyword = useSelector( + emojiPickerActor, + (snapshot) => snapshot.context.keyword, + ) + const matches = useSelector( + emojiPickerActor, + (snapshot) => snapshot.context.matches, + ) + const selectedIndex = useSelector( + emojiPickerActor, + (snapshot) => snapshot.context.selectedIndex, + ) + + return ( + { + emojiPickerActor.send({type: 'dismiss'}) + }} + onNavigateTo={(index) => { + emojiPickerActor.send({type: 'navigate to', index}) + }} + onSelect={() => { + emojiPickerActor.send({type: 'insert selected match'}) + editor.send({type: 'focus'}) + }} + /> + ) +} export function EmojiListBox(props: { + keyword: string matches: Array selectedIndex: number + onDismiss: () => void + onNavigateTo: (index: number) => void + onSelect: () => void }) { + if (props.keyword.length < 2) { + return null + } + return ( -
    - {props.matches.map((match, index) => ( - - ))} -
+
+ {props.matches.length === 0 ? ( +
+ No results found{' '} + +
+ ) : ( +
    + {props.matches.map((match, index) => ( + { + props.onNavigateTo(index) + }} + onSelect={props.onSelect} + /> + ))} +
+ )} +
) } -function EmojiListItem(props: {match: EmojiMatch; selected: boolean}) { +function EmojiListItem(props: { + match: EmojiMatch + selected: boolean + onMouseEnter: () => void + onSelect: () => void +}) { const ref = useRef(null) useEffect(() => { @@ -38,6 +856,8 @@ function EmojiListItem(props: {match: EmojiMatch; selected: boolean}) { style={{ background: props.selected ? 'lightblue' : 'unset', }} + onMouseEnter={props.onMouseEnter} + onClick={props.onSelect} > {props.match.emoji} : {props.match.keyword}: @@ -51,6 +871,8 @@ function EmojiListItem(props: {match: EmojiMatch; selected: boolean}) { style={{ background: props.selected ? 'lightblue' : 'unset', }} + onMouseEnter={props.onMouseEnter} + onClick={props.onSelect} > {props.match.emoji} : {props.match.startSlice} diff --git a/apps/playground/src/emoji-search.ts b/apps/playground/src/emoji-search.ts index 7eb6a9b77..d181d4026 100644 --- a/apps/playground/src/emoji-search.ts +++ b/apps/playground/src/emoji-search.ts @@ -15,7 +15,7 @@ export type EmojiMatch = export function matchEmojis(keyword: string): Array { const foundEmojis: Array = [] - if (keyword.length < 2) { + if (keyword.length < 1) { return foundEmojis } @@ -15206,7 +15206,6 @@ const emojis: Record> = { '🧑‍🦯‍➡️': [ 'person with white cane facing right', 'walk', - 'walk', 'visually impaired', 'blind', ],