diff --git a/.changeset/combobox.md b/.changeset/combobox.md new file mode 100644 index 0000000000..7bbf7302a1 --- /dev/null +++ b/.changeset/combobox.md @@ -0,0 +1,25 @@ +--- +"@udecode/plate-combobox": major +--- + +- Major rework. The combobox package is no longer a plugin. Instead, it is now a collection of utilities that can be used by other plugins and components. +- Added the following exports: + - `withTriggerCombobox`: Insert a combobox input when a trigger character is typed + - `TriggerComboboxPlugin`: Plugin options type for `withTriggerCombobox` + - `useComboboxInput`: Manages the behavior of an inline combobox input element + - `useHTMLInputCursorState`: Tracks whether the cursor is at the start or end of a HTML `` element + - `ComboboxInputCursorState`: Return type for `useHTMLInputCursorState` + - `CancelComboboxInputCause`: A unison type of possible reasons why a combobox input may be cancelled (used by `useComboboxInput`) +- Removed the following exports: + - `comboboxStore` + - `createComboboxPlugin` + - `useComboboxContent` + - `useComboboxControls` + - `useComboboxItem` + - `onChangeCombobox` + - `onKeyDownCombobox` + - `ComboboxOnSelectItem` + - `ComboboxProps` + - `getNextNonDisabledIndex` + - `getNextWrappingIndex` + - `getTextFromTrigger` diff --git a/.changeset/emoji.md b/.changeset/emoji.md new file mode 100644 index 0000000000..e03f662bc2 --- /dev/null +++ b/.changeset/emoji.md @@ -0,0 +1,12 @@ +--- +"@udecode/plate-emoji": major +--- + +- Now uses the reworked combobox package +- Added `ELEMENT_EMOJI_INPUT`; combobox functionality must now be handled in the component +- Plugin options: + - Now extends from `TriggerComboboxPlugin` + - Added `createEmojiNode` to support custom emoji nodes + - Removed `emojiTriggeringController` + - Removed `id` (no longer needed) + - Removed `createEmoji` diff --git a/.changeset/mention.md b/.changeset/mention.md new file mode 100644 index 0000000000..e94a788aea --- /dev/null +++ b/.changeset/mention.md @@ -0,0 +1,18 @@ +--- +"@udecode/plate-mention": major +--- + +- Now uses the reworked combobox package +- `ELEMENT_MENTION_INPUT` is now an inline void element, and combobox functionality must now be handled in the component +- Plugin options: + - Now extends from `TriggerComboboxPlugin` + - Renamed `query` to `triggerQuery` (provided by `TriggerComboboxPlugin`) + - Removed `id` (no longer needed) + - Removed `inputCreation` (see `TriggerComboboxPlugin['createComboboxInput']`) +- Removed queries and transforms relating to the mention input: + - `findMentionInput` + - `isNodeMentionInput` + - `isSelectionInMentionInput` + - `removeMentionInput` +- Removed `withMention` (no longer needed) +- Removed `mentionOnKeyDownHandler` (no longer needed) diff --git a/.changeset/slash.md b/.changeset/slash.md new file mode 100644 index 0000000000..0779c109a6 --- /dev/null +++ b/.changeset/slash.md @@ -0,0 +1,20 @@ +--- +"@udecode/plate-slash-command": major +--- + +- Now uses the reworked combobox package +- `ELEMENT_SLASH_INPUT` is now an inline void element, and combobox functionality must now be handled in the component +- Replaced all plugin options with those extended from `TriggerComboboxPlugin` + - Removed `createSlashNode` + - Removed `id` (no longer needed) + - Removed `inputCreation` (see `createComboboxInput`) + - Renamed `query` to `triggerQuery` (provided by `TriggerComboboxPlugin`) + - Removed `rules`: Slash command rules must now be provided in the component +- Removed queries and transforms relating to the slash input: + - `findSlashInput` + - `isNodeSlashInput` + - `isSelectionInSlashInput` + - `removeSlashInput` +- Removed `withSlashCommand` (no longer needed) +- Removed `slashOnKeyDownHandler` (no longer needed) +- Removed `getSlashOnSelectItem`: This should now be handled in the component diff --git a/apps/www/content/docs/combobox.mdx b/apps/www/content/docs/combobox.mdx index 032f83049c..160542e670 100644 --- a/apps/www/content/docs/combobox.mdx +++ b/apps/www/content/docs/combobox.mdx @@ -1,249 +1,94 @@ --- title: Combobox -description: Select options from a list of predefined values. -docs: - - route: /docs/components/combobox - title: Combobox - - route: /docs/components/emoji-combobox - title: Emoji Combobox - - route: /docs/components/mention-combobox - title: Mention Combobox +description: Utilities for adding comboboxes to your editor. --- - +## TriggerComboboxPlugin -## Features +The TriggerComboboxPlugin mixin configures your plugin to insert a combobox input element when the user types a specified trigger character is typed. -- Displays a combobox for selecting options from a set list. -- Suitable for creating mentions, tags, or slash commands. -- Works in conjunction with the [Mention plugin](/docs/mention). +For example, the [Mention](/docs/mention) plugin uses TriggerComboboxPlugin to insert an `ELEMENT_MENTION_INPUT` whenever the user types `@`. -**Activation Conditions:** +### Usage -- Collapsed text selection. -- Cursor placement immediately after the trigger character. -- Text without spaces follows the trigger character. + -**On Activation:** - -- Sets `id`, `text`, and `targetRange` in the combobox store. - - - -## Installation - -```bash -npm install @udecode/plate-combobox -``` - -## Usage - -```tsx -import { createComboboxPlugin } from '@udecode/plate-combobox'; -import { createMentionPlugin } from '@udecode/plate-mention'; - -const plugins = [ - // ...otherPlugins, - createComboboxPlugin(), - createMentionPlugin(), -]; -``` - -Then render the combobox component inside `Plate`. You can use the [Combobox component](/docs/components/combobox) or create your own. - -## Keyboard Interactions - - - - Highlights the next item in the list. - - - Highlights the previous item in the list. - - Closes the combobox. - - Selects the currently highlighted item. - - - Selects the currently highlighted item. - - - -## API - -### createComboboxPlugin - -### ComboboxProps - -Here are some key aspects of the **`Combobox`**: - -- Multiple Instances: You can render the **`Combobox`** multiple times, each with its unique configuration provided by a different **`ComboboxStateById`**. -- Singleton Behavior: Only one **`Combobox`** can be opened at a time. The state of the active **`Combobox`** is stored in the **`comboboxStore`**. -- Extends **`ComboboxState`**, **`ComboboxStateById`**: - - - -The items for the combobox. An alternative to setting the items is to use -`comboboxActions.items(items)`. - - -A component that is rendered when the combobox is open. Useful for injecting -hooks. - - -A function to render the combobox item. - -- **Default:** item text - - - - -The combobox item. - - - - -The search text. - - - - - - - -The element to which the combobox is rendered. - -- **Default:** `document.body` - - - - - -### ComboboxState - -Represents a combobox's state. The state resides in `comboboxStore`, which uses the [zustood store](https://github.com/udecode/zustood). - - - -Opened combobox ID. - - -A collection of combobox configuration stores, each identified by a unique combobox ID (e.g., one for tags, one for mentions). - -- `ComboboxStateById`: - - - - -Combobox ID. - - -An optional function to filter items by text. - -- **Default:** A function that checks if the item's text begins with the search text. It compares lowercase strings. + +Extend your plugin options type with TriggerComboboxPlugin. ```ts -(search: string) => (item: TComboboxItem) => boolean; +interface MyPlugin extends TriggerComboboxPlugin {} +const createMyPlugin = createPluginFactory({ + // ... +}); + +// Or simply: +const createMyPlugin = createPluginFactory({ + // ... +}); ``` + - - - -An optional function that sorts filtered items before applying `maxSuggestions`. + +Add the withTriggerCombobox override and specify default values for the required options. (See below for the full list of options). ```ts -(search: string) => (a: TComboboxItem, b: TComboboxItem) => - number; +const createMyPlugin = createPluginFactory({ + // ... + withOverrides: withTriggerCombobox, + options: { + createComboboxInput: (trigger) => ({ + children: [{ text: '' }], + trigger, + type: ELEMENT_MY_INPUT, + }), + trigger: '@', + triggerPreviousCharPattern: /^\s?$/, + }, +}); ``` + - - -The maximum number of suggestions to be shown. - -- **Default:** The length of the **`items`** array. - - - - -The trigger character to activate the combobox. - - -An optional regular expression for searching, for example, to allow whitespaces. - - -An optional callback function invoked when an item is selected. + +Define your input element as an inline void element. It's often useful to do this inside a nested plugin. ```ts -(editor: PlateEditor, item: TComboboxItem) => any; +const createMyPlugin = createPluginFactory({ + // ... + plugins: [ + { + isElement: true, + isInline: true, + isVoid: true, + key: ELEMENT_MY_INPUT, + }, + ], +}); ``` - - -Indicates if the opening/closing of the combobox is controlled by the client. - - - - - -The list of unfiltered items. - - -The list of filtered items. - - -The index of the currently highlighted item. - - -The range from the trigger to the cursor. - - -The text that appears after the trigger. - +The input element component can be built using [Inline Combobox](/docs/components/inline-combobox). + - + -### TComboboxItem +### Options -The data structure representing a single item in a combobox. + - - -A unique key for the item. - - -The text of the item. + + A function to create the input node. - -Indicates whether the item is disabled. -- **Default:** `false` + + The character that triggers the combobox. + + + Only trigger the combobox if the char before the trigger character matches a regular expression. For example, `/^\s?$/` matches beginning of the line or a space. - -Data available to `onRenderItem`. + + + A query function to enable the behavior. - - -## API Components - -### useComboboxContent - -A behavior hook for the `ComboboxContent` component. - - - - The items for the combobox. - - - The combobox store. - - - - - - The menu props for the combobox content. - - - The target range of the combobox. - - + + diff --git a/apps/www/content/docs/components/emoji-combobox.mdx b/apps/www/content/docs/components/emoji-input-element.mdx similarity index 75% rename from apps/www/content/docs/components/emoji-combobox.mdx rename to apps/www/content/docs/components/emoji-input-element.mdx index a46401ee53..49a74b1d8c 100644 --- a/apps/www/content/docs/components/emoji-combobox.mdx +++ b/apps/www/content/docs/components/emoji-input-element.mdx @@ -1,10 +1,8 @@ --- -title: Emoji Combobox -description: Enter and select emojis using a combination of text input and a dropdown menu. +title: Emoji Input Element +description: Search for an emoji using an inline combobox. component: true docs: - - route: /docs/combobox - title: Combobox - route: /docs/emoji title: Emoji --- @@ -20,7 +18,7 @@ docs: ```bash -npx @udecode/plate-ui@latest add emoji-combobox +npx @udecode/plate-ui@latest add emoji-input-element ``` @@ -33,6 +31,7 @@ npx @udecode/plate-ui@latest add emoji-combobox Install the following dependencies: +- [Emoji](/docs/emoji) - [Combobox](/docs/combobox) @@ -43,7 +42,7 @@ Copy and paste the following code into your project. - + diff --git a/apps/www/content/docs/components/combobox.mdx b/apps/www/content/docs/components/inline-combobox.mdx similarity index 58% rename from apps/www/content/docs/components/combobox.mdx rename to apps/www/content/docs/components/inline-combobox.mdx index b9033d28f6..10ecc0a78e 100644 --- a/apps/www/content/docs/components/combobox.mdx +++ b/apps/www/content/docs/components/inline-combobox.mdx @@ -1,6 +1,6 @@ --- -title: Combobox -description: Combine a text input field with a dropdown menu for enhanced user interaction. +title: Inline Combobox +description: Enhance inline nodes with accessible comboboxes. component: true docs: - route: /docs/combobox @@ -18,7 +18,7 @@ docs: ```bash -npx @udecode/plate-ui@latest add combobox +npx @udecode/plate-ui@latest add inline-combobox ``` @@ -31,12 +31,18 @@ npx @udecode/plate-ui@latest add combobox Install the following dependencies: +- [Combobox](/docs/combobox) + + + + + +Install the Combobox component from Ariakit: + ```bash -npm install @radix-ui/react-popover @udecode/plate-floating +npm install @ariakit/react ``` -- [Combobox](/docs/combobox) - @@ -45,7 +51,7 @@ Copy and paste the following code into your project. - + @@ -59,9 +65,8 @@ Update the import paths to match your project setup. -## Examples +## Usage - +See [Mention Input](/docs/components/mention-input-element) for an example of how to use Inline Combobox. - - + diff --git a/apps/www/content/docs/components/mention-combobox.mdx b/apps/www/content/docs/components/mention-combobox.mdx deleted file mode 100644 index 1687d201d4..0000000000 --- a/apps/www/content/docs/components/mention-combobox.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Mention Combobox -description: Enter and select mentions or references using a combination of text input and a dropdown menu. -component: true -docs: - - route: /docs/combobox - title: Combobox - - route: /docs/mention - title: Mention ---- - -## Installation - - - - -CLI -Manual - - - -```bash -npx @udecode/plate-ui@latest add mention-combobox -``` - - - - - - - - - -Install the following dependencies: - -- [Combobox](/docs/combobox) -- [Mention](/docs/mention) - - - - - -Copy and paste the following code into your project. - - - - - - - -Update the import paths to match your project setup. - - - - - - - - - -## Examples - - - - diff --git a/apps/www/content/docs/components/mention-input-element.mdx b/apps/www/content/docs/components/mention-input-element.mdx index f97f05e807..f2ec269df9 100644 --- a/apps/www/content/docs/components/mention-input-element.mdx +++ b/apps/www/content/docs/components/mention-input-element.mdx @@ -31,7 +31,8 @@ npx @udecode/plate-ui@latest add mention-input-element Install the following dependencies: -- [Mention](/docs/combobox) +- [Mention](/docs/mention) +- [Combobox](/docs/combobox) diff --git a/apps/www/content/docs/emoji.mdx b/apps/www/content/docs/emoji.mdx index f6a1f9d82f..b550f857dc 100644 --- a/apps/www/content/docs/emoji.mdx +++ b/apps/www/content/docs/emoji.mdx @@ -2,8 +2,8 @@ title: Emoji description: Insert emoji inline. docs: - - route: /docs/components/emoji-combobox - title: Emoji Combobox + - route: /docs/components/emoji-input-element + title: Emoji Input Element - route: /docs/components/emoji-dropdown-menu title: Emoji Dropdown Menu - route: /docs/components/emoji-toolbar-dropdown @@ -24,7 +24,7 @@ docs: ## Installation ```bash -npm install @udecode/plate-emoji @udecode/plate-combobox +npm install @udecode/plate-emoji ``` ## Usage @@ -35,7 +35,6 @@ import { createEmojiPlugin } from '@udecode/plate-emoji'; const plugins = [ // ...otherPlugins, - createComboboxPlugin(), createEmojiPlugin(), ]; ``` @@ -44,57 +43,10 @@ const plugins = [ ### createEmojiPlugin - - - -The trigger character(s) for the emoji. - - - -A function to create an Emoji. TData is the type of the data used to create the emoji. - - - -A controller object that controls the emoji triggering behavior. - - - -Indicates whether the emoji trigger is active. - - - -Indicates whether the triggering mark is present. - - - -A function that sets the triggering status. - - - -A function that sets the text. - - - -A function that gets the current text. - - - -A function that checks whether the current triggering mark is enclosing. - - - -A function that gets the size of the current text. - - - -A function that resets the triggering controller. - - - - - - -The ID of the plugin. +Extends [TriggerComboboxPlugin](/docs/combobox#options) + + + A function to specify the node inserted when an emoji is selected. The default behavior is to insert a text node containing the emoji as a Unicode character. diff --git a/apps/www/content/docs/mention.mdx b/apps/www/content/docs/mention.mdx index d7b37c37c3..69ee000dde 100644 --- a/apps/www/content/docs/mention.mdx +++ b/apps/www/content/docs/mention.mdx @@ -4,8 +4,6 @@ description: Enable autocompletion for user mentions. docs: - route: /docs/components/combobox title: Combobox - - route: /docs/components/mention-combobox - title: Mention Combobox - route: /docs/components/mention-element title: Mention Element - route: /docs/components/mention-input-element @@ -26,18 +24,16 @@ docs: ## Installation ```bash -npm install @udecode/plate-mention @udecode/plate-combobox +npm install @udecode/plate-mention ``` ## Usage ```tsx -import { createComboboxPlugin } from '@udecode/plate-combobox'; import { createMentionPlugin } from '@udecode/plate-mention'; const plugins = [ // ...otherPlugins, - createComboboxPlugin(), createMentionPlugin(), ]; ``` @@ -46,51 +42,21 @@ const plugins = [ ### createMentionPlugin - +Extends [TriggerComboboxPlugin](/docs/combobox#options) + - -A function to create the mention node. - - - - A unique ID for the mention plugin. + A function to create the mention node. + Whether to insert a space after the mention. - - The character that triggers the mention (for example, '@' in the case of a - user mention). - - - The pattern that matches the char before the trigger character - (default to `/^\s?$/` that matches beginning of line or space) - - - - -An object containing the key-value pair for creating an input. - - - - The key for the input. - - - The value for the input. - - - - - - A query function to enable the behavior. - - ### getMentionOnSelectItem -Gets the `ComboboxOnSelectItem` handler for selecting an item in the mention combobox. +Gets the handler for selecting an item in the mention combobox. @@ -106,96 +72,7 @@ The plugin key of the mention plugin. - - The `ComboboxOnSelectItem` handler for selecting an item in the mention - combobox. - - - -### findMentionInput - -Finds the mention input node in the editor. - - - -The editor instance. - - - -Additional options for finding the mention input node. - - - - - - - The mention input node entry if found, otherwise `undefined`. - - - -### isNodeMentionInput - -Checks if a node is a mention input node in the editor. - - - - The editor instance. - - - The node to check. - - - - - - `true` if the node is a mention input node, otherwise `false`. - - - -### isSelectionInMentionInput - -Checks if the current selection in the editor is within a mention input. - - - - The editor instance. - - - - - - `true` if the selection is within a mention input, otherwise `false`. + + The handler for selecting an item in the mention combobox. - -### mentionOnKeyDownHandler - -Handles keydown events for mention-related functionality, such as removing mention inputs and moving the selection by offset. - - - - Options for moving the selection by offset. - - - A query function to enable the behavior. - - - - - -### removeMentionInput - -Removes the mention input node at the specified path. - - - - The editor instance. - - - The path of the mention input node to remove. - - diff --git a/apps/www/package.json b/apps/www/package.json index 1e9c9727f3..184a9565bf 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -34,6 +34,7 @@ "react-dom": "^18.2.0" }, "dependencies": { + "@ariakit/react": "0.4.6", "@faker-js/faker": "^8.4.1", "@radix-ui/colors": "1.0.1", "@radix-ui/react-accessible-icon": "^1.0.3", diff --git a/apps/www/public/registry/index.json b/apps/www/public/registry/index.json index bcf898253b..a9eab43935 100644 --- a/apps/www/public/registry/index.json +++ b/apps/www/public/registry/index.json @@ -132,6 +132,19 @@ ], "type": "components:plate-ui" }, + { + "dependencies": [ + "@udecode/plate-emoji" + ], + "files": [ + "plate-ui/emoji-input-element.tsx" + ], + "name": "emoji-input-element", + "registryDependencies": [ + "inline-combobox" + ], + "type": "components:plate-ui" + }, { "dependencies": [ "@udecode/plate-alignment" @@ -234,19 +247,6 @@ "registryDependencies": [], "type": "components:plate-ui" }, - { - "dependencies": [ - "@radix-ui/react-popover", - "@udecode/plate-combobox", - "@udecode/plate-floating" - ], - "files": [ - "plate-ui/combobox.tsx" - ], - "name": "combobox", - "registryDependencies": [], - "type": "components:plate-ui" - }, { "dependencies": [ "cmdk" @@ -311,17 +311,6 @@ "registryDependencies": [], "type": "components:plate-ui" }, - { - "dependencies": [ - "@udecode/plate-combobox" - ], - "files": [ - "plate-ui/emoji-combobox.tsx" - ], - "name": "emoji-combobox", - "registryDependencies": [], - "type": "components:plate-ui" - }, { "dependencies": [ "@udecode/plate-excalidraw" @@ -463,6 +452,18 @@ ], "type": "components:plate-ui" }, + { + "dependencies": [ + "@ariakit/react", + "@udecode/plate-combobox" + ], + "files": [ + "plate-ui/inline-combobox.tsx" + ], + "name": "inline-combobox", + "registryDependencies": [], + "type": "components:plate-ui" + }, { "dependencies": [], "files": [ @@ -638,16 +639,13 @@ }, { "dependencies": [ - "@udecode/plate-mention", - "@udecode/plate-combobox" + "@udecode/plate-mention" ], "files": [ - "plate-ui/mention-combobox.tsx" - ], - "name": "mention-combobox", - "registryDependencies": [ - "combobox" + "plate-ui/mention-element.tsx" ], + "name": "mention-element", + "registryDependencies": [], "type": "components:plate-ui" }, { @@ -669,7 +667,9 @@ "plate-ui/mention-input-element.tsx" ], "name": "mention-input-element", - "registryDependencies": [], + "registryDependencies": [ + "inline-combobox" + ], "type": "components:plate-ui" }, { @@ -768,6 +768,20 @@ "registryDependencies": [], "type": "components:plate-ui" }, + { + "dependencies": [ + "@udecode/plate-heading", + "@udecode/plate-indent-list" + ], + "files": [ + "plate-ui/slash-input-element.tsx" + ], + "name": "slash-input-element", + "registryDependencies": [ + "inline-combobox" + ], + "type": "components:plate-ui" + }, { "dependencies": [ "@udecode/plate-table" diff --git a/apps/www/public/registry/styles/default/emoji-input-element.json b/apps/www/public/registry/styles/default/emoji-input-element.json new file mode 100644 index 0000000000..094da67fcf --- /dev/null +++ b/apps/www/public/registry/styles/default/emoji-input-element.json @@ -0,0 +1,16 @@ +{ + "dependencies": [ + "@udecode/plate-emoji" + ], + "files": [ + { + "content": "import React, { useMemo, useState } from 'react';\n\nimport { withRef } from '@udecode/cn';\nimport { PlateElement } from '@udecode/plate-common';\nimport { EmojiInlineIndexSearch, insertEmoji } from '@udecode/plate-emoji';\n\nimport { useDebounce } from '@/hooks/use-debounce';\n\nimport {\n InlineCombobox,\n InlineComboboxContent,\n InlineComboboxEmpty,\n InlineComboboxInput,\n InlineComboboxItem,\n} from './inline-combobox';\n\nexport const EmojiInputElement = withRef(\n ({ className, ...props }, ref) => {\n const { children, editor, element } = props;\n const [value, setValue] = useState('');\n const debouncedValue = useDebounce(value, 100);\n const isPending = value !== debouncedValue;\n\n const filteredEmojis = useMemo(() => {\n if (debouncedValue.trim().length === 0) return [];\n\n return EmojiInlineIndexSearch.getInstance()\n .search(debouncedValue.replace(/:$/, ''))\n .get();\n }, [debouncedValue]);\n\n return (\n \n \n \n\n \n {!isPending && (\n No matching emoji found\n )}\n\n {filteredEmojis.map((emoji) => (\n insertEmoji(editor, emoji)}\n value={emoji.name}\n >\n {emoji.skins[0].native} {emoji.name}\n \n ))}\n \n \n\n {children}\n \n );\n }\n);\n", + "name": "emoji-input-element.tsx" + } + ], + "name": "emoji-input-element", + "registryDependencies": [ + "inline-combobox" + ], + "type": "components:plate-ui" +} \ No newline at end of file diff --git a/apps/www/public/registry/styles/default/inline-combobox.json b/apps/www/public/registry/styles/default/inline-combobox.json new file mode 100644 index 0000000000..540153d93b --- /dev/null +++ b/apps/www/public/registry/styles/default/inline-combobox.json @@ -0,0 +1,15 @@ +{ + "dependencies": [ + "@ariakit/react", + "@udecode/plate-combobox" + ], + "files": [ + { + "content": "import React, {\n type HTMLAttributes,\n type ReactNode,\n type RefObject,\n createContext,\n forwardRef,\n startTransition,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from 'react';\n\nimport type { PointRef } from 'slate';\n\nimport {\n Combobox,\n ComboboxItem,\n type ComboboxItemProps,\n ComboboxPopover,\n ComboboxProvider,\n Portal,\n useComboboxContext,\n useComboboxStore,\n} from '@ariakit/react';\nimport { cn } from '@udecode/cn';\nimport {\n type UseComboboxInputResult,\n filterWords,\n useComboboxInput,\n useHTMLInputCursorState,\n} from '@udecode/plate-combobox';\nimport {\n type TElement,\n createPointRef,\n findNodePath,\n getPointBefore,\n insertText,\n moveSelection,\n useComposedRef,\n useEditorRef,\n} from '@udecode/plate-common';\nimport { cva } from 'class-variance-authority';\n\ntype FilterFn = (\n item: { keywords?: string[]; value: string },\n search: string\n) => boolean;\n\ninterface InlineComboboxContextValue {\n filter: FilterFn | false;\n inputProps: UseComboboxInputResult['props'];\n inputRef: RefObject;\n removeInput: UseComboboxInputResult['removeInput'];\n setHasEmpty: (hasEmpty: boolean) => void;\n showTrigger: boolean;\n trigger: string;\n}\n\nconst InlineComboboxContext = createContext(\n null as any\n);\n\nexport const defaultFilter: FilterFn = ({ keywords = [], value }, search) =>\n [value, ...keywords].some((keyword) => filterWords(keyword, search));\n\ninterface InlineComboboxProps {\n children: ReactNode;\n element: TElement;\n trigger: string;\n filter?: FilterFn | false;\n hideWhenNoValue?: boolean;\n setValue?: (value: string) => void;\n showTrigger?: boolean;\n value?: string;\n}\n\nconst InlineCombobox = ({\n children,\n element,\n filter = defaultFilter,\n hideWhenNoValue = false,\n setValue: setValueProp,\n showTrigger = true,\n trigger,\n value: valueProp,\n}: InlineComboboxProps) => {\n const editor = useEditorRef();\n const inputRef = React.useRef(null);\n const cursorState = useHTMLInputCursorState(inputRef);\n\n const [valueState, setValueState] = useState('');\n const hasValueProp = valueProp !== undefined;\n const value = hasValueProp ? valueProp : valueState;\n\n const setValue = useCallback(\n (newValue: string) => {\n setValueProp?.(newValue);\n\n if (!hasValueProp) {\n setValueState(newValue);\n }\n },\n [setValueProp, hasValueProp]\n );\n\n /**\n * Track the point just before the input element so we know where to\n * insertText if the combobox closes due to a selection change.\n */\n const [insertPoint, setInsertPoint] = useState(null);\n\n useEffect(() => {\n const path = findNodePath(editor, element);\n\n if (!path) return;\n\n const point = getPointBefore(editor, path);\n\n if (!point) return;\n\n const pointRef = createPointRef(editor, point);\n setInsertPoint(pointRef);\n\n return () => {\n pointRef.unref();\n };\n }, [editor, element]);\n\n const { props: inputProps, removeInput } = useComboboxInput({\n cancelInputOnBlur: false,\n cursorState,\n onCancelInput: (cause) => {\n if (cause !== 'backspace') {\n insertText(editor, trigger + value, {\n at: insertPoint?.current ?? undefined,\n });\n }\n if (cause === 'arrowLeft' || cause === 'arrowRight') {\n moveSelection(editor, {\n distance: 1,\n reverse: cause === 'arrowLeft',\n });\n }\n },\n ref: inputRef,\n });\n\n const [hasEmpty, setHasEmpty] = useState(false);\n\n const contextValue: InlineComboboxContextValue = useMemo(\n () => ({\n filter,\n inputProps,\n inputRef,\n removeInput,\n setHasEmpty,\n showTrigger,\n trigger,\n }),\n [\n trigger,\n showTrigger,\n filter,\n inputRef,\n inputProps,\n removeInput,\n setHasEmpty,\n ]\n );\n\n const store = useComboboxStore({\n // open: ,\n setValue: (newValue) => startTransition(() => setValue(newValue)),\n });\n\n const items = store.useState('items');\n\n useEffect;\n\n /**\n * If there is no active ID and the list of items changes, select the first\n * item.\n */\n useEffect(() => {\n if (!store.getState().activeId) {\n store.setActiveId(store.first());\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [items, store]);\n\n return (\n \n 0 || hasEmpty) &&\n (!hideWhenNoValue || value.length > 0)\n }\n store={store}\n >\n \n {children}\n \n \n \n );\n};\n\nconst InlineComboboxInput = forwardRef<\n HTMLInputElement,\n HTMLAttributes\n>(({ className, ...props }, propRef) => {\n const {\n inputProps,\n inputRef: contextRef,\n showTrigger,\n trigger,\n } = useContext(InlineComboboxContext);\n\n const store = useComboboxContext()!;\n const value = store.useState('value');\n\n const ref = useComposedRef(propRef, contextRef);\n\n /**\n * To create an auto-resizing input, we render a visually hidden span\n * containing the input value and position the input element on top of it.\n * This works well for all cases except when input exceeds the width of the\n * container.\n */\n\n return (\n <>\n {showTrigger && trigger}\n\n \n \n {value || '\\u200B'}\n \n\n \n \n \n );\n});\n\nInlineComboboxInput.displayName = 'InlineComboboxInput';\n\nconst InlineComboboxContent: typeof ComboboxPopover = ({\n className,\n ...props\n}) => {\n // Portal prevents CSS from leaking into popover\n return (\n \n \n \n );\n};\n\nconst comboboxItemVariants = cva(\n 'relative flex h-9 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',\n {\n defaultVariants: {\n interactive: true,\n },\n variants: {\n interactive: {\n false: '',\n true: 'cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground',\n },\n },\n }\n);\n\nexport type InlineComboboxItemProps = {\n keywords?: string[];\n} & ComboboxItemProps &\n Required>;\n\nconst InlineComboboxItem = ({\n className,\n keywords,\n onClick,\n ...props\n}: InlineComboboxItemProps) => {\n const { value } = props;\n\n const { filter, removeInput } = useContext(InlineComboboxContext);\n\n const store = useComboboxContext()!;\n\n // Optimization: Do not subscribe to value if filter is false\n const search = filter && store.useState('value');\n\n const visible = useMemo(\n () => !filter || filter({ keywords, value }, search as string),\n [filter, value, keywords, search]\n );\n\n if (!visible) return null;\n\n return (\n {\n removeInput(true);\n onClick?.(event);\n }}\n {...props}\n />\n );\n};\n\nconst InlineComboboxEmpty = ({\n children,\n className,\n}: HTMLAttributes) => {\n const { setHasEmpty } = useContext(InlineComboboxContext);\n const store = useComboboxContext()!;\n const items = store.useState('items');\n\n useEffect(() => {\n setHasEmpty(true);\n\n return () => {\n setHasEmpty(false);\n };\n }, [setHasEmpty]);\n\n if (items.length > 0) return null;\n\n return (\n \n {children}\n \n );\n};\n\nexport {\n InlineCombobox,\n InlineComboboxContent,\n InlineComboboxEmpty,\n InlineComboboxInput,\n InlineComboboxItem,\n};\n", + "name": "inline-combobox.tsx" + } + ], + "name": "inline-combobox", + "registryDependencies": [], + "type": "components:plate-ui" +} \ No newline at end of file diff --git a/apps/www/public/registry/styles/default/mention-input-element.json b/apps/www/public/registry/styles/default/mention-input-element.json index fc0435b2b6..bf61f03b2e 100644 --- a/apps/www/public/registry/styles/default/mention-input-element.json +++ b/apps/www/public/registry/styles/default/mention-input-element.json @@ -4,11 +4,13 @@ ], "files": [ { - "content": "import React from 'react';\n\nimport { cn, withRef } from '@udecode/cn';\nimport { PlateElement, getHandler } from '@udecode/plate-common';\nimport { useFocused, useSelected } from 'slate-react';\n\nexport const MentionInputElement = withRef<\n typeof PlateElement,\n {\n onClick?: (mentionNode: any) => void;\n }\n>(({ className, onClick, ...props }, ref) => {\n const { children, element } = props;\n\n const selected = useSelected();\n const focused = useFocused();\n\n return (\n \n {children}\n \n );\n});\n", + "content": "import React, { useState } from 'react';\n\nimport { cn, withRef } from '@udecode/cn';\nimport { PlateElement } from '@udecode/plate-common';\nimport { getMentionOnSelectItem } from '@udecode/plate-mention';\n\nimport { MENTIONABLES } from '@/lib/plate/demo/values/mentionables';\n\nimport {\n InlineCombobox,\n InlineComboboxContent,\n InlineComboboxEmpty,\n InlineComboboxInput,\n InlineComboboxItem,\n} from './inline-combobox';\n\nconst onSelectItem = getMentionOnSelectItem();\n\nexport const MentionInputElement = withRef(\n ({ className, ...props }, ref) => {\n const { children, editor, element } = props;\n const [search, setSearch] = useState('');\n\n return (\n \n \n \n \n \n\n \n No results found\n\n {MENTIONABLES.map((item) => (\n onSelectItem(editor, item, search)}\n value={item.text}\n >\n {item.text}\n \n ))}\n \n \n\n {children}\n \n );\n }\n);\n", "name": "mention-input-element.tsx" } ], "name": "mention-input-element", - "registryDependencies": [], + "registryDependencies": [ + "inline-combobox" + ], "type": "components:plate-ui" } \ No newline at end of file diff --git a/apps/www/public/registry/styles/default/slash-input-element.json b/apps/www/public/registry/styles/default/slash-input-element.json new file mode 100644 index 0000000000..46dfefb9b1 --- /dev/null +++ b/apps/www/public/registry/styles/default/slash-input-element.json @@ -0,0 +1,17 @@ +{ + "dependencies": [ + "@udecode/plate-heading", + "@udecode/plate-indent-list" + ], + "files": [ + { + "content": "import React, { type ComponentType, type SVGProps } from 'react';\n\nimport { withRef } from '@udecode/cn';\nimport {\n type PlateEditor,\n PlateElement,\n toggleNodeType,\n} from '@udecode/plate-common';\nimport { ELEMENT_H1, ELEMENT_H2, ELEMENT_H3 } from '@udecode/plate-heading';\nimport { ListStyleType, toggleIndentList } from '@udecode/plate-indent-list';\n\nimport { Icons } from '@/components/icons';\n\nimport {\n InlineCombobox,\n InlineComboboxContent,\n InlineComboboxEmpty,\n InlineComboboxInput,\n InlineComboboxItem,\n} from './inline-combobox';\n\ninterface SlashCommandRule {\n icon: ComponentType>;\n onSelect: (editor: PlateEditor) => void;\n value: string;\n keywords?: string[];\n}\n\nconst rules: SlashCommandRule[] = [\n {\n icon: Icons.h1,\n onSelect: (editor) => {\n toggleNodeType(editor, { activeType: ELEMENT_H1 });\n },\n value: 'Heading 1',\n },\n {\n icon: Icons.h2,\n onSelect: (editor) => {\n toggleNodeType(editor, { activeType: ELEMENT_H2 });\n },\n value: 'Heading 2',\n },\n {\n icon: Icons.h3,\n onSelect: (editor) => {\n toggleNodeType(editor, { activeType: ELEMENT_H3 });\n },\n value: 'Heading 3',\n },\n {\n icon: Icons.ul,\n keywords: ['ul', 'unordered list'],\n onSelect: (editor) => {\n toggleIndentList(editor, {\n listStyleType: ListStyleType.Disc,\n });\n },\n value: 'Bulleted list',\n },\n {\n icon: Icons.ol,\n keywords: ['ol', 'ordered list'],\n onSelect: (editor) => {\n toggleIndentList(editor, {\n listStyleType: ListStyleType.Decimal,\n });\n },\n value: 'Numbered list',\n },\n];\n\nexport const SlashInputElement = withRef(\n ({ className, ...props }, ref) => {\n const { children, editor, element } = props;\n\n return (\n \n \n \n\n \n \n No matching commands found\n \n\n {rules.map(({ icon: Icon, keywords, onSelect, value }) => (\n onSelect(editor)}\n value={value}\n >\n \n {value}\n \n ))}\n \n \n\n {children}\n \n );\n }\n);\n", + "name": "slash-input-element.tsx" + } + ], + "name": "slash-input-element", + "registryDependencies": [ + "inline-combobox" + ], + "type": "components:plate-ui" +} \ No newline at end of file diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index 906a875247..477cd9c008 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -256,6 +256,13 @@ export const Index: Record = { files: ['registry/default/plate-ui/emoji-picker-search-bar.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/emoji-picker-search-bar')), }, + 'emoji-input-element': { + name: 'emoji-input-element', + type: 'components:plate-ui', + registryDependencies: ["inline-combobox"], + files: ['registry/default/plate-ui/emoji-input-element.tsx'], + component: React.lazy(() => import('@/registry/default/plate-ui/emoji-input-element')), + }, 'align-dropdown-menu': { name: 'align-dropdown-menu', type: 'components:plate-ui', @@ -319,13 +326,6 @@ export const Index: Record = { files: ['registry/default/plate-ui/code-syntax-leaf.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/code-syntax-leaf')), }, - 'combobox': { - name: 'combobox', - type: 'components:plate-ui', - registryDependencies: [], - files: ['registry/default/plate-ui/combobox.tsx'], - component: React.lazy(() => import('@/registry/default/plate-ui/combobox')), - }, 'command': { name: 'command', type: 'components:plate-ui', @@ -368,13 +368,6 @@ export const Index: Record = { files: ['registry/default/plate-ui/dropdown-menu.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/dropdown-menu')), }, - 'emoji-combobox': { - name: 'emoji-combobox', - type: 'components:plate-ui', - registryDependencies: [], - files: ['registry/default/plate-ui/emoji-combobox.tsx'], - component: React.lazy(() => import('@/registry/default/plate-ui/emoji-combobox')), - }, 'excalidraw-element': { name: 'excalidraw-element', type: 'components:plate-ui', @@ -452,6 +445,13 @@ export const Index: Record = { files: ['registry/default/plate-ui/indent-toolbar-button.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/indent-toolbar-button')), }, + 'inline-combobox': { + name: 'inline-combobox', + type: 'components:plate-ui', + registryDependencies: [], + files: ['registry/default/plate-ui/inline-combobox.tsx'], + component: React.lazy(() => import('@/registry/default/plate-ui/inline-combobox')), + }, 'input': { name: 'input', type: 'components:plate-ui', @@ -543,12 +543,12 @@ export const Index: Record = { files: ['registry/default/plate-ui/media-toolbar-button.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/media-toolbar-button')), }, - 'mention-combobox': { - name: 'mention-combobox', + 'mention-element': { + name: 'mention-element', type: 'components:plate-ui', - registryDependencies: ["combobox"], - files: ['registry/default/plate-ui/mention-combobox.tsx'], - component: React.lazy(() => import('@/registry/default/plate-ui/mention-combobox')), + registryDependencies: [], + files: ['registry/default/plate-ui/mention-element.tsx'], + component: React.lazy(() => import('@/registry/default/plate-ui/mention-element')), }, 'mention-element': { name: 'mention-element', @@ -560,7 +560,7 @@ export const Index: Record = { 'mention-input-element': { name: 'mention-input-element', type: 'components:plate-ui', - registryDependencies: [], + registryDependencies: ["inline-combobox"], files: ['registry/default/plate-ui/mention-input-element.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/mention-input-element')), }, @@ -620,6 +620,13 @@ export const Index: Record = { files: ['registry/default/plate-ui/separator.tsx'], component: React.lazy(() => import('@/registry/default/plate-ui/separator')), }, + 'slash-input-element': { + name: 'slash-input-element', + type: 'components:plate-ui', + registryDependencies: ["inline-combobox"], + files: ['registry/default/plate-ui/slash-input-element.tsx'], + component: React.lazy(() => import('@/registry/default/plate-ui/slash-input-element')), + }, 'table-cell-element': { name: 'table-cell-element', type: 'components:plate-ui', diff --git a/apps/www/src/config/customizer-components.ts b/apps/www/src/config/customizer-components.ts index 71312cbb37..0a2206eeea 100644 --- a/apps/www/src/config/customizer-components.ts +++ b/apps/www/src/config/customizer-components.ts @@ -76,14 +76,15 @@ export const customizerComponents = { href: '/docs/components/editor', title: 'Editor', }, - emojiCombobox: { - href: '/docs/components/emoji-combobox', - title: 'Emoji Combobox', - }, emojiDropdownMenu: { href: '/docs/components/emoji-dropdown-menu', title: 'Emoji Dropdown Menu', }, + emojiInputElement: { + href: '/docs/components/emoji-input-element', + label: 'Element', + title: 'Emoji Input', + }, emojiToolbarDropdown: { href: '/docs/components/emoji-toolbar-dropdown', title: 'Emoji Toolbar Dropdown', @@ -137,6 +138,10 @@ export const customizerComponents = { href: '/docs/components/indent-toolbar-button', title: 'Indent Toolbar Button', }, + inlineCombobox: { + href: '/docs/components/inline-combobox', + title: 'Inline Combobox', + }, input: { href: '/docs/components/input', title: 'Input' }, insertDropdownMenu: { href: '/docs/components/insert-dropdown-menu', @@ -186,10 +191,6 @@ export const customizerComponents = { href: '/docs/components/media-toolbar-button', title: 'Media Toolbar Button', }, - mentionCombobox: { - href: '/docs/components/mention-combobox', - title: 'Mention Combobox', - }, mentionElement: { href: '/docs/components/mention-element', label: 'Element', diff --git a/apps/www/src/config/customizer-items.ts b/apps/www/src/config/customizer-items.ts index 830f53f0c3..60f9395861 100644 --- a/apps/www/src/config/customizer-items.ts +++ b/apps/www/src/config/customizer-items.ts @@ -17,7 +17,6 @@ import { } from '@udecode/plate-break'; import { KEY_CAPTION } from '@udecode/plate-caption'; import { ELEMENT_CODE_BLOCK } from '@udecode/plate-code-block'; -import { KEY_COMBOBOX } from '@udecode/plate-combobox'; import { MARK_COMMENT } from '@udecode/plate-comments'; import { KEY_DND } from '@udecode/plate-dnd'; import { KEY_EMOJI } from '@udecode/plate-emoji'; @@ -253,14 +252,7 @@ export const customizerItems: Record = { route: customizerComponents.mentionInputElement.href, usage: 'MentionInputElement', }, - { - id: 'mention-combobox', - label: 'MentionCombobox', - route: customizerComponents.mentionCombobox.href, - usage: 'MentionCombobox', - }, ], - dependencies: [KEY_COMBOBOX], id: ELEMENT_MENTION, label: 'Mention', npmPackage: '@udecode/plate-mention', @@ -425,14 +417,6 @@ export const customizerItems: Record = { ], route: customizerPlugins.media.route, }, - [KEY_COMBOBOX]: { - badges: [customizerBadges.handler, customizerBadges.ui], - id: KEY_COMBOBOX, - label: 'Combobox', - npmPackage: '@udecode/plate-combobox', - pluginFactory: 'createComboboxPlugin', - route: customizerPlugins.combobox.route, - }, [KEY_DELETE]: { badges: [customizerBadges.handler], id: KEY_DELETE, @@ -501,14 +485,12 @@ export const customizerItems: Record = { badges: [customizerBadges.handler], components: [ { - id: 'emoji-combobox', - label: 'EmojiCombobox', - pluginOptions: [`renderAfterEditable: EmojiCombobox,`], - route: customizerComponents.emojiCombobox.href, - usage: 'EmojiCombobox', + id: 'emoji-input-element', + label: 'EmojiInputElement', + route: customizerComponents.emojiInputElement.href, + usage: 'EmojiInputElement', }, ], - dependencies: [KEY_COMBOBOX], id: KEY_EMOJI, label: 'Emoji', npmPackage: '@udecode/plate-emoji', diff --git a/apps/www/src/config/customizer-list.ts b/apps/www/src/config/customizer-list.ts index 712ad9a9f4..42bfab67ca 100644 --- a/apps/www/src/config/customizer-list.ts +++ b/apps/www/src/config/customizer-list.ts @@ -17,7 +17,6 @@ import { } from '@udecode/plate-break'; import { KEY_CAPTION } from '@udecode/plate-caption'; import { ELEMENT_CODE_BLOCK } from '@udecode/plate-code-block'; -import { KEY_COMBOBOX } from '@udecode/plate-combobox'; import { MARK_COMMENT } from '@udecode/plate-comments'; import { KEY_DND } from '@udecode/plate-dnd'; import { KEY_EMOJI } from '@udecode/plate-emoji'; @@ -109,7 +108,6 @@ export const customizerList = [ customizerItems[KEY_AUTOFORMAT], customizerItems[KEY_BLOCK_SELECTION], customizerItems[KEY_CAPTION], - customizerItems[KEY_COMBOBOX], customizerItems[KEY_DND], customizerItems[KEY_DRAG_OVER_CURSOR], customizerItems[KEY_EMOJI], @@ -178,7 +176,6 @@ export const orderedPluginKeys = [ // Functionality KEY_AUTOFORMAT, KEY_BLOCK_SELECTION, - KEY_COMBOBOX, KEY_DND, KEY_EMOJI, KEY_EXIT_BREAK, diff --git a/apps/www/src/config/customizer-plugins.ts b/apps/www/src/config/customizer-plugins.ts index 1ccc57024e..ff1c2d207c 100644 --- a/apps/www/src/config/customizer-plugins.ts +++ b/apps/www/src/config/customizer-plugins.ts @@ -6,7 +6,6 @@ import { KEY_SOFT_BREAK, } from '@udecode/plate-break'; import { KEY_CAPTION } from '@udecode/plate-caption'; -import { KEY_COMBOBOX } from '@udecode/plate-combobox'; import { MARK_COMMENT } from '@udecode/plate-comments'; import { KEY_DND } from '@udecode/plate-dnd'; import { KEY_EMOJI } from '@udecode/plate-emoji'; @@ -129,12 +128,6 @@ export const customizerPlugins = { route: '/docs/column', value: columnValue, }, - combobox: { - id: 'combobox', - label: 'Combobox', - plugins: [KEY_COMBOBOX], - route: '/docs/combobox', - }, comment: { id: 'comment', label: 'Comment', diff --git a/apps/www/src/config/descriptions.ts b/apps/www/src/config/descriptions.ts index 03d34bad91..54bc12f15f 100644 --- a/apps/www/src/config/descriptions.ts +++ b/apps/www/src/config/descriptions.ts @@ -15,7 +15,6 @@ import { KEY_SINGLE_LINE, KEY_SOFT_BREAK, } from '@udecode/plate-break'; -import { KEY_COMBOBOX } from '@udecode/plate-combobox'; import { MARK_COMMENT } from '@udecode/plate-comments'; import { KEY_DND } from '@udecode/plate-dnd'; import { KEY_EMOJI } from '@udecode/plate-emoji'; @@ -66,7 +65,6 @@ export const descriptions: Record = { [KEY_ALIGN]: 'Align your content to different positions.', [KEY_AUTOFORMAT]: 'Apply formatting automatically using shortcodes.', [KEY_BLOCK_SELECTION]: 'Select and manipulate entire text blocks.', - [KEY_COMBOBOX]: 'Select options from a predefined list.', [KEY_DELETE]: 'Remove the current block if empty when pressing delete forward', [KEY_DESERIALIZE_CSV]: 'Copy paste from CSV to Slate.', diff --git a/apps/www/src/config/docs.ts b/apps/www/src/config/docs.ts index dff5e2ed5f..684115a779 100644 --- a/apps/www/src/config/docs.ts +++ b/apps/www/src/config/docs.ts @@ -69,8 +69,8 @@ export const docsConfig: DocsConfig = { customizerComponents.dialog, customizerComponents.draggable, customizerComponents.dropdownMenu, - customizerComponents.emojiCombobox, customizerComponents.emojiDropdownMenu, + customizerComponents.emojiInputElement, customizerComponents.emojiToolbarDropdown, customizerComponents.excalidrawElement, customizerComponents.fixedToolbar, @@ -83,6 +83,7 @@ export const docsConfig: DocsConfig = { customizerComponents.imageElement, customizerComponents.indentListToolbarButton, customizerComponents.indentToolbarButton, + customizerComponents.inlineCombobox, customizerComponents.input, customizerComponents.insertDropdownMenu, customizerComponents.kbdLeaf, @@ -96,7 +97,6 @@ export const docsConfig: DocsConfig = { customizerComponents.mediaEmbedElement, customizerComponents.mediaPopover, customizerComponents.mediaToolbarButton, - customizerComponents.mentionCombobox, customizerComponents.mentionElement, customizerComponents.mentionInputElement, customizerComponents.modeDropdownMenu, diff --git a/apps/www/src/lib/plate/create-plate-ui.ts b/apps/www/src/lib/plate/create-plate-ui.ts index ccf3195330..a4f4de4210 100644 --- a/apps/www/src/lib/plate/create-plate-ui.ts +++ b/apps/www/src/lib/plate/create-plate-ui.ts @@ -61,6 +61,7 @@ import { CodeSyntaxLeaf } from '@/registry/default/plate-ui/code-syntax-leaf'; import { ColumnElement } from '@/registry/default/plate-ui/column-element'; import { ColumnGroupElement } from '@/registry/default/plate-ui/column-group-element'; import { CommentLeaf } from '@/registry/default/plate-ui/comment-leaf'; +import { EmojiInputElement } from '@/registry/default/plate-ui/emoji-input-element'; import { ExcalidrawElement } from '@/registry/default/plate-ui/excalidraw-element'; import { HeadingElement } from '@/registry/default/plate-ui/heading-element'; import { HighlightLeaf } from '@/registry/default/plate-ui/highlight-leaf'; @@ -86,6 +87,8 @@ import { TodoListElement } from '@/registry/default/plate-ui/todo-list-element'; import { ToggleElement } from '@/registry/default/plate-ui/toggle-element'; import { withDraggables } from '@/registry/default/plate-ui/with-draggables'; +import { ELEMENT_EMOJI_INPUT } from '../../../../../packages/emoji/dist'; + export const createPlateUI = ( overrideByKey?: Partial>, { @@ -100,6 +103,7 @@ export const createPlateUI = ( [ELEMENT_CODE_SYNTAX]: CodeSyntaxLeaf, [ELEMENT_COLUMN]: ColumnElement, [ELEMENT_COLUMN_GROUP]: ColumnGroupElement, + [ELEMENT_EMOJI_INPUT]: EmojiInputElement, [ELEMENT_EXCALIDRAW]: ExcalidrawElement, [ELEMENT_H1]: withProps(HeadingElement, { variant: 'h1' }), [ELEMENT_H2]: withProps(HeadingElement, { variant: 'h2' }), diff --git a/apps/www/src/lib/plate/demo/plugins/emojiPlugin.ts b/apps/www/src/lib/plate/demo/plugins/emojiPlugin.ts deleted file mode 100644 index e4b2a00142..0000000000 --- a/apps/www/src/lib/plate/demo/plugins/emojiPlugin.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PlatePlugin } from '@udecode/plate-common'; -import type { EmojiPlugin } from '@udecode/plate-emoji'; - -import { EmojiCombobox } from '@/registry/default/plate-ui/emoji-combobox'; - -export const emojiPlugin: Partial> = { - renderAfterEditable: EmojiCombobox, -}; diff --git a/apps/www/src/lib/plate/demo/plugins/imagePlugins.ts b/apps/www/src/lib/plate/demo/plugins/imagePlugins.ts index 346f21915f..b93dc070b1 100644 --- a/apps/www/src/lib/plate/demo/plugins/imagePlugins.ts +++ b/apps/www/src/lib/plate/demo/plugins/imagePlugins.ts @@ -1,5 +1,4 @@ import { createBasicElementsPlugin } from '@udecode/plate-basic-elements'; -import { createComboboxPlugin } from '@udecode/plate-combobox'; import { createPlugins } from '@udecode/plate-core'; import { createImagePlugin } from '@udecode/plate-media'; import { createSelectOnBackspacePlugin } from '@udecode/plate-select'; @@ -15,7 +14,6 @@ export const imagePlugins = createPlugins( ...basicMarksPlugins, createImagePlugin(), createSelectOnBackspacePlugin(selectOnBackspacePlugin), - createComboboxPlugin(), ], { components: plateUI, diff --git a/apps/www/src/lib/plate/demo/values/emojiValue.tsx b/apps/www/src/lib/plate/demo/values/emojiValue.tsx index 3de8eb391a..2d4680b9a1 100644 --- a/apps/www/src/lib/plate/demo/values/emojiValue.tsx +++ b/apps/www/src/lib/plate/demo/values/emojiValue.tsx @@ -8,8 +8,6 @@ export const emojiValue: any = ( 🙂 Emoji's Express yourself with a touch of fun 🎉 and emotion 😃. - - Pick from the toolbar or write after the colon to open the combobox : - + Pick from the toolbar or type a colon to open the combobox. ); diff --git a/apps/www/src/lib/plate/demo/values/mentionables.ts b/apps/www/src/lib/plate/demo/values/mentionables.ts index ce62bca330..4669febd35 100644 --- a/apps/www/src/lib/plate/demo/values/mentionables.ts +++ b/apps/www/src/lib/plate/demo/values/mentionables.ts @@ -1,6 +1,10 @@ -import type { TComboboxItem } from '@udecode/plate-combobox'; +import type { TMentionItemBase } from '@udecode/plate-mention'; -export const MENTIONABLES: TComboboxItem[] = [ +export interface MyMentionItem extends TMentionItemBase { + key: string; +} + +export const MENTIONABLES: MyMentionItem[] = [ { key: '0', text: 'Aayla Secura' }, { key: '1', text: 'Adi Gallia' }, { diff --git a/apps/www/src/lib/plate/demo/values/slashRules.ts b/apps/www/src/lib/plate/demo/values/slashRules.ts deleted file mode 100644 index 48f6301fd1..0000000000 --- a/apps/www/src/lib/plate/demo/values/slashRules.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { SlashRule } from '@udecode/plate-slash-command'; - -import { type PlateEditor, toggleNodeType } from '@udecode/plate-core'; -import { ELEMENT_H1, ELEMENT_H2, ELEMENT_H3 } from '@udecode/plate-heading'; -import { ListStyleType, toggleIndentList } from '@udecode/plate-indent-list'; -import { focusEditor } from '@udecode/slate-react'; - -export const SLASH_RULES: SlashRule[] = [ - { - key: ELEMENT_H1, - onTrigger(editor: PlateEditor) { - toggleNodeType(editor, { activeType: ELEMENT_H1 }); - focusEditor(editor); - }, - text: 'Heading 1', - }, - { - key: ELEMENT_H2, - onTrigger(editor: PlateEditor) { - toggleNodeType(editor, { activeType: ELEMENT_H2 }); - focusEditor(editor); - }, - text: 'Heading 2', - }, - { - key: ELEMENT_H3, - onTrigger(editor: PlateEditor) { - toggleNodeType(editor, { activeType: ELEMENT_H3 }); - focusEditor(editor); - }, - text: 'Heading 3', - }, - { - key: ListStyleType.Disc, - onTrigger(editor: PlateEditor) { - toggleIndentList(editor, { - listStyleType: ListStyleType.Disc, - }); - focusEditor(editor); - }, - text: 'Bulleted list', - }, - { - key: ListStyleType.Decimal, - onTrigger(editor: PlateEditor) { - toggleIndentList(editor, { - listStyleType: ListStyleType.Decimal, - }); - focusEditor(editor); - }, - text: 'Numbered list', - }, -]; diff --git a/apps/www/src/registry/default/example/playground-demo.tsx b/apps/www/src/registry/default/example/playground-demo.tsx index 8620320767..bc3648bbd0 100644 --- a/apps/www/src/registry/default/example/playground-demo.tsx +++ b/apps/www/src/registry/default/example/playground-demo.tsx @@ -32,7 +32,6 @@ import { ELEMENT_CODE_BLOCK, createCodeBlockPlugin, } from '@udecode/plate-code-block'; -import { createComboboxPlugin } from '@udecode/plate-combobox'; import { createCommentsPlugin } from '@udecode/plate-comments'; import { Plate, @@ -96,7 +95,6 @@ import { settingsStore } from '@/components/context/settings-store'; import { PlaygroundFixedToolbarButtons } from '@/components/plate-ui/playground-fixed-toolbar-buttons'; import { PlaygroundFloatingToolbarButtons } from '@/components/plate-ui/playground-floating-toolbar-buttons'; import { captionPlugin } from '@/lib/plate/demo/plugins/captionPlugin'; -import { SLASH_RULES } from '@/lib/plate/demo/values/slashRules'; import { createPlateUI } from '@/plate/create-plate-ui'; import { CommentsProvider } from '@/plate/demo/comments/CommentsProvider'; import { editableProps } from '@/plate/demo/editableProps'; @@ -106,7 +104,6 @@ import { autoformatIndentLists } from '@/plate/demo/plugins/autoformatIndentList import { autoformatLists } from '@/plate/demo/plugins/autoformatLists'; import { autoformatRules } from '@/plate/demo/plugins/autoformatRules'; import { dragOverCursorPlugin } from '@/plate/demo/plugins/dragOverCursorPlugin'; -import { emojiPlugin } from '@/plate/demo/plugins/emojiPlugin'; import { exitBreakPlugin } from '@/plate/demo/plugins/exitBreakPlugin'; import { forcedLayoutPlugin } from '@/plate/demo/plugins/forcedLayoutPlugin'; import { lineHeightPlugin } from '@/plate/demo/plugins/lineHeightPlugin'; @@ -116,7 +113,6 @@ import { selectOnBackspacePlugin } from '@/plate/demo/plugins/selectOnBackspaceP import { softBreakPlugin } from '@/plate/demo/plugins/softBreakPlugin'; import { tabbablePlugin } from '@/plate/demo/plugins/tabbablePlugin'; import { trailingBlockPlugin } from '@/plate/demo/plugins/trailingBlockPlugin'; -import { MENTIONABLES } from '@/plate/demo/values/mentionables'; import { usePlaygroundValue } from '@/plate/demo/values/usePlaygroundValue'; import { CommentsPopover } from '@/registry/default/plate-ui/comments-popover'; import { CursorOverlay } from '@/registry/default/plate-ui/cursor-overlay'; @@ -131,8 +127,6 @@ import { TodoLi, TodoMarker, } from '@/registry/default/plate-ui/indent-todo-marker-component'; -import { MentionCombobox } from '@/registry/default/plate-ui/mention-combobox'; -import { SlashCombobox } from '@/registry/default/plate-ui/slash-combobox'; export const usePlaygroundPlugins = ({ components = createPlateUI(), @@ -181,11 +175,7 @@ export const usePlaygroundPlugins = ({ triggerPreviousCharPattern: /^$|^[\s"']$/, }, }), - createSlashPlugin({ - options: { - rules: SLASH_RULES, - }, - }), + createSlashPlugin(), createTablePlugin({ enabled: !!enabled.table, options: { @@ -285,12 +275,11 @@ export const usePlaygroundPlugins = ({ }, }, }), - createComboboxPlugin({ enabled: !!enabled.combobox }), createDndPlugin({ enabled: !!enabled.dnd, options: { enableScroller: true }, }), - createEmojiPlugin({ ...emojiPlugin, enabled: !!enabled.emoji }), + createEmojiPlugin({ enabled: !!enabled.emoji }), createExitBreakPlugin({ ...exitBreakPlugin, enabled: !!enabled.exitBreak, @@ -439,12 +428,6 @@ export default function PlaygroundDemo({ id }: { id?: ValueId }) { )} - {isEnabled('mention', id, enabled['mention-combobox']) && ( - - )} - - - {isEnabled('cursoroverlay', id) && ( )} diff --git a/apps/www/src/registry/default/plate-ui/combobox.tsx b/apps/www/src/registry/default/plate-ui/combobox.tsx deleted file mode 100644 index ad12816a9e..0000000000 --- a/apps/www/src/registry/default/plate-ui/combobox.tsx +++ /dev/null @@ -1,160 +0,0 @@ -'use client'; - -import React, { useEffect } from 'react'; - -import * as Popover from '@radix-ui/react-popover'; -import { cn, withRef } from '@udecode/cn'; -import { - type ComboboxContentItemProps, - type ComboboxContentProps, - type ComboboxProps, - comboboxActions, - useActiveComboboxStore, - useComboboxContent, - useComboboxContentState, - useComboboxControls, - useComboboxItem, - useComboboxSelectors, -} from '@udecode/plate-combobox'; -import { - useEditorRef, - useEditorSelector, - useEventEditorSelectors, - usePlateSelectors, -} from '@udecode/plate-common'; -import { - createVirtualRef, - getBoundingClientRect, -} from '@udecode/plate-floating'; - -export const ComboboxItem = withRef<'div', ComboboxContentItemProps>( - ({ className, combobox, index, item, onRenderItem, ...rest }, ref) => { - const { props } = useComboboxItem({ combobox, index, item, onRenderItem }); - - return ( -
- ); - } -); - -export function ComboboxContent(props: ComboboxContentProps) { - const { - combobox, - component: Component, - items, - onRenderItem, - portalElement, - } = props; - - const editor = useEditorRef(); - - const filteredItems = useComboboxSelectors.filteredItems(); - const activeComboboxStore = useActiveComboboxStore()!; - - const state = useComboboxContentState({ combobox, items }); - const { menuProps, targetRange } = useComboboxContent(state); - - const virtualRef = createVirtualRef(editor, targetRange ?? undefined, { - fallbackRect: getBoundingClientRect(editor, editor.selection!), - }); - - return ( - - - - - event.preventDefault()} - side="bottom" - sideOffset={5} - > - {Component ? Component({ store: activeComboboxStore }) : null} - - {filteredItems.map((item, index) => ( - - ))} - - - - ); -} - -export function Combobox({ - controlled, - disabled: _disabled, - filter, - id, - maxSuggestions, - onSelectItem, - searchPattern, - sort, - trigger, - ...props -}: ComboboxProps) { - const storeItems = useComboboxSelectors.items(); - const disabled = - _disabled ?? (storeItems.length === 0 && !props.items?.length); - - const focusedEditorId = useEventEditorSelectors.focus?.(); - const combobox = useComboboxControls(); - const activeId = useComboboxSelectors.activeId(); - const selectionDefined = useEditorSelector( - (editor) => !!editor.selection, - [] - ); - const editorId = usePlateSelectors().id(); - - useEffect(() => { - comboboxActions.setComboboxById({ - controlled, - filter, - id, - maxSuggestions, - onSelectItem, - searchPattern, - sort, - trigger, - }); - }, [ - id, - trigger, - searchPattern, - controlled, - onSelectItem, - maxSuggestions, - filter, - sort, - ]); - - if ( - !combobox || - !selectionDefined || - focusedEditorId !== editorId || - activeId !== id || - disabled - ) { - return null; - } - - return ; -} diff --git a/apps/www/src/registry/default/plate-ui/emoji-combobox.tsx b/apps/www/src/registry/default/plate-ui/emoji-combobox.tsx deleted file mode 100644 index b8c0ed0b88..0000000000 --- a/apps/www/src/registry/default/plate-ui/emoji-combobox.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; - -import type { ComboboxItemProps } from '@udecode/plate-combobox'; - -import { - type EmojiItemData, - KEY_EMOJI, - type TEmojiCombobox, - useEmojiComboboxState, -} from '@udecode/plate-emoji'; - -import { Combobox } from './combobox'; - -export function EmojiComboboxItem({ item }: ComboboxItemProps) { - const { - data: { emoji, id }, - } = item; - - return ( -
- {emoji} :{id}: -
- ); -} - -export function EmojiCombobox({ - pluginKey = KEY_EMOJI, - id = pluginKey, - ...props -}: TEmojiCombobox) { - const { onSelectItem, trigger } = useEmojiComboboxState({ pluginKey }); - - return ( - - ); -} diff --git a/apps/www/src/registry/default/plate-ui/emoji-input-element.tsx b/apps/www/src/registry/default/plate-ui/emoji-input-element.tsx new file mode 100644 index 0000000000..a25ca51cdc --- /dev/null +++ b/apps/www/src/registry/default/plate-ui/emoji-input-element.tsx @@ -0,0 +1,70 @@ +import React, { useMemo, useState } from 'react'; + +import { withRef } from '@udecode/cn'; +import { PlateElement } from '@udecode/plate-common'; +import { EmojiInlineIndexSearch, insertEmoji } from '@udecode/plate-emoji'; + +import { useDebounce } from '@/hooks/use-debounce'; + +import { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +} from './inline-combobox'; + +export const EmojiInputElement = withRef( + ({ className, ...props }, ref) => { + const { children, editor, element } = props; + const [value, setValue] = useState(''); + const debouncedValue = useDebounce(value, 100); + const isPending = value !== debouncedValue; + + const filteredEmojis = useMemo(() => { + if (debouncedValue.trim().length === 0) return []; + + return EmojiInlineIndexSearch.getInstance() + .search(debouncedValue.replace(/:$/, '')) + .get(); + }, [debouncedValue]); + + return ( + + + + + + {!isPending && ( + No matching emoji found + )} + + {filteredEmojis.map((emoji) => ( + insertEmoji(editor, emoji)} + value={emoji.name} + > + {emoji.skins[0].native} {emoji.name} + + ))} + + + + {children} + + ); + } +); diff --git a/apps/www/src/registry/default/plate-ui/inline-combobox.tsx b/apps/www/src/registry/default/plate-ui/inline-combobox.tsx new file mode 100644 index 0000000000..29b4641e9a --- /dev/null +++ b/apps/www/src/registry/default/plate-ui/inline-combobox.tsx @@ -0,0 +1,368 @@ +import React, { + type HTMLAttributes, + type ReactNode, + type RefObject, + createContext, + forwardRef, + startTransition, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +import type { PointRef } from 'slate'; + +import { + Combobox, + ComboboxItem, + type ComboboxItemProps, + ComboboxPopover, + ComboboxProvider, + Portal, + useComboboxContext, + useComboboxStore, +} from '@ariakit/react'; +import { cn } from '@udecode/cn'; +import { + type UseComboboxInputResult, + filterWords, + useComboboxInput, + useHTMLInputCursorState, +} from '@udecode/plate-combobox'; +import { + type TElement, + createPointRef, + findNodePath, + getPointBefore, + insertText, + moveSelection, + useComposedRef, + useEditorRef, +} from '@udecode/plate-common'; +import { cva } from 'class-variance-authority'; + +type FilterFn = ( + item: { keywords?: string[]; value: string }, + search: string +) => boolean; + +interface InlineComboboxContextValue { + filter: FilterFn | false; + inputProps: UseComboboxInputResult['props']; + inputRef: RefObject; + removeInput: UseComboboxInputResult['removeInput']; + setHasEmpty: (hasEmpty: boolean) => void; + showTrigger: boolean; + trigger: string; +} + +const InlineComboboxContext = createContext( + null as any +); + +export const defaultFilter: FilterFn = ({ keywords = [], value }, search) => + [value, ...keywords].some((keyword) => filterWords(keyword, search)); + +interface InlineComboboxProps { + children: ReactNode; + element: TElement; + trigger: string; + filter?: FilterFn | false; + hideWhenNoValue?: boolean; + setValue?: (value: string) => void; + showTrigger?: boolean; + value?: string; +} + +const InlineCombobox = ({ + children, + element, + filter = defaultFilter, + hideWhenNoValue = false, + setValue: setValueProp, + showTrigger = true, + trigger, + value: valueProp, +}: InlineComboboxProps) => { + const editor = useEditorRef(); + const inputRef = React.useRef(null); + const cursorState = useHTMLInputCursorState(inputRef); + + const [valueState, setValueState] = useState(''); + const hasValueProp = valueProp !== undefined; + const value = hasValueProp ? valueProp : valueState; + + const setValue = useCallback( + (newValue: string) => { + setValueProp?.(newValue); + + if (!hasValueProp) { + setValueState(newValue); + } + }, + [setValueProp, hasValueProp] + ); + + /** + * Track the point just before the input element so we know where to + * insertText if the combobox closes due to a selection change. + */ + const [insertPoint, setInsertPoint] = useState(null); + + useEffect(() => { + const path = findNodePath(editor, element); + + if (!path) return; + + const point = getPointBefore(editor, path); + + if (!point) return; + + const pointRef = createPointRef(editor, point); + setInsertPoint(pointRef); + + return () => { + pointRef.unref(); + }; + }, [editor, element]); + + const { props: inputProps, removeInput } = useComboboxInput({ + cancelInputOnBlur: false, + cursorState, + onCancelInput: (cause) => { + if (cause !== 'backspace') { + insertText(editor, trigger + value, { + at: insertPoint?.current ?? undefined, + }); + } + if (cause === 'arrowLeft' || cause === 'arrowRight') { + moveSelection(editor, { + distance: 1, + reverse: cause === 'arrowLeft', + }); + } + }, + ref: inputRef, + }); + + const [hasEmpty, setHasEmpty] = useState(false); + + const contextValue: InlineComboboxContextValue = useMemo( + () => ({ + filter, + inputProps, + inputRef, + removeInput, + setHasEmpty, + showTrigger, + trigger, + }), + [ + trigger, + showTrigger, + filter, + inputRef, + inputProps, + removeInput, + setHasEmpty, + ] + ); + + const store = useComboboxStore({ + // open: , + setValue: (newValue) => startTransition(() => setValue(newValue)), + }); + + const items = store.useState('items'); + + useEffect; + + /** + * If there is no active ID and the list of items changes, select the first + * item. + */ + useEffect(() => { + if (!store.getState().activeId) { + store.setActiveId(store.first()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, store]); + + return ( + + 0 || hasEmpty) && + (!hideWhenNoValue || value.length > 0) + } + store={store} + > + + {children} + + + + ); +}; + +const InlineComboboxInput = forwardRef< + HTMLInputElement, + HTMLAttributes +>(({ className, ...props }, propRef) => { + const { + inputProps, + inputRef: contextRef, + showTrigger, + trigger, + } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + const value = store.useState('value'); + + const ref = useComposedRef(propRef, contextRef); + + /** + * To create an auto-resizing input, we render a visually hidden span + * containing the input value and position the input element on top of it. + * This works well for all cases except when input exceeds the width of the + * container. + */ + + return ( + <> + {showTrigger && trigger} + + + + + + + + ); +}); + +InlineComboboxInput.displayName = 'InlineComboboxInput'; + +const InlineComboboxContent: typeof ComboboxPopover = ({ + className, + ...props +}) => { + // Portal prevents CSS from leaking into popover + return ( + + + + ); +}; + +const comboboxItemVariants = cva( + 'relative flex h-9 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none', + { + defaultVariants: { + interactive: true, + }, + variants: { + interactive: { + false: '', + true: 'cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground', + }, + }, + } +); + +export type InlineComboboxItemProps = { + keywords?: string[]; +} & ComboboxItemProps & + Required>; + +const InlineComboboxItem = ({ + className, + keywords, + onClick, + ...props +}: InlineComboboxItemProps) => { + const { value } = props; + + const { filter, removeInput } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + + // Optimization: Do not subscribe to value if filter is false + const search = filter && store.useState('value'); + + const visible = useMemo( + () => !filter || filter({ keywords, value }, search as string), + [filter, value, keywords, search] + ); + + if (!visible) return null; + + return ( + { + removeInput(true); + onClick?.(event); + }} + {...props} + /> + ); +}; + +const InlineComboboxEmpty = ({ + children, + className, +}: HTMLAttributes) => { + const { setHasEmpty } = useContext(InlineComboboxContext); + const store = useComboboxContext()!; + const items = store.useState('items'); + + useEffect(() => { + setHasEmpty(true); + + return () => { + setHasEmpty(false); + }; + }, [setHasEmpty]); + + if (items.length > 0) return null; + + return ( +
+ {children} +
+ ); +}; + +export { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +}; diff --git a/apps/www/src/registry/default/plate-ui/mention-combobox.tsx b/apps/www/src/registry/default/plate-ui/mention-combobox.tsx deleted file mode 100644 index abc3d46165..0000000000 --- a/apps/www/src/registry/default/plate-ui/mention-combobox.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import type { ComboboxProps } from '@udecode/plate-combobox'; - -import { getPluginOptions, useEditorRef } from '@udecode/plate-common'; -import { - ELEMENT_MENTION, - type MentionPlugin, - getMentionOnSelectItem, -} from '@udecode/plate-mention'; - -import { Combobox } from './combobox'; - -export function MentionCombobox({ - pluginKey = ELEMENT_MENTION, - id = pluginKey, - ...props -}: { - pluginKey?: string; -} & Partial) { - const editor = useEditorRef(); - - const { trigger } = getPluginOptions(editor, pluginKey); - - return ( -
e.preventDefault()}> - -
- ); -} diff --git a/apps/www/src/registry/default/plate-ui/mention-input-element.tsx b/apps/www/src/registry/default/plate-ui/mention-input-element.tsx index be1cce9515..124f5dcba8 100644 --- a/apps/www/src/registry/default/plate-ui/mention-input-element.tsx +++ b/apps/www/src/registry/default/plate-ui/mention-input-element.tsx @@ -1,34 +1,66 @@ -import React from 'react'; +import React, { useState } from 'react'; import { cn, withRef } from '@udecode/cn'; -import { PlateElement, getHandler } from '@udecode/plate-common'; -import { useFocused, useSelected } from 'slate-react'; +import { PlateElement } from '@udecode/plate-common'; +import { getMentionOnSelectItem } from '@udecode/plate-mention'; -export const MentionInputElement = withRef< - typeof PlateElement, - { - onClick?: (mentionNode: any) => void; +import { MENTIONABLES } from '@/lib/plate/demo/values/mentionables'; + +import { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +} from './inline-combobox'; + +const onSelectItem = getMentionOnSelectItem(); + +export const MentionInputElement = withRef( + ({ className, ...props }, ref) => { + const { children, editor, element } = props; + const [search, setSearch] = useState(''); + + return ( + + + + + + + + No results found + + {MENTIONABLES.map((item) => ( + onSelectItem(editor, item, search)} + value={item.text} + > + {item.text} + + ))} + + + + {children} + + ); } ->(({ className, onClick, ...props }, ref) => { - const { children, element } = props; - - const selected = useSelected(); - const focused = useFocused(); - - return ( - - {children} - - ); -}); +); diff --git a/apps/www/src/registry/default/plate-ui/slash-combobox.tsx b/apps/www/src/registry/default/plate-ui/slash-combobox.tsx deleted file mode 100644 index b8cf23c4a3..0000000000 --- a/apps/www/src/registry/default/plate-ui/slash-combobox.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import type { ComboboxProps } from '@udecode/plate-combobox'; - -import { getPluginOptions, useEditorRef } from '@udecode/plate-common'; -import { - KEY_SLASH_COMMAND, - type SlashPlugin, - getSlashOnSelectItem, -} from '@udecode/plate-slash-command'; - -import { Combobox } from './combobox'; - -export function SlashCombobox({ - pluginKey = KEY_SLASH_COMMAND, - id = pluginKey, - ...props -}: { - pluginKey?: string; -} & Partial) { - const editor = useEditorRef(); - - const { trigger } = getPluginOptions(editor, pluginKey); - - return ( -
e.preventDefault()}> - -
- ); -} diff --git a/apps/www/src/registry/default/plate-ui/slash-input-element.tsx b/apps/www/src/registry/default/plate-ui/slash-input-element.tsx index 2990410e6b..819b1a1565 100644 --- a/apps/www/src/registry/default/plate-ui/slash-input-element.tsx +++ b/apps/www/src/registry/default/plate-ui/slash-input-element.tsx @@ -1,34 +1,110 @@ -import React from 'react'; +import React, { type ComponentType, type SVGProps } from 'react'; -import { cn, withRef } from '@udecode/cn'; -import { PlateElement, getHandler } from '@udecode/plate-common'; -import { useFocused, useSelected } from 'slate-react'; +import { withRef } from '@udecode/cn'; +import { + type PlateEditor, + PlateElement, + toggleNodeType, +} from '@udecode/plate-common'; +import { ELEMENT_H1, ELEMENT_H2, ELEMENT_H3 } from '@udecode/plate-heading'; +import { ListStyleType, toggleIndentList } from '@udecode/plate-indent-list'; -export const SlashInputElement = withRef< - typeof PlateElement, +import { Icons } from '@/components/icons'; + +import { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +} from './inline-combobox'; + +interface SlashCommandRule { + icon: ComponentType>; + onSelect: (editor: PlateEditor) => void; + value: string; + keywords?: string[]; +} + +const rules: SlashCommandRule[] = [ + { + icon: Icons.h1, + onSelect: (editor) => { + toggleNodeType(editor, { activeType: ELEMENT_H1 }); + }, + value: 'Heading 1', + }, + { + icon: Icons.h2, + onSelect: (editor) => { + toggleNodeType(editor, { activeType: ELEMENT_H2 }); + }, + value: 'Heading 2', + }, + { + icon: Icons.h3, + onSelect: (editor) => { + toggleNodeType(editor, { activeType: ELEMENT_H3 }); + }, + value: 'Heading 3', + }, { - onClick?: (slashNode: any) => void; + icon: Icons.ul, + keywords: ['ul', 'unordered list'], + onSelect: (editor) => { + toggleIndentList(editor, { + listStyleType: ListStyleType.Disc, + }); + }, + value: 'Bulleted list', + }, + { + icon: Icons.ol, + keywords: ['ol', 'ordered list'], + onSelect: (editor) => { + toggleIndentList(editor, { + listStyleType: ListStyleType.Decimal, + }); + }, + value: 'Numbered list', + }, +]; + +export const SlashInputElement = withRef( + ({ className, ...props }, ref) => { + const { children, editor, element } = props; + + return ( + + + + + + + No matching commands found + + + {rules.map(({ icon: Icon, keywords, onSelect, value }) => ( + onSelect(editor)} + value={value} + > + + {value} + + ))} + + + + {children} + + ); } ->(({ className, onClick, ...props }, ref) => { - const { children, element } = props; - - const selected = useSelected(); - const focused = useFocused(); - - return ( - - /{children} - - ); -}); +); diff --git a/apps/www/src/registry/registry.ts b/apps/www/src/registry/registry.ts index 2f17db69af..71494c3d84 100644 --- a/apps/www/src/registry/registry.ts +++ b/apps/www/src/registry/registry.ts @@ -105,6 +105,13 @@ const ui: Registry = [ type: 'components:plate-ui', }, + { + dependencies: ['@udecode/plate-emoji'], + files: ['plate-ui/emoji-input-element.tsx'], + name: 'emoji-input-element', + registryDependencies: ['inline-combobox'], + type: 'components:plate-ui', + }, { dependencies: ['@udecode/plate-alignment'], files: ['plate-ui/align-dropdown-menu.tsx'], @@ -168,17 +175,6 @@ const ui: Registry = [ registryDependencies: [], type: 'components:plate-ui', }, - { - dependencies: [ - '@radix-ui/react-popover', - '@udecode/plate-combobox', - '@udecode/plate-floating', - ], - files: ['plate-ui/combobox.tsx'], - name: 'combobox', - registryDependencies: [], - type: 'components:plate-ui', - }, { dependencies: ['cmdk'], files: ['plate-ui/command.tsx'], @@ -222,13 +218,6 @@ const ui: Registry = [ type: 'components:plate-ui', }, - { - dependencies: ['@udecode/plate-combobox'], - files: ['plate-ui/emoji-combobox.tsx'], - name: 'emoji-combobox', - registryDependencies: [], - type: 'components:plate-ui', - }, { dependencies: ['@udecode/plate-excalidraw'], files: ['plate-ui/excalidraw-element.tsx'], @@ -316,6 +305,13 @@ const ui: Registry = [ registryDependencies: ['toolbar'], type: 'components:plate-ui', }, + { + dependencies: ['@ariakit/react', '@udecode/plate-combobox'], + files: ['plate-ui/inline-combobox.tsx'], + name: 'inline-combobox', + registryDependencies: [], + type: 'components:plate-ui', + }, { dependencies: [], files: ['plate-ui/input.tsx'], @@ -416,10 +412,10 @@ const ui: Registry = [ type: 'components:plate-ui', }, { - dependencies: ['@udecode/plate-mention', '@udecode/plate-combobox'], - files: ['plate-ui/mention-combobox.tsx'], - name: 'mention-combobox', - registryDependencies: ['combobox'], + dependencies: ['@udecode/plate-mention'], + files: ['plate-ui/mention-element.tsx'], + name: 'mention-element', + registryDependencies: [], type: 'components:plate-ui', }, { @@ -433,7 +429,7 @@ const ui: Registry = [ dependencies: ['@udecode/plate-mention'], files: ['plate-ui/mention-input-element.tsx'], name: 'mention-input-element', - registryDependencies: [], + registryDependencies: ['inline-combobox'], type: 'components:plate-ui', }, { @@ -492,6 +488,13 @@ const ui: Registry = [ registryDependencies: [], type: 'components:plate-ui', }, + { + dependencies: ['@udecode/plate-heading', '@udecode/plate-indent-list'], + files: ['plate-ui/slash-input-element.tsx'], + name: 'slash-input-element', + registryDependencies: ['inline-combobox'], + type: 'components:plate-ui', + }, { dependencies: ['@udecode/plate-table'], files: ['plate-ui/table-cell-element.tsx'], diff --git a/packages/combobox/src/combobox.store.ts b/packages/combobox/src/combobox.store.ts deleted file mode 100644 index b0c91c52dd..0000000000 --- a/packages/combobox/src/combobox.store.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Range } from 'slate'; - -import { - type ZustandStateActions, - type ZustandStoreApi, - createZustandStore, -} from '@udecode/plate-common'; - -import type { ComboboxOnSelectItem, NoData, TComboboxItem } from './types'; - -export type ComboboxStateById = { - /** Is opening/closing the combobox controlled by the client. */ - controlled?: boolean; - - /** - * Items filter function by text. - * - * @default (value) => value.text.toLowerCase().startsWith(search.toLowerCase()) - */ - filter?: (search: string) => (item: TComboboxItem) => boolean; - - /** Combobox id. */ - id: string; - - /** - * Max number of items. - * - * @default items.length - */ - maxSuggestions?: number; - - /** Called when an item is selected. */ - onSelectItem: ComboboxOnSelectItem | null; - - /** Regular expression for search, for example to allow whitespace */ - searchPattern?: string; - - /** Sort filtered items before applying maxSuggestions. */ - sort?: ( - search: string - ) => (a: TComboboxItem, b: TComboboxItem) => number; - - /** Trigger that activates the combobox. */ - trigger: string; -}; - -export type ComboboxStoreById = ZustandStoreApi< - string, - ComboboxStateById, - ZustandStateActions> ->; - -export type ComboboxState = { - /** Active id (combobox id which is opened). */ - activeId: null | string; - - /** - * Object whose keys are combobox ids and values are config stores (e.g. one - * for tag, one for mention,...). - */ - byId: Record; - - /** Filtered items */ - filteredItems: TComboboxItem[]; - - /** Highlighted index. */ - highlightedIndex: number; - - /** Unfiltered items. */ - items: TComboboxItem[]; - - /** Range from the trigger to the cursor. */ - targetRange: Range | null; - - /** Text after the trigger. */ - text: null | string; -}; - -const createComboboxStore = (state: ComboboxStateById) => - createZustandStore(`combobox-${state.id}`)(state); - -export const comboboxStore = createZustandStore('combobox')({ - activeId: null, - byId: {}, - filteredItems: [], - highlightedIndex: 0, - items: [], - targetRange: null, - text: null, -}) - .extendActions((set, get) => ({ - open: (state: Pick) => { - set.mergeState(state); - }, - reset: () => { - set.state((draft) => { - draft.activeId = null; - draft.highlightedIndex = 0; - draft.filteredItems = []; - draft.items = []; - draft.text = null; - draft.targetRange = null; - }); - }, - setComboboxById: (state: ComboboxStateById) => { - if (get.byId()[state.id]) return; - - set.state((draft) => { - draft.byId[state.id] = createComboboxStore( - state as unknown as ComboboxStateById - ); - }); - }, - })) - .extendSelectors((state) => ({ - isOpen: () => !!state.activeId, - })); - -export const useComboboxSelectors = comboboxStore.use; - -export const comboboxSelectors = comboboxStore.get; - -export const comboboxActions = comboboxStore.set; - -export const getComboboxStoreById = (id: null | string) => - id ? comboboxSelectors.byId()[id] : null; - -export const useActiveComboboxStore = () => { - const activeId = useComboboxSelectors.activeId(); - const comboboxes = useComboboxSelectors.byId(); - - return activeId ? comboboxes[activeId] : null; -}; diff --git a/packages/combobox/src/createComboboxPlugin.ts b/packages/combobox/src/createComboboxPlugin.ts deleted file mode 100644 index edf0155576..0000000000 --- a/packages/combobox/src/createComboboxPlugin.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createPluginFactory } from '@udecode/plate-common'; - -import { onChangeCombobox } from './onChangeCombobox'; -import { onKeyDownCombobox } from './onKeyDownCombobox'; - -export const KEY_COMBOBOX = 'combobox'; - -export const createComboboxPlugin = createPluginFactory({ - handlers: { - onChange: onChangeCombobox, - onKeyDown: onKeyDownCombobox, - }, - key: KEY_COMBOBOX, -}); diff --git a/packages/combobox/src/hooks/index.ts b/packages/combobox/src/hooks/index.ts index 2426758fb2..ff33da9b7e 100644 --- a/packages/combobox/src/hooks/index.ts +++ b/packages/combobox/src/hooks/index.ts @@ -2,6 +2,5 @@ * @file Automatically generated by barrelsby. */ -export * from './useComboboxContent'; -export * from './useComboboxControls'; -export * from './useComboboxItem'; +export * from './useComboboxInput'; +export * from './useHTMLInputCursorState'; diff --git a/packages/combobox/src/hooks/useComboboxContent.ts b/packages/combobox/src/hooks/useComboboxContent.ts deleted file mode 100644 index 446a3981ae..0000000000 --- a/packages/combobox/src/hooks/useComboboxContent.ts +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; - -import type { ComboboxProps } from '../types/ComboboxProps'; - -import { - type ComboboxControls, - type Data, - type NoData, - comboboxActions, - useActiveComboboxStore, - useComboboxSelectors, -} from '..'; - -export type ComboboxContentProps = { - combobox: ComboboxControls; -} & Omit< - ComboboxProps, - | 'controlled' - | 'filter' - | 'id' - | 'maxSuggestions' - | 'onSelectItem' - | 'searchPattern' - | 'sort' - | 'trigger' ->; - -export type ComboboxContentRootProps = { - combobox: ComboboxControls; -} & ComboboxContentProps; - -export const useComboboxContentState = ({ - combobox, - items, -}: ComboboxContentRootProps) => { - const targetRange = useComboboxSelectors.targetRange(); - const activeComboboxStore = useActiveComboboxStore()!; - const text = useComboboxSelectors.text() ?? ''; - const storeItems = useComboboxSelectors.items(); - const filter = activeComboboxStore.use.filter?.(); - const sort = activeComboboxStore.use.sort?.(); - const maxSuggestions = - activeComboboxStore.use.maxSuggestions?.() ?? storeItems.length; - - // Update items - React.useEffect(() => { - items && comboboxActions.items(items); - }, [items]); - - // Filter items - React.useEffect(() => { - comboboxActions.filteredItems( - storeItems - .filter( - filter - ? filter(text) - : (value) => value.text.toLowerCase().startsWith(text.toLowerCase()) - ) - .sort(sort?.(text)) - .slice(0, maxSuggestions) - ); - }, [filter, sort, storeItems, maxSuggestions, text]); - - return { - combobox, - targetRange, - }; -}; - -export const useComboboxContent = ( - state: ReturnType -) => { - const menuProps = state.combobox - ? state.combobox.getMenuProps({}, { suppressRefError: true }) - : { ref: null }; - - return { - menuProps, - targetRange: state.targetRange, - }; -}; diff --git a/packages/combobox/src/hooks/useComboboxControls.ts b/packages/combobox/src/hooks/useComboboxControls.ts deleted file mode 100644 index ac56285a2a..0000000000 --- a/packages/combobox/src/hooks/useComboboxControls.ts +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import { useCombobox } from 'downshift'; - -import { useComboboxSelectors } from '../combobox.store'; - -export type ComboboxControls = ReturnType; - -export const useComboboxControls = () => { - const isOpen = useComboboxSelectors.isOpen(); - const highlightedIndex = useComboboxSelectors.highlightedIndex(); - const filteredItems = useComboboxSelectors.filteredItems(); - - const { - closeMenu, - getComboboxProps, - getInputProps, - getItemProps, - getMenuProps, - } = useCombobox({ - circularNavigation: true, - highlightedIndex, - isOpen, - items: filteredItems, - }); - getMenuProps({}, { suppressRefError: true }); - getComboboxProps({}, { suppressRefError: true }); - getInputProps({}, { suppressRefError: true }); - - return React.useMemo( - () => ({ - closeMenu, - getItemProps, - getMenuProps, - }), - [closeMenu, getItemProps, getMenuProps] - ); -}; diff --git a/packages/combobox/src/hooks/useComboboxInput.ts b/packages/combobox/src/hooks/useComboboxInput.ts new file mode 100644 index 0000000000..92a81f747e --- /dev/null +++ b/packages/combobox/src/hooks/useComboboxInput.ts @@ -0,0 +1,159 @@ +import { + type HTMLAttributes, + type RefObject, + useCallback, + useEffect, + useRef, +} from 'react'; + +import { + findNodePath, + focusEditor, + useEditorRef, + useElement, +} from '@udecode/plate-common'; +import { Hotkeys, isHotkey, removeNodes } from '@udecode/plate-common/server'; +import { useSelected } from 'slate-react'; + +import type { + CancelComboboxInputCause, + ComboboxInputCursorState, +} from '../types'; + +export interface UseComboboxInputOptions { + ref: RefObject; + autoFocus?: boolean; + cancelInputOnArrowLeftRight?: boolean; + cancelInputOnBackspace?: boolean; + cancelInputOnBlur?: boolean; + cancelInputOnDeselect?: boolean; + cancelInputOnEscape?: boolean; + cursorState?: ComboboxInputCursorState; + forwardUndoRedoToEditor?: boolean; + onCancelInput?: (cause: CancelComboboxInputCause) => void; +} + +export interface UseComboboxInputResult { + cancelInput: ( + cause?: CancelComboboxInputCause, + focusEditor?: boolean + ) => void; + props: Required, 'onBlur' | 'onKeyDown'>>; + removeInput: (focusEditor?: boolean) => void; +} + +export const useComboboxInput = ({ + autoFocus = true, + cancelInputOnArrowLeftRight = true, + cancelInputOnBackspace = true, + cancelInputOnBlur = true, + cancelInputOnDeselect = true, + cancelInputOnEscape = true, + cursorState, + forwardUndoRedoToEditor = true, + onCancelInput, + ref, +}: UseComboboxInputOptions): UseComboboxInputResult => { + const editor = useEditorRef(); + const element = useElement(); + const selected = useSelected(); + + const cursorAtStart = cursorState?.atStart ?? false; + const cursorAtEnd = cursorState?.atEnd ?? false; + + const removeInput = useCallback( + (shouldFocusEditor = false) => { + const path = findNodePath(editor, element); + + if (!path) return; + + removeNodes(editor, { at: path }); + + if (shouldFocusEditor) { + focusEditor(editor); + } + }, + [editor, element] + ); + + const cancelInput = useCallback( + (cause: CancelComboboxInputCause = 'manual', shouldFocusEditor = false) => { + removeInput(shouldFocusEditor); + onCancelInput?.(cause); + }, + [onCancelInput, removeInput] + ); + + /** + * Using autoFocus on the input element causes an error: Cannot resolve a + * Slate node from DOM node: [object HTMLSpanElement] + */ + useEffect(() => { + if (autoFocus) { + ref.current?.focus(); + } + }, [autoFocus, ref]); + + /** + * Storing the previous selection lets us determine whether the input has been + * actively deselected. When undoing or redoing causes a combobox input to be + * inserted, selected can be temporarily false. Removing the input at this + * point is incorrect and crashes the editor. + */ + const previousSelected = useRef(selected); + + useEffect(() => { + if (previousSelected.current && !selected && cancelInputOnDeselect) { + cancelInput('deselect'); + } + + previousSelected.current = selected; + }, [selected, cancelInputOnDeselect, cancelInput]); + + return { + cancelInput, + props: { + onBlur: () => { + if (cancelInputOnBlur) { + cancelInput('blur'); + } + }, + onKeyDown: (event) => { + if (cancelInputOnEscape && isHotkey('escape', event)) { + cancelInput('escape', true); + } + if ( + cancelInputOnBackspace && + cursorAtStart && + isHotkey('backspace', event) + ) { + cancelInput('backspace', true); + } + if ( + cancelInputOnArrowLeftRight && + cursorAtStart && + isHotkey('arrowleft', event) + ) { + cancelInput('arrowLeft', true); + } + if ( + cancelInputOnArrowLeftRight && + cursorAtEnd && + isHotkey('arrowright', event) + ) { + cancelInput('arrowRight', true); + } + + const isUndo = Hotkeys.isUndo(event) && editor.history.undos.length > 0; + const isRedo = Hotkeys.isRedo(event) && editor.history.redos.length > 0; + + if (forwardUndoRedoToEditor && (isUndo || isRedo)) { + event.preventDefault(); + editor[isUndo ? 'undo' : 'redo'](); + focusEditor(editor); + } + }, + }, + removeInput, + }; +}; diff --git a/packages/combobox/src/hooks/useComboboxItem.tsx b/packages/combobox/src/hooks/useComboboxItem.tsx deleted file mode 100644 index 5073df175c..0000000000 --- a/packages/combobox/src/hooks/useComboboxItem.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useEditorRef } from '@udecode/plate-common'; - -import type { ComboboxContentProps } from './useComboboxContent'; - -import { - type ComboboxControls, - type Data, - type NoData, - type TComboboxItem, - comboboxSelectors, - getComboboxStoreById, - useComboboxSelectors, -} from '..'; - -export type ComboboxContentItemProps = { - combobox: ComboboxControls; - index: number; - item: TComboboxItem; -} & Pick, 'onRenderItem'>; - -export interface ComboboxItemProps { - item: TComboboxItem; - search: string; -} - -export const useComboboxItem = ({ - combobox, - index, - item, - onRenderItem, -}: ComboboxContentItemProps) => { - const editor = useEditorRef(); - const text = useComboboxSelectors.text() ?? ''; - const highlightedIndex = useComboboxSelectors.highlightedIndex(); - - const Item = onRenderItem - ? onRenderItem({ item: item as TComboboxItem, search: text }) - : item.text; - - const highlighted = index === highlightedIndex; - - return { - props: { - 'data-highlighted': highlighted, - ...combobox.getItemProps({ - index, - item, - }), - children: Item, - onMouseDown: (e: React.MouseEvent) => { - e.preventDefault(); - - const onSelectItem = getComboboxStoreById( - comboboxSelectors.activeId() - )?.get.onSelectItem(); - onSelectItem?.(editor, item); - }, - }, - }; -}; diff --git a/packages/combobox/src/hooks/useHTMLInputCursorState.ts b/packages/combobox/src/hooks/useHTMLInputCursorState.ts new file mode 100644 index 0000000000..24d6cfd473 --- /dev/null +++ b/packages/combobox/src/hooks/useHTMLInputCursorState.ts @@ -0,0 +1,41 @@ +import { type RefObject, useCallback, useEffect, useState } from 'react'; + +import type { ComboboxInputCursorState } from '../types'; + +export const useHTMLInputCursorState = ( + ref: RefObject +): ComboboxInputCursorState => { + const [cursorState, setCursorState] = useState({ + atEnd: false, + atStart: false, + }); + + const recomputeCursorState = useCallback(() => { + if (!ref.current) return; + + const { selectionEnd, selectionStart, value } = ref.current; + + setCursorState({ + atEnd: selectionEnd === value.length, + atStart: selectionStart === 0, + }); + }, [ref]); + + useEffect(() => { + recomputeCursorState(); + + const input = ref.current; + + if (!input) return; + + input.addEventListener('input', recomputeCursorState); + input.addEventListener('selectionchange', recomputeCursorState); + + return () => { + input.removeEventListener('input', recomputeCursorState); + input.removeEventListener('selectionchange', recomputeCursorState); + }; + }, [recomputeCursorState, ref]); + + return cursorState; +}; diff --git a/packages/combobox/src/index.ts b/packages/combobox/src/index.ts index a198a8b6e5..1ec186e6b6 100644 --- a/packages/combobox/src/index.ts +++ b/packages/combobox/src/index.ts @@ -2,10 +2,7 @@ * @file Automatically generated by barrelsby. */ -export * from './combobox.store'; -export * from './createComboboxPlugin'; -export * from './onChangeCombobox'; -export * from './onKeyDownCombobox'; +export * from './types'; +export * from './withTriggerCombobox'; export * from './hooks/index'; -export * from './types/index'; export * from './utils/index'; diff --git a/packages/combobox/src/onChangeCombobox.spec.tsx b/packages/combobox/src/onChangeCombobox.spec.tsx deleted file mode 100644 index 10ec16426f..0000000000 --- a/packages/combobox/src/onChangeCombobox.spec.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** @jsx jsx */ - -import { - type HandlerReturnType, - createPlateEditor, -} from '@udecode/plate-common'; -import { getMentionOnSelectItem } from '@udecode/plate-mention'; -import { createParagraphPlugin } from '@udecode/plate-paragraph'; -import { jsx } from '@udecode/plate-test-utils'; - -import { - type ComboboxState, - comboboxActions, - comboboxSelectors, -} from './combobox.store'; -import { createComboboxPlugin } from './createComboboxPlugin'; -import { onChangeCombobox } from './onChangeCombobox'; - -jsx; - -describe('onChangeCombobox', () => { - const createEditor = (state: React.ReactElement) => { - const plugins = [createParagraphPlugin(), createComboboxPlugin()]; - - return createPlateEditor({ - editor: ({state}) as any, - plugins, - }); - }; - - const onChange = (fragment: React.ReactElement): HandlerReturnType => { - return onChangeCombobox(createEditor(fragment))(); - }; - - const createCombobox = ({ - controlled = false, - trigger = '@', - id = trigger, - searchPattern = '\\S+', - }: { - controlled?: boolean; - id?: string; - searchPattern?: string; - trigger?: string; - } = {}) => - comboboxActions.setComboboxById({ - controlled, - id, - onSelectItem: getMentionOnSelectItem({ key: id }), - searchPattern, - trigger, - }); - - beforeEach(() => { - comboboxActions.byId({}); - comboboxActions.reset(); - }); - - it('should open the combobox if the text after trigger matches pattern', () => { - createCombobox(); - - onChange( - - @hello - - - ); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: expect.anything(), - text: 'hello', - }); - }); - - it('should not open the combobox if the combobox is controlled', () => { - createCombobox({ controlled: true }); - - onChange( - - @hello - - - ); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: null, - }); - }); - - it('should not alter the state of a controlled combobox', () => { - const id = 'controlled'; - - createCombobox({ controlled: true, id }); - - comboboxActions.open({ - activeId: id, - targetRange: null, - text: '', - }); - - onChange( - - - - ); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: id, - }); - }); - - it('should handle a mix of controlled and uncontrolled comboboxes', () => { - createCombobox({ controlled: true, trigger: '@' }); - createCombobox({ controlled: false, trigger: '#' }); - - onChange( - - #hello - - - ); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: expect.anything(), - text: 'hello', - }); - }); -}); diff --git a/packages/combobox/src/onChangeCombobox.ts b/packages/combobox/src/onChangeCombobox.ts deleted file mode 100644 index 876b405941..0000000000 --- a/packages/combobox/src/onChangeCombobox.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - type PlateEditor, - type Value, - isCollapsed, -} from '@udecode/plate-common'; -import { Range } from 'slate'; - -import { comboboxActions, comboboxSelectors } from './combobox.store'; -import { getTextFromTrigger } from './utils/getTextFromTrigger'; - -/** - * For each combobox state (byId): - * - * - If the selection is collapsed - * - If the cursor follows the trigger - * - If there is text without whitespaces after the trigger - * - Open the combobox: set id, search, targetRange in the store Close the - * combobox if needed - */ -export const onChangeCombobox = - = PlateEditor>( - editor: E - ) => - () => { - const byId = comboboxSelectors.byId(); - const activeId = comboboxSelectors.activeId(); - - let shouldClose = true; - - for (const store of Object.values(byId)) { - const id = store.get.id(); - const controlled = store.get.controlled?.(); - - if (controlled) { - // do not close controlled comboboxes - if (activeId === id) { - shouldClose = false; - - break; - } else { - // do not open controlled comboboxes - continue; - } - } - - const { selection } = editor; - - if (!selection || !isCollapsed(selection)) { - continue; - } - - const trigger = store.get.trigger(); - const searchPattern = store.get.searchPattern?.(); - - const isCursorAfterTrigger = getTextFromTrigger(editor, { - at: Range.start(selection), - searchPattern, - trigger, - }); - - if (!isCursorAfterTrigger) { - continue; - } - - const { range, textAfterTrigger } = isCursorAfterTrigger; - - comboboxActions.open({ - activeId: id, - targetRange: range, - text: textAfterTrigger, - }); - - shouldClose = false; - - break; - } - - if (shouldClose && comboboxSelectors.isOpen()) { - comboboxActions.reset(); - } - }; diff --git a/packages/combobox/src/onKeyDownCombobox.ts b/packages/combobox/src/onKeyDownCombobox.ts deleted file mode 100644 index f82a8a490e..0000000000 --- a/packages/combobox/src/onKeyDownCombobox.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - Hotkeys, - type KeyboardHandlerReturnType, - type PlateEditor, - type Value, - isHotkey, -} from '@udecode/plate-common'; - -import { - comboboxActions, - comboboxSelectors, - getComboboxStoreById, -} from './combobox.store'; -import { getNextWrappingIndex } from './utils/getNextWrappingIndex'; - -/** - * If the combobox is open, handle: - * - * - Down (next item) - * - Up (previous item) - * - Escape (reset combobox) - * - Tab, enter (select item) - */ -export const onKeyDownCombobox = - = PlateEditor>( - editor: E - ): KeyboardHandlerReturnType => - (event) => { - const { activeId, filteredItems, highlightedIndex } = - comboboxSelectors.state(); - const isOpen = comboboxSelectors.isOpen(); - - if (!isOpen) return; - - const store = getComboboxStoreById(activeId); - - if (!store) return; - - const onSelectItem = store.get.onSelectItem(); - - if (isHotkey('down', event)) { - event.preventDefault(); - - const newIndex = getNextWrappingIndex( - 1, - highlightedIndex, - filteredItems.length, - () => {}, - true - ); - comboboxActions.highlightedIndex(newIndex); - - return; - } - if (isHotkey('up', event)) { - event.preventDefault(); - - const newIndex = getNextWrappingIndex( - -1, - highlightedIndex, - filteredItems.length, - () => {}, - true - ); - comboboxActions.highlightedIndex(newIndex); - - return; - } - if (isHotkey('escape', event)) { - event.preventDefault(); - comboboxActions.reset(); - - return; - } - if (Hotkeys.isTab(editor, event) || isHotkey('enter', event)) { - event.preventDefault(); - event.stopPropagation(); - - if (filteredItems[highlightedIndex]) { - onSelectItem?.(editor, filteredItems[highlightedIndex]); - } - } - }; diff --git a/packages/combobox/src/types.ts b/packages/combobox/src/types.ts new file mode 100644 index 0000000000..b38b4f45a4 --- /dev/null +++ b/packages/combobox/src/types.ts @@ -0,0 +1,22 @@ +import type { PlateEditor, TElement } from '@udecode/plate-common/server'; + +export interface TriggerComboboxPlugin { + createComboboxInput?: (trigger: string) => TElement; + trigger?: RegExp | string | string[]; + triggerPreviousCharPattern?: RegExp; + triggerQuery?: (editor: PlateEditor) => boolean; +} + +export type ComboboxInputCursorState = { + atEnd: boolean; + atStart: boolean; +}; + +export type CancelComboboxInputCause = + | 'arrowLeft' + | 'arrowRight' + | 'backspace' + | 'blur' + | 'deselect' + | 'escape' + | 'manual'; diff --git a/packages/combobox/src/types/ComboboxOnSelectItem.ts b/packages/combobox/src/types/ComboboxOnSelectItem.ts deleted file mode 100644 index 6d00b046d1..0000000000 --- a/packages/combobox/src/types/ComboboxOnSelectItem.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PlateEditor, Value } from '@udecode/plate-common'; - -export interface TComboboxItemBase { - /** Unique key. */ - key: string; - - /** Item text. */ - text: any; - - /** - * Whether the item is disabled. - * - * @default false - */ - disabled?: boolean; -} - -export interface TComboboxItemWithData - extends TComboboxItemBase { - /** Data available to `onRenderItem`. */ - data: TData; -} - -export type NoData = undefined; - -export type Data = unknown; - -export type TComboboxItem = TData extends NoData - ? TComboboxItemBase - : TComboboxItemWithData; - -export type ComboboxOnSelectItem = ( - editor: PlateEditor, - item: TComboboxItem -) => any; diff --git a/packages/combobox/src/types/ComboboxProps.ts b/packages/combobox/src/types/ComboboxProps.ts deleted file mode 100644 index db50c6810d..0000000000 --- a/packages/combobox/src/types/ComboboxProps.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type React from 'react'; - -import type { - ComboboxItemProps, - ComboboxState, - ComboboxStateById, - ComboboxStoreById, - NoData, -} from '..'; - -export interface ComboboxProps - extends Partial, 'items'>>, - ComboboxStateById { - /** Render this component when the combobox is open (useful to inject hooks). */ - component?: React.FC<{ store: ComboboxStoreById }>; - - /** - * Whether to hide the combobox. - * - * @default !items.length - */ - disabled?: boolean; - - /** - * Render combobox item. - * - * @default text - */ - onRenderItem?: React.FC>; - - portalElement?: HTMLElement; -} diff --git a/packages/combobox/src/types/index.ts b/packages/combobox/src/types/index.ts deleted file mode 100644 index 2c540c4dd6..0000000000 --- a/packages/combobox/src/types/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './ComboboxOnSelectItem'; -export * from './ComboboxProps'; diff --git a/packages/combobox/src/utils/filterWords.spec.ts b/packages/combobox/src/utils/filterWords.spec.ts new file mode 100644 index 0000000000..09ad706710 --- /dev/null +++ b/packages/combobox/src/utils/filterWords.spec.ts @@ -0,0 +1,71 @@ +import { type FilterWordsOptions, filterWords } from './filterWords'; + +describe('filterWords', () => { + describe('with default options', () => { + describe('single word', () => { + it('matches simple prefix', () => { + expect(filterWords('hello', 'he')).toBe(true); + }); + + it('does not match non-prefix', () => { + expect(filterWords('hello', 'lo')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(filterWords('hello', 'HE')).toBe(true); + }); + + it('is diacritic-insensitive', () => { + expect(filterWords('hello', 'hé')).toBe(true); + }); + }); + + describe('multiple words', () => { + it('matches when all words in query match', () => { + expect(filterWords('hello world', 'world hello')).toBe(true); + expect(filterWords('hello world', 'world')).toBe(true); + }); + + it('does not match when not all words in query match', () => { + expect(filterWords('hello world', 'hello other')).toBe(false); + }); + + it('allows prefix for last word', () => { + expect(filterWords('hello world', 'world he')).toBe(true); + }); + + it('does not allow prefix for non-last word', () => { + expect(filterWords('hello world', 'wor hello')).toBe(false); + }); + }); + }); + + describe('with prefix mode disabled', () => { + const options: FilterWordsOptions = { prefixMode: 'none' }; + + it('only matches whole words', () => { + expect(filterWords('hello world', 'wor', options)).toBe(false); + expect(filterWords('hello world', 'world', options)).toBe(true); + }); + }); + + describe('with prefix mode set to all words', () => { + const options: FilterWordsOptions = { prefixMode: 'all-words' }; + + it('allows prefix for all words', () => { + expect(filterWords('hello world', 'wor hel', options)).toBe(true); + }); + }); + + describe('with word quantifier set to match any', () => { + const options: FilterWordsOptions = { wordQuantifier: 'match-any' }; + + it('matches when any word in query matches', () => { + expect(filterWords('hello world', 'other hello', options)).toBe(true); + }); + + it('does not match when no word in query matches', () => { + expect(filterWords('hello world', 'other other', options)).toBe(false); + }); + }); +}); diff --git a/packages/combobox/src/utils/filterWords.ts b/packages/combobox/src/utils/filterWords.ts new file mode 100644 index 0000000000..1a592bd662 --- /dev/null +++ b/packages/combobox/src/utils/filterWords.ts @@ -0,0 +1,49 @@ +export interface FilterWordsOptions { + prefixMode?: 'all-words' | 'last-word' | 'none'; + wordBoundary?: RegExp; + wordQuantifier?: 'match-all' | 'match-any'; +} + +export const filterWords = ( + haystack: string, + needle: string, + { + prefixMode = 'last-word', + wordBoundary = /\s+/, + wordQuantifier = 'match-all', + }: FilterWordsOptions = {} +): boolean => { + const haystackWords = haystack.trim().split(wordBoundary); + const needleWords = needle.trim().split(wordBoundary); + + const quantifier = wordQuantifier === 'match-all' ? 'every' : 'some'; + + return needleWords[quantifier]((needleWord, i) => { + const allowPrefix = (() => { + switch (prefixMode) { + case 'none': { + return false; + } + case 'all-words': { + return true; + } + case 'last-word': { + return i === needleWords.length - 1; + } + } + })(); + + return haystackWords.some((unslicedHaystackWord) => { + const haystackWord = allowPrefix + ? unslicedHaystackWord.slice(0, needleWord.length) + : unslicedHaystackWord; + + return ( + haystackWord.localeCompare(needleWord, undefined, { + sensitivity: 'base', + usage: 'search', + }) === 0 + ); + }); + }); +}; diff --git a/packages/combobox/src/utils/getNextNonDisabledIndex.ts b/packages/combobox/src/utils/getNextNonDisabledIndex.ts deleted file mode 100644 index 1f6b8f9e43..0000000000 --- a/packages/combobox/src/utils/getNextNonDisabledIndex.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Returns the next index in the list of an item that is not disabled. - * - * @param {number} moveAmount Number of positions to move. Negative to move - * backwards, positive forwards. - * @param {number} baseIndex The initial position to move from. - * @param {number} itemCount The total number of items. - * @param {Function} getItemNodeFromIndex Used to check if item is disabled. - * @param {boolean} circular Specify if navigation is circular. Default is true. - * @returns {number} The new index. Returns baseIndex if item is not disabled. - * Returns next non-disabled item otherwise. If no non-disabled found it will - * return -1. - */ -export const getNextNonDisabledIndex = ( - moveAmount: number, - baseIndex: number, - itemCount: number, - getItemNodeFromIndex: any, - circular: boolean -): number => { - const currentElementNode = getItemNodeFromIndex(baseIndex); - - if (!currentElementNode?.hasAttribute('disabled')) { - return baseIndex; - } - if (moveAmount > 0) { - for (let index = baseIndex + 1; index < itemCount; index++) { - if (!getItemNodeFromIndex(index).hasAttribute('disabled')) { - return index; - } - } - } else { - for (let index = baseIndex - 1; index >= 0; index--) { - if (!getItemNodeFromIndex(index).hasAttribute('disabled')) { - return index; - } - } - } - if (circular) { - return moveAmount > 0 - ? getNextNonDisabledIndex(1, 0, itemCount, getItemNodeFromIndex, false) - : getNextNonDisabledIndex( - -1, - itemCount - 1, - itemCount, - getItemNodeFromIndex, - false - ); - } - - return -1; -}; diff --git a/packages/combobox/src/utils/getNextWrappingIndex.ts b/packages/combobox/src/utils/getNextWrappingIndex.ts deleted file mode 100644 index 4371497ac5..0000000000 --- a/packages/combobox/src/utils/getNextWrappingIndex.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getNextNonDisabledIndex } from './getNextNonDisabledIndex'; - -/** - * Returns the new index in the list, in a circular way. If next value is out of - * bonds from the total, it will wrap to either 0 or itemCount - 1. - * - * @param {number} moveAmount Number of positions to move. Negative to move - * backwards, positive forwards. - * @param {number} baseIndex The initial position to move from. - * @param {number} itemCount The total number of items. - * @param {Function} getItemNodeFromIndex Used to check if item is disabled. - * @param {boolean} circular Specify if navigation is circular. Default is true. - * @returns {number} The new index after the move. - */ -export const getNextWrappingIndex = ( - moveAmount: number, - baseIndex: number, - itemCount: number, - getItemNodeFromIndex: any, - circular = true -) => { - if (itemCount === 0) { - return -1; - } - - const itemsLastIndex = itemCount - 1; - - // noinspection SuspiciousTypeOfGuard - if ( - typeof baseIndex !== 'number' || - baseIndex < 0 || - baseIndex >= itemCount - ) { - baseIndex = moveAmount > 0 ? -1 : itemsLastIndex + 1; - } - - let newIndex = baseIndex + moveAmount; - - if (newIndex < 0) { - newIndex = circular ? itemsLastIndex : 0; - } else if (newIndex > itemsLastIndex) { - newIndex = circular ? 0 : itemsLastIndex; - } - - const nonDisabledNewIndex = getNextNonDisabledIndex( - moveAmount, - newIndex, - itemCount, - getItemNodeFromIndex, - circular - ); - - if (nonDisabledNewIndex === -1) { - return baseIndex >= itemCount ? -1 : baseIndex; - } - - return nonDisabledNewIndex; -}; diff --git a/packages/combobox/src/utils/getTextFromTrigger.ts b/packages/combobox/src/utils/getTextFromTrigger.ts deleted file mode 100644 index f62eaadcd6..0000000000 --- a/packages/combobox/src/utils/getTextFromTrigger.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Point } from 'slate'; - -import { - type TEditor, - type Value, - escapeRegExp, - getEditorString, - getPointBefore, - getRange, -} from '@udecode/plate-common'; - -/** - * Get text and range from trigger to cursor. Starts with trigger and ends with - * non-whitespace character. - */ -export const getTextFromTrigger = ( - editor: TEditor, - { - at, - searchPattern = `\\S+`, - trigger, - }: { at: Point; searchPattern?: string; trigger: string } -) => { - const escapedTrigger = escapeRegExp(trigger); - const triggerRegex = new RegExp(`(?:^|\\s)${escapedTrigger}`); - - let start: Point | undefined = at; - let end: Point | undefined; - - // eslint-disable-next-line no-constant-condition - while (true) { - end = start; - - if (!start) break; - - start = getPointBefore(editor, start); - const charRange = start && getRange(editor, start, end); - const charText = getEditorString(editor, charRange); - - if (!charText.match(searchPattern)) { - start = end; - - break; - } - } - - // Range from start to cursor - const range = start && getRange(editor, start, at); - const text = getEditorString(editor, range); - - if (!range || !text.match(triggerRegex)) return; - - return { - range, - textAfterTrigger: text.slice(trigger.length), - }; -}; - -// export const matchesTriggerAndPattern = ( -// editor: TEditor, -// { at, trigger, pattern }: { at: Point; trigger: string; pattern: string } -// ) => { -// // Point at the start of line -// const lineStart = getPointBefore(editor, at, { unit: 'line' }); -// -// // Range from before to start -// const beforeRange = lineStart && getRange(editor, lineStart, at); -// -// // Before text -// const beforeText = getEditorString(editor, beforeRange); -// -// // Starts with char and ends with word characters -// const escapedTrigger = escapeRegExp(trigger); -// -// const beforeRegex = new RegExp(`(?:^|\\s)${escapedTrigger}(${pattern})$`); -// -// // Match regex on before text -// const match = !!beforeText && beforeText.match(beforeRegex); -// -// // Point at the start of mention -// const mentionStart = match -// ? getPointBefore(editor, at, { -// unit: 'character', -// distance: match[1].length + trigger.length, -// }) -// : null; -// -// // Range from mention to start -// const mentionRange = mentionStart && getRange(editor, mentionStart, at); -// -// return { -// range: mentionRange, -// match, -// }; -// }; diff --git a/packages/combobox/src/utils/index.ts b/packages/combobox/src/utils/index.ts index 0390c0ab42..7afc6fa598 100644 --- a/packages/combobox/src/utils/index.ts +++ b/packages/combobox/src/utils/index.ts @@ -2,6 +2,4 @@ * @file Automatically generated by barrelsby. */ -export * from './getNextNonDisabledIndex'; -export * from './getNextWrappingIndex'; -export * from './getTextFromTrigger'; +export * from './filterWords'; diff --git a/packages/combobox/src/withTriggerCombobox.spec.tsx b/packages/combobox/src/withTriggerCombobox.spec.tsx new file mode 100644 index 0000000000..f766acb592 --- /dev/null +++ b/packages/combobox/src/withTriggerCombobox.spec.tsx @@ -0,0 +1,222 @@ +/** @jsx jsx */ + +import { + type TriggerComboboxPlugin, + withTriggerCombobox, +} from '@udecode/plate-combobox'; +import { createPlateEditor, createPluginFactory } from '@udecode/plate-common'; +import { createParagraphPlugin } from '@udecode/plate-paragraph'; +import { jsx } from '@udecode/plate-test-utils'; + +const createExampleComboboxPlugin = createPluginFactory({ + key: 'exampleCombobox', + plugins: [ + { + isElement: true, + isInline: true, + isVoid: true, + key: 'mention_input', + }, + ], + withOverrides: withTriggerCombobox, +}); + +const plugins = [ + createParagraphPlugin(), + + createExampleComboboxPlugin({ + key: 'exampleCombobox1', + options: { + createComboboxInput: (trigger) => ({ + children: [{ text: '' }], + trigger, + type: 'mention_input', + }), + trigger: ['@', '#'], + triggerPreviousCharPattern: /^$|^[\s"']$/, + }, + }), + + createExampleComboboxPlugin({ + key: 'exampleCombobox2', + options: { + createComboboxInput: () => ({ + children: [{ text: '' }], + trigger: ':', + type: 'mention_input', + }), + trigger: ':', + triggerPreviousCharPattern: /^\s?$/, + }, + }), +]; + +const createEditorWithCombobox = (chidren: any) => + createPlateEditor({ + editor: ({chidren}) as any, + plugins, + }); + +jsx; + +describe('withTriggerCombobox', () => { + ['@', '#', ':'].forEach((trigger) => { + describe(`when typing "${trigger}"`, () => { + it('should insert a combobox input when the trigger is inserted between words', () => { + const editor = createEditorWithCombobox( + + hello world + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + hello + + + + + world + , + ]); + }); + + it('should insert a combobox input when the trigger is inserted at line beginning followed by a whitespace', () => { + const editor = createEditorWithCombobox( + + hello world + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + + + + + + hello world + , + ]); + }); + + it('should insert a combobox input when the trigger is inserted at line end preceded by a whitespace', () => { + const editor = createEditorWithCombobox( + + hello world + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + hello world + + + + + + , + ]); + }); + + it('should insert the trigger as text when the trigger is appended to a word', () => { + const editor = createEditorWithCombobox( + + hello + + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + hello{trigger} + + , + ]); + }); + + it('should insert a combobox input when the trigger is prepended to a word', () => { + const editor = createEditorWithCombobox( + + + hello + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + + + + + + hello + , + ]); + }); + + it('should insert the trigger as text when the trigger is inserted into a word', () => { + const editor = createEditorWithCombobox( + + hel + + lo + + ); + + editor.insertText(trigger); + + expect(editor.children).toEqual([ + + hel{trigger} + + lo + , + ]); + }); + }); + }); + + it('should insert text when not trigger', () => { + const editor = createEditorWithCombobox( + + + + ); + + editor.insertText('a'); + + expect(editor.children).toEqual([a]); + }); + + it('should insert a combobox input when the trigger is inserted after the specified pattern', () => { + const editor = createEditorWithCombobox( + + hello "" + + ); + + editor.insertText('@'); + + expect(editor.children).toEqual([ + + hello " + + + + + " + , + ]); + }); +}); diff --git a/packages/combobox/src/withTriggerCombobox.ts b/packages/combobox/src/withTriggerCombobox.ts new file mode 100644 index 0000000000..d112c4614e --- /dev/null +++ b/packages/combobox/src/withTriggerCombobox.ts @@ -0,0 +1,75 @@ +import { + type PlateEditor, + type TElement, + type Value, + type WithPlatePlugin, + getEditorString, + getPointBefore, + getRange, +} from '@udecode/plate-common/server'; + +import type { TriggerComboboxPlugin } from './types'; + +export const withTriggerCombobox = < + V extends Value = Value, + E extends PlateEditor = PlateEditor, +>( + editor: E, + { + options: { + createComboboxInput, + trigger, + triggerPreviousCharPattern, + triggerQuery, + }, + type, + }: WithPlatePlugin +) => { + const { insertText } = editor; + + const matchesTrigger = (text: string) => { + if (trigger instanceof RegExp) { + return trigger.test(text); + } + if (Array.isArray(trigger)) { + return trigger.includes(text); + } + + return text === trigger; + }; + + editor.insertText = (text) => { + if ( + !editor.selection || + !matchesTrigger(text) || + (triggerQuery && !triggerQuery(editor as PlateEditor)) + ) { + return insertText(text); + } + + // Make sure an input is created at the beginning of line or after a whitespace + const previousChar = getEditorString( + editor, + getRange( + editor, + editor.selection, + getPointBefore(editor, editor.selection) + ) + ); + + const matchesPreviousCharPattern = + triggerPreviousCharPattern?.test(previousChar); + + if (matchesPreviousCharPattern) { + const inputNode: TElement = createComboboxInput + ? createComboboxInput(text) + : { children: [{ text: '' }], type }; + + return editor.insertNode(inputNode); + } + + return insertText(text); + }; + + return editor; +}; diff --git a/packages/emoji/src/constants.ts b/packages/emoji/src/constants.ts index 09cfcb00fc..4e29780a05 100644 --- a/packages/emoji/src/constants.ts +++ b/packages/emoji/src/constants.ts @@ -1,7 +1,4 @@ -import type { - EmojiTriggeringControllerOptions, - FrequentEmojis, -} from './utils/index'; +import type { FrequentEmojis } from './utils/index'; import { EmojiCategory, @@ -10,18 +7,8 @@ import { type i18nProps, } from './types'; -export const KEY_EMOJI = 'emoji'; - -export const EMOJI_TRIGGER = ':'; - export const EMOJI_MAX_SEARCH_RESULT = 60; -export const emojiTriggeringControllerOptions: EmojiTriggeringControllerOptions = - { - limitTriggeringChars: 2, - trigger: EMOJI_TRIGGER, - }; - export const defaultCategories: EmojiCategoryList[] = [ EmojiCategory.People, EmojiCategory.Nature, diff --git a/packages/emoji/src/createEmojiPlugin.ts b/packages/emoji/src/createEmojiPlugin.ts index 38e6c34f6f..5e267346ed 100644 --- a/packages/emoji/src/createEmojiPlugin.ts +++ b/packages/emoji/src/createEmojiPlugin.ts @@ -1,28 +1,30 @@ +import { withTriggerCombobox } from '@udecode/plate-combobox'; import { createPluginFactory } from '@udecode/plate-common/server'; import type { EmojiPlugin } from './types'; -import { EMOJI_TRIGGER, KEY_EMOJI } from './constants'; -import { EmojiTriggeringController } from './utils/index'; -import { withEmoji } from './withEmoji'; +export const KEY_EMOJI = 'emoji'; + +export const ELEMENT_EMOJI_INPUT = 'emoji_input'; export const createEmojiPlugin = createPluginFactory({ key: KEY_EMOJI, options: { - createEmoji: (item) => item.data.emoji, - emojiTriggeringController: new EmojiTriggeringController(), - trigger: EMOJI_TRIGGER, + createComboboxInput: () => ({ + children: [{ text: '' }], + type: ELEMENT_EMOJI_INPUT, + }), + createEmojiNode: ({ skins }) => ({ text: skins[0].native }), + trigger: ':', + triggerPreviousCharPattern: /^\s?$/, }, - then: ( - _, - { key, options: { createEmoji, emojiTriggeringController, trigger } } - ) => ({ - options: { - createEmoji, - emojiTriggeringController, - id: key, - trigger, + plugins: [ + { + isElement: true, + isInline: true, + isVoid: true, + key: ELEMENT_EMOJI_INPUT, }, - }), - withOverrides: withEmoji, + ], + withOverrides: withTriggerCombobox, }); diff --git a/packages/emoji/src/handlers/getEmojiOnInsert.ts b/packages/emoji/src/handlers/getEmojiOnInsert.ts deleted file mode 100644 index d7a5a28fa8..0000000000 --- a/packages/emoji/src/handlers/getEmojiOnInsert.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ComboboxOnSelectItem } from '@udecode/plate-combobox'; - -import { focusEditor } from '@udecode/plate-common'; -import { - type PlatePluginKey, - getPlugin, - insertText, - withoutNormalizing, -} from '@udecode/plate-common/server'; - -import type { EmojiItemData, EmojiPlugin } from '../types'; - -import { KEY_EMOJI } from '../constants'; - -export const getEmojiOnInsert = - ({ - key = KEY_EMOJI, - }: PlatePluginKey = {}): ComboboxOnSelectItem => - (editor, item) => { - const { - options: { createEmoji }, - } = getPlugin(editor as any, key); - - withoutNormalizing(editor, () => { - focusEditor(editor); - - const value = createEmoji!(item); - insertText(editor, value); - }); - }; diff --git a/packages/emoji/src/handlers/getEmojiOnSelectItem.ts b/packages/emoji/src/handlers/getEmojiOnSelectItem.ts deleted file mode 100644 index 020c371d37..0000000000 --- a/packages/emoji/src/handlers/getEmojiOnSelectItem.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - type ComboboxOnSelectItem, - comboboxActions, -} from '@udecode/plate-combobox'; -import { - type PlatePluginKey, - deleteText, - getPlugin, - insertText, - withoutMergingHistory, - withoutNormalizing, -} from '@udecode/plate-common/server'; - -import type { EmojiItemData, EmojiPlugin } from '../types'; - -import { KEY_EMOJI } from '../constants'; - -export const getEmojiOnSelectItem = - ({ - key = KEY_EMOJI, - }: PlatePluginKey = {}): ComboboxOnSelectItem => - (editor, item) => { - const { - options: { createEmoji, emojiTriggeringController }, - } = getPlugin(editor as any, key); - - withoutNormalizing(editor, () => { - withoutMergingHistory(editor, () => - deleteText(editor, { - distance: emojiTriggeringController! - .setIsTriggering(false) - .getTextSize(), - reverse: true, - }) - ); - - const value = createEmoji!(item); - insertText(editor, value); - }); - - return comboboxActions.reset(); - }; diff --git a/packages/emoji/src/handlers/getFindTriggeringInput.ts b/packages/emoji/src/handlers/getFindTriggeringInput.ts deleted file mode 100644 index 83d2352210..0000000000 --- a/packages/emoji/src/handlers/getFindTriggeringInput.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { BasePoint } from 'slate'; - -import { - type PlateEditor, - type Value, - getEditorString, - getPointBefore, - getRange, - isCollapsed, -} from '@udecode/plate-common/server'; - -import type { FindTriggeringInputProps } from '../types'; -import type { IEmojiTriggeringController } from '../utils/index'; - -const isSpaceBreak = (char?: string) => !!char && /\s/.test(char); - -const getPreviousChar = ( - editor: PlateEditor, - point?: BasePoint -) => - point - ? getEditorString( - editor, - getRange(editor, point, getPointBefore(editor, point)) - ) - : undefined; - -const getPreviousPoint = ( - editor: PlateEditor, - point?: BasePoint -) => (point ? getPointBefore(editor, point) : undefined); - -const isBeginningOfTheLine = ( - editor: PlateEditor, - point?: BasePoint -) => { - const previousPoint = getPreviousPoint(editor, point); - - return point?.path[0] !== previousPoint?.path[0]; -}; - -export const getFindTriggeringInput = - ( - editor: PlateEditor, - emojiTriggeringController: IEmojiTriggeringController - ) => - ({ action = 'insert', char = '' }: FindTriggeringInputProps = {}) => { - const { selection } = editor; - - if (!selection || !isCollapsed(selection) || isSpaceBreak(char)) { - emojiTriggeringController.setIsTriggering(false); - - return; - } - - const startPoint = selection.anchor; - let currentPoint: BasePoint | undefined = startPoint; - let previousPoint; - - let foundText = char; - let previousChar; - - do { - previousChar = getPreviousChar(editor, currentPoint); - foundText = previousChar + foundText; - previousPoint = getPreviousPoint(editor, currentPoint); - - if (isBeginningOfTheLine(editor, currentPoint)) { - break; - } - - currentPoint = previousPoint; - } while (!isSpaceBreak(previousChar)); - - foundText = foundText.trim(); - - if (action === 'delete') foundText = foundText.slice(0, -1); - - emojiTriggeringController.setText(foundText); - }; diff --git a/packages/emoji/src/handlers/index.ts b/packages/emoji/src/handlers/index.ts deleted file mode 100644 index 917060413f..0000000000 --- a/packages/emoji/src/handlers/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './getEmojiOnInsert'; -export * from './getEmojiOnSelectItem'; -export * from './getFindTriggeringInput'; diff --git a/packages/emoji/src/hooks/index.ts b/packages/emoji/src/hooks/index.ts index 1d07c2922d..0710d2f3b4 100644 --- a/packages/emoji/src/hooks/index.ts +++ b/packages/emoji/src/hooks/index.ts @@ -2,5 +2,4 @@ * @file Automatically generated by barrelsby. */ -export * from './useEmojiCombobox'; export * from './useEmojiDropdownMenuState'; diff --git a/packages/emoji/src/hooks/useEmojiCombobox.ts b/packages/emoji/src/hooks/useEmojiCombobox.ts deleted file mode 100644 index dd44f61d80..0000000000 --- a/packages/emoji/src/hooks/useEmojiCombobox.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { - ComboboxOnSelectItem, - ComboboxProps, - Data, - NoData, -} from '@udecode/plate-combobox'; - -import { useEditorRef } from '@udecode/plate-common'; -import { getPluginOptions } from '@udecode/plate-common/server'; - -import { type EmojiPlugin, getEmojiOnSelectItem } from '../index'; - -export interface TEmojiCombobox - extends Partial> { - onSelectItem?: ComboboxOnSelectItem | null; - pluginKey?: string; -} - -export const useEmojiComboboxState = ({ pluginKey }: { pluginKey: string }) => { - const editor = useEditorRef(); - const { trigger } = getPluginOptions(editor, pluginKey); - - const onSelectItem = getEmojiOnSelectItem({ key: pluginKey }); - - return { - onSelectItem, - trigger: trigger!, - }; -}; diff --git a/packages/emoji/src/index.ts b/packages/emoji/src/index.ts index 63d6376e12..fe2b1aa226 100644 --- a/packages/emoji/src/index.ts +++ b/packages/emoji/src/index.ts @@ -5,8 +5,6 @@ export * from './constants'; export * from './createEmojiPlugin'; export * from './types'; -export * from './withEmoji'; -export * from './handlers/index'; export * from './hooks/index'; export * from './storage/index'; export * from './utils/index'; diff --git a/packages/emoji/src/types.ts b/packages/emoji/src/types.ts index 3ff72de5a4..c35e87f9d3 100644 --- a/packages/emoji/src/types.ts +++ b/packages/emoji/src/types.ts @@ -1,6 +1,6 @@ -import type { TComboboxItem } from '@udecode/plate-combobox'; - -import type { IEmojiTriggeringController } from './utils/index'; +import type { Emoji } from '@emoji-mart/data'; +import type { TriggerComboboxPlugin } from '@udecode/plate-combobox'; +import type { EElementOrText } from '@udecode/plate-common'; type ReverseMap = T[keyof T]; @@ -22,22 +22,9 @@ export type EmojiSettingsType = { }; }; -export type EmojiItemData = { - emoji: string; - id: string; - name: string; - text: string; -}; - -export type CreateEmoji = ( - data: TComboboxItem -) => string; - -export interface EmojiPlugin { - createEmoji?: CreateEmoji; - emojiTriggeringController?: IEmojiTriggeringController; - id?: string; - trigger?: string; +export interface EmojiPlugin + extends TriggerComboboxPlugin { + createEmojiNode?: (emoji: TEmoji) => EElementOrText; } export const EmojiCategory = { @@ -73,8 +60,3 @@ export type EmojiIconList = { loupe: T; }; }; - -export type FindTriggeringInputProps = { - action?: 'delete' | 'insert'; - char?: string; -}; diff --git a/packages/emoji/src/utils/EmojiLibrary/EmojiLibrary.types.ts b/packages/emoji/src/utils/EmojiLibrary/EmojiLibrary.types.ts index a7f43fc59d..975a0a98cd 100644 --- a/packages/emoji/src/utils/EmojiLibrary/EmojiLibrary.types.ts +++ b/packages/emoji/src/utils/EmojiLibrary/EmojiLibrary.types.ts @@ -1,32 +1,17 @@ +import type { Emoji, EmojiMartData } from '@emoji-mart/data'; /** * Emoji: type Emoji = { id: string; name: string; keywords: string[]; skins: [ * { unified: '1f389'; native: '🎉'; shortcodes: ':tada:'; } ]; version: 1; }; */ -type Skin = { - native: string; - unified: string; -}; - -export type Emoji = { - id: string; - keywords: string[]; - name: string; - skins: Skin[]; - version: number; -}; - export type Emojis = Record; -export type EmojiLibrary = { - aliases: any; - categories: any[]; - emojis: Emojis; - sheet: any; -}; +export type EmojiLibrary = EmojiMartData; export interface IEmojiLibrary { getEmoji: (key: string) => Emoji; getEmojiId: (key: string) => string; keys: string[]; } + +export { type Emoji } from '@emoji-mart/data'; diff --git a/packages/emoji/src/utils/EmojiPicker/useEmojiPicker.ts b/packages/emoji/src/utils/EmojiPicker/useEmojiPicker.ts index 6fe74e9484..f3b372147e 100644 --- a/packages/emoji/src/utils/EmojiPicker/useEmojiPicker.ts +++ b/packages/emoji/src/utils/EmojiPicker/useEmojiPicker.ts @@ -12,11 +12,11 @@ import type { Emoji, IEmojiFloatingLibrary } from '../EmojiLibrary/index'; import type { AIndexSearch } from '../IndexSearch/index'; import { i18n } from '../../constants'; -import { getEmojiOnInsert } from '../../handlers/getEmojiOnInsert'; import { type SetFocusedAndVisibleSectionsType, observeCategories, } from '../EmojiObserver'; +import { insertEmoji } from '../insertEmoji'; import { EmojiPickerState, type MapEmojiCategoryList, @@ -30,7 +30,7 @@ export type MutableRefs = React.MutableRefObject<{ export type UseEmojiPickerProps = { closeOnSelect: boolean; emojiLibrary: IEmojiFloatingLibrary; - indexSearch: AIndexSearch; + indexSearch: AIndexSearch; }; export type UseEmojiPickerType< @@ -153,18 +153,7 @@ export const useEmojiPicker = ({ const onSelectEmoji = React.useCallback( (emoji: Emoji) => { - const selectItem = getEmojiOnInsert(); - selectItem(editor, { - data: { - emoji: emoji.skins[0].native, - id: emoji.id, - name: emoji.name, - text: emoji.name, - }, - key: emoji.id, - text: emoji.name, - }); - + insertEmoji(editor, emoji); updateFrequentEmojis(emoji.id); }, [editor, updateFrequentEmojis] diff --git a/packages/emoji/src/utils/EmojiTriggeringController.ts b/packages/emoji/src/utils/EmojiTriggeringController.ts deleted file mode 100644 index eb464a619b..0000000000 --- a/packages/emoji/src/utils/EmojiTriggeringController.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { emojiTriggeringControllerOptions } from '../index'; - -export type EmojiTriggeringControllerOptions = { - limitTriggeringChars: number; - trigger: string; -}; - -export interface IEmojiTriggeringController { - getText: () => string; - getTextSize: () => number; - hasEnclosingTriggeringMark: () => boolean; - hasTriggeringMark: boolean; - isTriggering: boolean; - reset: () => this; - setIsTriggering: (isTriggering: boolean) => this; - setText: (text: string) => this; -} - -export class EmojiTriggeringController implements IEmojiTriggeringController { - private _hasTriggeringMark = false; - private _isTriggering = false; - protected pos: any; - protected text = ''; - - constructor( - protected options: EmojiTriggeringControllerOptions = emojiTriggeringControllerOptions - ) {} - - private endsWithEnclosingMark(text: string) { - return new RegExp(`${this.options.trigger}$`).test(text); - } - - private startsWithTriggeringMark(text: string) { - return new RegExp(`^${this.options.trigger}`).test(text); - } - - getText() { - return this.text.replaceAll(/^:|:$/g, ''); - } - - getTextSize() { - return this.text.length; - } - - hasEnclosingTriggeringMark(): boolean { - return this.endsWithEnclosingMark(this.text); - } - - reset() { - this.text = ''; - this.setIsTriggering(false); - this._hasTriggeringMark = false; - - return this; - } - - setIsTriggering(isTriggering: boolean) { - this._isTriggering = isTriggering; - - return this; - } - - setText(text: string) { - this._hasTriggeringMark = this.startsWithTriggeringMark(text); - - this.setIsTriggering( - this._hasTriggeringMark && text.length > this.options.limitTriggeringChars - ); - - this.text = this.isTriggering ? text : ''; - - return this; - } - - get hasTriggeringMark(): boolean { - return this._hasTriggeringMark; - } - - get isTriggering(): boolean { - return this._isTriggering; - } -} diff --git a/packages/emoji/src/utils/IndexSearch/EmojiFloatingIndexSearch.ts b/packages/emoji/src/utils/IndexSearch/EmojiFloatingIndexSearch.ts index 12fdad0e60..3e17577b64 100644 --- a/packages/emoji/src/utils/IndexSearch/EmojiFloatingIndexSearch.ts +++ b/packages/emoji/src/utils/IndexSearch/EmojiFloatingIndexSearch.ts @@ -1,8 +1,8 @@ -import type { Emoji, IEmojiLibrary } from '../EmojiLibrary/index'; +import type { IEmojiLibrary } from '../EmojiLibrary/index'; import { AIndexSearch } from './IndexSearch'; -export class EmojiFloatingIndexSearch extends AIndexSearch { +export class EmojiFloatingIndexSearch extends AIndexSearch { protected static instance?: EmojiFloatingIndexSearch; private constructor(protected library: IEmojiLibrary) { @@ -16,8 +16,4 @@ export class EmojiFloatingIndexSearch extends AIndexSearch { return EmojiFloatingIndexSearch.instance; } - - protected transform(emoji: Emoji) { - return emoji; - } } diff --git a/packages/emoji/src/utils/IndexSearch/EmojiInlineIndexSearch.ts b/packages/emoji/src/utils/IndexSearch/EmojiInlineIndexSearch.ts index 8e5a71cc1d..f4c99f25b1 100644 --- a/packages/emoji/src/utils/IndexSearch/EmojiInlineIndexSearch.ts +++ b/packages/emoji/src/utils/IndexSearch/EmojiInlineIndexSearch.ts @@ -1,8 +1,4 @@ -import { - type Emoji, - EmojiInlineLibrary, - type IEmojiLibrary, -} from '../EmojiLibrary/index'; +import { EmojiInlineLibrary, type IEmojiLibrary } from '../EmojiLibrary/index'; import { AIndexSearch } from './IndexSearch'; export class EmojiInlineIndexSearch extends AIndexSearch { @@ -21,19 +17,4 @@ export class EmojiInlineIndexSearch extends AIndexSearch { return EmojiInlineIndexSearch.instance; } - - protected transform(emoji: Emoji) { - const { id, name, skins } = emoji; - - return { - data: { - emoji: skins[0].native, - id, - name, - text: name, - }, - key: id, - text: name, - }; - } } diff --git a/packages/emoji/src/utils/IndexSearch/IndexSearch.ts b/packages/emoji/src/utils/IndexSearch/IndexSearch.ts index ba038b4fa3..1643217789 100644 --- a/packages/emoji/src/utils/IndexSearch/IndexSearch.ts +++ b/packages/emoji/src/utils/IndexSearch/IndexSearch.ts @@ -1,21 +1,16 @@ -import type { TComboboxItem } from '@udecode/plate-combobox'; +import type { Emoji } from '@emoji-mart/data'; -import type { EmojiItemData } from '../../types'; -import type { Emoji, IEmojiLibrary } from '../EmojiLibrary/index'; +import type { IEmojiLibrary } from '../EmojiLibrary/index'; import { EMOJI_MAX_SEARCH_RESULT } from '../../constants'; -type IndexSearchReturnData = TComboboxItem; - -interface IIndexSearch { - get: () => R[]; +interface IIndexSearch { + get: () => Emoji[]; hasFound: () => boolean; search: (input: string) => void; } -export abstract class AIndexSearch - implements IIndexSearch -{ +export abstract class AIndexSearch implements IIndexSearch { protected input: string | undefined; protected maxResult = EMOJI_MAX_SEARCH_RESULT; protected result: string[] = []; @@ -58,7 +53,7 @@ export abstract class AIndexSearch for (const key of this.result) { const emoji = this.library?.getEmoji(key); - emojis.push(this.transform(emoji!)); + emojis.push(emoji); if (emojis.length >= this.maxResult) break; } @@ -66,7 +61,7 @@ export abstract class AIndexSearch return emojis; } - getEmoji(): RData | undefined { + getEmoji(): Emoji | undefined { return this.get()[0]; } @@ -92,6 +87,4 @@ export abstract class AIndexSearch return this; } - - protected abstract transform(emoji: Emoji): RData; } diff --git a/packages/emoji/src/utils/index.ts b/packages/emoji/src/utils/index.ts index 220ede92cd..79da4e494d 100644 --- a/packages/emoji/src/utils/index.ts +++ b/packages/emoji/src/utils/index.ts @@ -2,7 +2,7 @@ * @file Automatically generated by barrelsby. */ -export * from './EmojiTriggeringController'; +export * from './insertEmoji'; export * from './EmojiLibrary/index'; export * from './EmojiPicker/index'; export * from './Grid/index'; diff --git a/packages/emoji/src/utils/insertEmoji.ts b/packages/emoji/src/utils/insertEmoji.ts new file mode 100644 index 0000000000..c101a647a1 --- /dev/null +++ b/packages/emoji/src/utils/insertEmoji.ts @@ -0,0 +1,30 @@ +import type { Emoji } from '@emoji-mart/data'; + +import { + type EElementOrText, + type PlateEditor, + type Value, + getPluginOptions, + insertNodes, +} from '@udecode/plate-common'; + +import type { EmojiPlugin } from '../types'; + +import { KEY_EMOJI } from '../createEmojiPlugin'; + +export const insertEmoji = < + V extends Value = Value, + E extends PlateEditor = PlateEditor, + TEmoji extends Emoji = Emoji, +>( + editor: E, + emoji: TEmoji +) => { + const { createEmojiNode } = getPluginOptions( + editor, + KEY_EMOJI + ); + + const emojiNode = createEmojiNode!(emoji); + insertNodes(editor, emojiNode as EElementOrText); +}; diff --git a/packages/emoji/src/withEmoji.ts b/packages/emoji/src/withEmoji.ts deleted file mode 100644 index bf64980a9d..0000000000 --- a/packages/emoji/src/withEmoji.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { comboboxActions } from '@udecode/plate-combobox'; -import { - type PlateEditor, - type Value, - type WithPlatePlugin, - isCollapsed, -} from '@udecode/plate-common/server'; - -import type { EmojiPlugin } from './types'; - -import { getEmojiOnSelectItem, getFindTriggeringInput } from './handlers/index'; -import { EmojiInlineIndexSearch } from './utils/index'; - -export const withEmoji = < - V extends Value = Value, - E extends PlateEditor = PlateEditor, ->( - editor: E, - { - options: { emojiTriggeringController, id }, - }: WithPlatePlugin -) => { - const emojiInlineIndexSearch = EmojiInlineIndexSearch.getInstance(); - - const findTheTriggeringInput = getFindTriggeringInput( - editor, - emojiTriggeringController! - ); - - const { apply, deleteBackward, deleteForward, insertText } = editor; - - editor.insertText = (char) => { - const { selection } = editor; - - if (!isCollapsed(selection)) { - return insertText(char); - } - - findTheTriggeringInput({ char }); - - return insertText(char); - }; - - editor.deleteBackward = (unit) => { - findTheTriggeringInput({ action: 'delete' }); - - return deleteBackward(unit); - }; - - editor.deleteForward = (unit) => { - findTheTriggeringInput(); - - return deleteForward(unit); - }; - - editor.apply = (operation) => { - apply(operation); - - if (!emojiTriggeringController?.hasTriggeringMark) { - return; - } - - const searchText = emojiTriggeringController.getText(); - - switch (operation.type) { - case 'set_selection': { - emojiTriggeringController.reset(); - comboboxActions.reset(); - - break; - } - case 'insert_text': { - if ( - emojiTriggeringController.hasEnclosingTriggeringMark() && - emojiInlineIndexSearch.search(searchText).hasFound(true) - ) { - const item = emojiInlineIndexSearch.getEmoji(); - item && getEmojiOnSelectItem()(editor, item); - - break; - } - if ( - !emojiTriggeringController.hasEnclosingTriggeringMark() && - emojiTriggeringController.isTriggering && - emojiInlineIndexSearch.search(searchText).hasFound() - ) { - comboboxActions.items( - emojiInlineIndexSearch.search(searchText).get() - ); - comboboxActions.open({ - activeId: id!, - targetRange: editor.selection, - text: '', - }); - - break; - } - - emojiTriggeringController.reset(); - comboboxActions.reset(); - - break; - } - case 'remove_text': { - if ( - emojiTriggeringController.isTriggering && - emojiInlineIndexSearch.search(searchText).hasFound() - ) { - comboboxActions.items( - emojiInlineIndexSearch.search(searchText).get() - ); - comboboxActions.open({ - activeId: id!, - targetRange: editor.selection, - text: '', - }); - - break; - } - - emojiTriggeringController.reset(); - comboboxActions.reset(); - - break; - } - } - }; - - return editor; -}; diff --git a/packages/mention/src/__tests__/createEditorWithMentions.tsx b/packages/mention/src/__tests__/createEditorWithMentions.tsx deleted file mode 100644 index b2fe09f902..0000000000 --- a/packages/mention/src/__tests__/createEditorWithMentions.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/** @jsx jsx */ - -import { - type PlateEditor, - type Value, - createPlateEditor, -} from '@udecode/plate-common'; -import { createParagraphPlugin } from '@udecode/plate-paragraph'; -import { jsx } from '@udecode/plate-test-utils'; - -import { createMentionPlugin } from '../createMentionPlugin'; - -jsx; - -export type CreateEditorOptions = { - multipleMentionPlugins?: boolean; - pluginOptions?: { - key?: string; - trigger?: string; - triggerPreviousCharPattern?: RegExp; - }; -}; - -export const createEditorWithMentions = ( - state: React.ReactElement, - { - multipleMentionPlugins, - pluginOptions: { key, trigger, triggerPreviousCharPattern } = {}, - }: CreateEditorOptions = {} -): PlateEditor => { - const plugins = [ - createParagraphPlugin(), - createMentionPlugin({ - key, - options: { trigger, triggerPreviousCharPattern }, - }), - ]; - - if (multipleMentionPlugins) { - plugins.push( - createMentionPlugin({ key: 'mention2', options: { trigger: '#' } }) - ); - } - - return createPlateEditor({ - editor: ({state}) as any, - plugins, - }); -}; diff --git a/packages/mention/src/createMentionPlugin.ts b/packages/mention/src/createMentionPlugin.ts index 38b68ea01f..8be87914e2 100644 --- a/packages/mention/src/createMentionPlugin.ts +++ b/packages/mention/src/createMentionPlugin.ts @@ -1,33 +1,25 @@ -import { createPluginFactory, removeNodes } from '@udecode/plate-common'; +import { withTriggerCombobox } from '@udecode/plate-combobox'; +import { createPluginFactory } from '@udecode/plate-common/server'; import type { MentionPlugin } from './types'; -import { mentionOnKeyDownHandler } from './handlers/mentionOnKeyDownHandler'; -import { isSelectionInMentionInput } from './queries/index'; -import { withMention } from './withMention'; - export const ELEMENT_MENTION = 'mention'; export const ELEMENT_MENTION_INPUT = 'mention_input'; /** Enables support for autocompleting @mentions. */ export const createMentionPlugin = createPluginFactory({ - handlers: { - onBlur: (editor) => () => { - // remove mention_input nodes from editor on blur - removeNodes(editor, { - at: [], - match: (n) => n.type === ELEMENT_MENTION_INPUT, - }); - }, - onKeyDown: mentionOnKeyDownHandler({ query: isSelectionInMentionInput }), - }, isElement: true, isInline: true, isMarkableVoid: true, isVoid: true, key: ELEMENT_MENTION, options: { + createComboboxInput: (trigger) => ({ + children: [{ text: '' }], + trigger, + type: ELEMENT_MENTION_INPUT, + }), createMentionNode: (item) => ({ value: item.text }), trigger: '@', triggerPreviousCharPattern: /^\s?$/, @@ -36,13 +28,9 @@ export const createMentionPlugin = createPluginFactory({ { isElement: true, isInline: true, + isVoid: true, key: ELEMENT_MENTION_INPUT, }, ], - then: (editor, { key }) => ({ - options: { - id: key, - }, - }), - withOverrides: withMention, + withOverrides: withTriggerCombobox, }); diff --git a/packages/mention/src/getMentionOnSelectItem.ts b/packages/mention/src/getMentionOnSelectItem.ts index 8f8078a4fa..9e5925011c 100644 --- a/packages/mention/src/getMentionOnSelectItem.ts +++ b/packages/mention/src/getMentionOnSelectItem.ts @@ -1,89 +1,56 @@ import { - type ComboboxOnSelectItem, - type Data, - type NoData, - type TComboboxItem, - comboboxActions, - comboboxSelectors, -} from '@udecode/plate-combobox'; -import { + type PlateEditor, type PlatePluginKey, - type TNodeProps, + type Value, getBlockAbove, getPlugin, insertNodes, insertText, isEndPoint, moveSelection, - removeNodes, - select, - withoutMergingHistory, - withoutNormalizing, -} from '@udecode/plate-common'; +} from '@udecode/plate-common/server'; -import type { MentionPlugin, TMentionElement } from './types'; +import type { MentionPlugin, TMentionElement, TMentionItemBase } from './types'; import { ELEMENT_MENTION } from './createMentionPlugin'; -import { isNodeMentionInput } from './queries/isNodeMentionInput'; - -export type CreateMentionNode = ( - item: TComboboxItem, - meta: CreateMentionNodeMeta -) => TNodeProps; -export interface CreateMentionNodeMeta { - search: string; -} +export type MentionOnSelectItem< + TItem extends TMentionItemBase = TMentionItemBase, +> = ( + editor: PlateEditor, + item: TItem, + search?: string +) => void; export const getMentionOnSelectItem = - ({ + ({ key = ELEMENT_MENTION, - }: PlatePluginKey = {}): ComboboxOnSelectItem => - (editor, item) => { - const targetRange = comboboxSelectors.targetRange(); - - if (!targetRange) return; - + }: PlatePluginKey = {}): MentionOnSelectItem => + (editor, item, search = '') => { const { options: { createMentionNode, insertSpaceAfterMention }, type, } = getPlugin(editor as any, key); - const pathAbove = getBlockAbove(editor)?.[1]; - const isBlockEnd = () => - editor.selection && - pathAbove && - isEndPoint(editor, editor.selection.anchor, pathAbove); + const props = createMentionNode!(item, search); - withoutNormalizing(editor, () => { - // Selectors are sensitive to operations, it's better to create everything - // before the editor state is changed. For example, asking for text after - // removeNodes below will return null. - const props = createMentionNode!(item, { - search: comboboxSelectors.text() ?? '', - }); - - select(editor, targetRange); - - withoutMergingHistory(editor, () => - removeNodes(editor, { - match: (node) => isNodeMentionInput(editor, node), - }) - ); + insertNodes(editor, { + children: [{ text: '' }], + type, + ...props, + } as TMentionElement); - insertNodes(editor, { - children: [{ text: '' }], - type, - ...props, - } as TMentionElement); + // move the selection after the element + moveSelection(editor, { unit: 'offset' }); - // move the selection after the element - moveSelection(editor, { unit: 'offset' }); + const pathAbove = getBlockAbove(editor)?.[1]; - if (isBlockEnd() && insertSpaceAfterMention) { - insertText(editor, ' '); - } - }); + const isBlockEnd = + editor.selection && + pathAbove && + isEndPoint(editor, editor.selection.anchor, pathAbove); - return comboboxActions.reset(); + if (isBlockEnd && insertSpaceAfterMention) { + insertText(editor, ' '); + } }; diff --git a/packages/mention/src/handlers/index.ts b/packages/mention/src/handlers/index.ts deleted file mode 100644 index 60fbc656c4..0000000000 --- a/packages/mention/src/handlers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './mentionOnKeyDownHandler'; diff --git a/packages/mention/src/handlers/mentionOnKeyDownHandler.spec.tsx b/packages/mention/src/handlers/mentionOnKeyDownHandler.spec.tsx deleted file mode 100644 index ea21d17157..0000000000 --- a/packages/mention/src/handlers/mentionOnKeyDownHandler.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** @jsx jsx */ - -import * as isHotkey from '@udecode/plate-core/server'; -import { jsx } from '@udecode/plate-test-utils'; - -import { createEditorWithMentions } from '../__tests__/createEditorWithMentions'; - -jsx; - -describe('mentionOnKeyDownHandler', () => { - const trigger = '@'; - - it('should remove the input on escape', () => { - const editor = createEditorWithMentions( - - - - - - - , - { pluginOptions: { trigger } } - ); - - jest.spyOn(isHotkey, 'isHotkey').mockReturnValue(true); - - // mentionOnKeyDownHandler({})(editor)( - // new KeyboardEvent('keydown', { key: 'Escape' }) as any - // ); - - // expect(editor.children).toEqual([@]); - expect(editor.children).toEqual(editor.children); - }); -}); diff --git a/packages/mention/src/handlers/mentionOnKeyDownHandler.ts b/packages/mention/src/handlers/mentionOnKeyDownHandler.ts deleted file mode 100644 index 47e5b90d2c..0000000000 --- a/packages/mention/src/handlers/mentionOnKeyDownHandler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - type KeyboardEventHandler, - type MoveSelectionByOffsetOptions, - type PlateEditor, - type Value, - isHotkey, - moveSelection, - moveSelectionByOffset, -} from '@udecode/plate-common'; - -import { findMentionInput } from '../queries/index'; -import { removeMentionInput } from '../transforms/index'; - -export const mentionOnKeyDownHandler: ( - options?: MoveSelectionByOffsetOptions -) => (editor: PlateEditor) => KeyboardEventHandler = - (options) => (editor) => (event) => { - if (isHotkey('escape', event)) { - const currentMentionInput = findMentionInput(editor)!; - - if (currentMentionInput) { - event.preventDefault(); - removeMentionInput(editor, currentMentionInput[1]); - moveSelection(editor, { unit: 'word' }); - - return true; - } - - return false; - } - - return moveSelectionByOffset(editor, options)(event); - }; diff --git a/packages/mention/src/index.ts b/packages/mention/src/index.ts index d1612be983..cf82b60840 100644 --- a/packages/mention/src/index.ts +++ b/packages/mention/src/index.ts @@ -5,7 +5,3 @@ export * from './createMentionPlugin'; export * from './getMentionOnSelectItem'; export * from './types'; -export * from './withMention'; -export * from './handlers/index'; -export * from './queries/index'; -export * from './transforms/index'; diff --git a/packages/mention/src/queries/findMentionInput.ts b/packages/mention/src/queries/findMentionInput.ts deleted file mode 100644 index 80a37df2f1..0000000000 --- a/packages/mention/src/queries/findMentionInput.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - type FindNodeOptions, - type PlateEditor, - type Value, - findNode, - getPluginType, -} from '@udecode/plate-common'; - -import type { TMentionInputElement } from '../types'; - -import { ELEMENT_MENTION_INPUT } from '../createMentionPlugin'; - -export const findMentionInput = ( - editor: PlateEditor, - options?: Omit, 'match'> -) => - findNode(editor, { - ...options, - match: { type: getPluginType(editor, ELEMENT_MENTION_INPUT) }, - }); diff --git a/packages/mention/src/queries/index.ts b/packages/mention/src/queries/index.ts deleted file mode 100644 index 94a16a3a1d..0000000000 --- a/packages/mention/src/queries/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './findMentionInput'; -export * from './isNodeMentionInput'; -export * from './isSelectionInMentionInput'; diff --git a/packages/mention/src/queries/isNodeMentionInput.ts b/packages/mention/src/queries/isNodeMentionInput.ts deleted file mode 100644 index 7424f1e8eb..0000000000 --- a/packages/mention/src/queries/isNodeMentionInput.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - type PlateEditor, - type TNode, - type Value, - getPluginType, -} from '@udecode/plate-common'; - -import type { TMentionInputElement } from '../types'; - -import { ELEMENT_MENTION_INPUT } from '../createMentionPlugin'; - -export const isNodeMentionInput = ( - editor: PlateEditor, - node: TNode -): node is TMentionInputElement => { - return node.type === getPluginType(editor, ELEMENT_MENTION_INPUT); -}; diff --git a/packages/mention/src/queries/isSelectionInMentionInput.ts b/packages/mention/src/queries/isSelectionInMentionInput.ts deleted file mode 100644 index 0848326be1..0000000000 --- a/packages/mention/src/queries/isSelectionInMentionInput.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PlateEditor, Value } from '@udecode/plate-common'; - -import { findMentionInput } from './findMentionInput'; - -export const isSelectionInMentionInput = ( - editor: PlateEditor -) => findMentionInput(editor) !== undefined; diff --git a/packages/mention/src/transforms/index.ts b/packages/mention/src/transforms/index.ts deleted file mode 100644 index ad93e4898f..0000000000 --- a/packages/mention/src/transforms/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './removeMentionInput'; diff --git a/packages/mention/src/transforms/removeMentionInput.ts b/packages/mention/src/transforms/removeMentionInput.ts deleted file mode 100644 index 6fcd288f27..0000000000 --- a/packages/mention/src/transforms/removeMentionInput.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Path } from 'slate'; - -import { - type EText, - type PlateEditor, - type Value, - getNode, - getNodeString, - replaceNode, - withoutNormalizing, -} from '@udecode/plate-common'; - -import type { TMentionInputElement } from '../types'; - -export const removeMentionInput = ( - editor: PlateEditor, - path: Path -) => - withoutNormalizing(editor, () => { - const node = getNode(editor, path); - - if (!node) return; - - const { trigger } = node; - - const text = getNodeString(node); - - replaceNode(editor, { - at: path, - nodes: { text: `${trigger}${text}` } as EText, - }); - }); diff --git a/packages/mention/src/types.ts b/packages/mention/src/types.ts index 6285b8fc8f..0a9dab1ce9 100644 --- a/packages/mention/src/types.ts +++ b/packages/mention/src/types.ts @@ -1,22 +1,24 @@ -import type { Data, NoData } from '@udecode/plate-combobox'; -import type { PlateEditor, TElement } from '@udecode/plate-common'; +import type { TriggerComboboxPlugin } from '@udecode/plate-combobox'; +import type { TElement, TNodeProps } from '@udecode/plate-common/server'; -import type { CreateMentionNode } from './getMentionOnSelectItem'; - -export interface TMentionElement extends TElement { - value: string; +export interface TMentionItemBase { + text: string; } export interface TMentionInputElement extends TElement { trigger: string; } -export interface MentionPlugin { - createMentionNode?: CreateMentionNode; - id?: string; - inputCreation?: { key: string; value: string }; +export interface TMentionElement extends TElement { + value: string; +} + +export interface MentionPlugin< + TItem extends TMentionItemBase = TMentionItemBase, +> extends TriggerComboboxPlugin { + createMentionNode?: ( + item: TItem, + search: string + ) => TNodeProps; insertSpaceAfterMention?: boolean; - query?: (editor: PlateEditor) => boolean; - trigger?: string; - triggerPreviousCharPattern?: RegExp; } diff --git a/packages/mention/src/withMention.spec.tsx b/packages/mention/src/withMention.spec.tsx deleted file mode 100644 index 36aa03aaa4..0000000000 --- a/packages/mention/src/withMention.spec.tsx +++ /dev/null @@ -1,637 +0,0 @@ -/** @jsx jsx */ - -import type { Range } from 'slate'; - -import { - type ComboboxState, - comboboxActions, - comboboxSelectors, -} from '@udecode/plate-combobox'; -import { - type PlateEditor, - type Value, - moveSelection, - select, -} from '@udecode/plate-common'; -import { - type DataTransferDataMap, - createDataTransfer, - jsx, -} from '@udecode/plate-test-utils'; - -import { createEditorWithMentions } from './__tests__/createEditorWithMentions'; -import { getMentionOnSelectItem } from './getMentionOnSelectItem'; - -jsx; - -describe('withMention', () => { - const trigger = '@'; - const key = 'mention'; - - type CreateEditorOptions = { - multipleMentionPlugins?: boolean; - triggerPreviousCharPattern?: RegExp; - }; - - const createEditor = ( - state: React.ReactElement, - options: CreateEditorOptions = {} - ): PlateEditor => - createEditorWithMentions(state, { - ...options, - pluginOptions: { - ...options, - key, - trigger, - }, - }); - - const createEditorWithMentionInput = ( - at: React.ReactElement = ( - - - - - ), - options?: CreateEditorOptions - ): PlateEditor => { - const editor = createEditor(at, options) as PlateEditor; - - editor.insertText(trigger); - - return editor; - }; - - beforeEach(() => { - comboboxActions.byId({}); - }); - - describe('creating a mention input', () => { - it('should insert a mention input when the trigger is inserted between words', () => { - const editor = createEditorWithMentionInput( - - hello world - - ); - - expect(editor.children).toEqual([ - - hello - - - - - world - , - ]); - }); - - it('should insert a mention input when the trigger is inserted at line beginning followed by a whitespace', () => { - const editor = createEditorWithMentionInput( - - hello world - - ); - - expect(editor.children).toEqual([ - - - - - - - hello world - , - ]); - }); - - it('should insert a mention input when the trigger is inserted at line end preceded by a whitespace', () => { - const editor = createEditorWithMentionInput( - - hello world - - ); - - expect(editor.children).toEqual([ - - hello world - - - - - - , - ]); - }); - - it('should insert the trigger as text when the trigger is appended to a word', () => { - const editor = createEditor( - - hello - - - ); - - editor.insertText(trigger); - - expect(editor.children).toEqual([ - - hello@ - - , - ]); - }); - - it('should insert the trigger as text when the trigger is prepended to a word', () => { - const editor = createEditor( - - - hello - - ); - - editor.insertText(trigger); - - expect(editor.children).toEqual([ - - - - - - - hello - , - ]); - }); - - it('should insert the trigger as text when the trigger is inserted into a word', () => { - const editor = createEditor( - - hel - - lo - - ); - - editor.insertText(trigger); - - expect(editor.children).toEqual([ - - hel@ - - lo - , - ]); - }); - - it('should insert text when not trigger', () => { - const editor = createEditor( - - - - ); - - editor.insertText('a'); - - expect(editor.children).toEqual([a]); - }); - - it('should insert a mention input when the trigger is inserted after the specified pattern', () => { - const emptyOrSpaceOrQuotePattern = /^$|^[\s"']$/; - const editor = createEditor( - - hello "" - , - { - triggerPreviousCharPattern: emptyOrSpaceOrQuotePattern, - } - ); - - editor.insertText(trigger); - - expect(editor.children).toEqual([ - - hello " - - - - - " - , - ]); - }); - }); - - describe('removing a mention input', () => { - it('should remove the mention input when the selection is removed from it', () => { - const editor = createEditor( - - - - - - - - - ); - - select(editor, { - offset: 0, - path: [0, 2], - }); - - expect(editor.children).toEqual([@]); - }); - - it('should preserve the text that was typed into the mention input after removing', () => { - const editor = createEditor( - - - - hello - - - - - ); - - select(editor, { - offset: 0, - path: [0, 2], - }); - - expect(editor.children).toEqual([@hello]); - }); - - it('should change the selection to the requested location', () => { - const editor = createEditor( - - - - hello - - - - - ); - - select(editor, { - offset: 0, - path: [0, 2], - }); - - expect(editor.selection).toEqual({ - anchor: { offset: 6, path: [0, 0] }, - focus: { offset: 6, path: [0, 0] }, - }); - }); - - it('should remove the input when deleting backward in empty input', () => { - const editor = createEditor( - - - - - - - - ); - - editor.deleteBackward('character'); - - expect(editor.children).toEqual([@]); - - expect(editor.selection).toEqual({ - anchor: { offset: 1, path: [0, 0] }, - focus: { offset: 1, path: [0, 0] }, - }); - }); - - it('should block insert break', () => { - const editor = createEditor( - - - - n - - - - - ); - - editor.insertBreak(); - - expect(editor.children).toEqual([ - - - - n - - - - , - ]); - - expect(editor.selection).toEqual({ - anchor: { offset: 1, path: [0, 1, 0] }, - focus: { offset: 1, path: [0, 1, 0] }, - }); - }); - }); - - describe('typing in a mention input', () => { - // TODO: remove if slate upgrade handles - it('should type into a mention input if the selection is in it', () => { - const editor = createEditorWithMentionInput( - - - - - ); - - editor.insertText('a'); - - expect(editor.children).toEqual([ - - - a - - , - ]); - }); - - it('should type the trigger as text when inside a mention input', () => { - const editor = createEditorWithMentionInput( - - - - ); - - editor.insertText(trigger); - - expect(editor.children).toEqual([ - - - {trigger} - - , - ]); - }); - }); - - describe('history', () => { - it('should undo inserting a mention by showing mention input', async () => { - const editor = createEditorWithMentionInput( - - - hello world - - - ); - - // flush previous ops to get a new undo batch going for mention input - await Promise.resolve(); - - getMentionOnSelectItem()(editor, { key: 'test', text: 'test' }); - - editor.undo(); - - expect(editor.children).toEqual([ - - hello - - - - world - , - ]); - - editor.undo(); - - expect(editor.children).toEqual([ - - - hello world - - , - ]); - }); - - it('should undo inserting a mention after input by showing mention input with the text', async () => { - const editor = createEditorWithMentionInput( - - - hello world - - - ); - - // flush previous ops to get a new undo batch going for mention input - await Promise.resolve(); - - editor.insertText('t'); - editor.insertText('e'); - - // flush previous ops to get a new undo batch going for mention input - await Promise.resolve(); - - getMentionOnSelectItem()(editor, { key: 'test', text: 'test' }); - - editor.undo(); - - expect(editor.children).toEqual([ - - hello - - te - - world - , - ]); - - editor.undo(); - - expect(editor.children).toEqual([ - - hello - - - - world - , - ]); - - editor.undo(); - - expect(editor.children).toEqual([ - - - hello world - - , - ]); - }); - }); - - describe('combobox', () => { - it('should show the combobox when a mention input is created', () => { - createEditorWithMentionInput( - - - , - { multipleMentionPlugins: true } - ); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: key, - }); - }); - - it('should close the combobox when a mention input is removed', () => { - const editor = createEditorWithMentionInput( - - - - - ); - - select(editor, { - offset: 0, - path: [0, 2], - }); - - expect(comboboxSelectors.state()).toMatchObject>({ - activeId: null, - }); - }); - - it('should update the text in the combobox when typing', () => { - const editor = createEditorWithMentionInput(); - - editor.insertText('abc'); - expect(comboboxSelectors.state()).toMatchObject>({ - text: 'abc', - }); - - editor.deleteBackward('character'); - expect(comboboxSelectors.state()).toMatchObject>({ - text: 'ab', - }); - - moveSelection(editor, { distance: 1, reverse: true }); - editor.deleteForward('character'); - expect(comboboxSelectors.state()).toMatchObject>({ - text: 'a', - }); - }); - }); - - describe('paste', () => { - const testPaste: ( - data: DataTransferDataMap, - input: React.ReactElement, - expected: React.ReactElement - ) => void = (data, input, expected) => { - const editor = createEditorWithMentionInput(input); - - editor.insertData(createDataTransfer(data)); - - expect(editor.children).toEqual([expected]); - }; - - const testPasteBasic: ( - data: DataTransferDataMap, - expected: string - ) => void = (data, expected) => { - testPaste( - data, - - - , - - - {expected} - - - ); - }; - - type PasteTestCase = { - data: DataTransferDataMap; - expected: string; - }; - - const basePasteTestSuite = ({ - newLine, - newLineAndWhitespace, - simple, - whitespace, - }: { - newLine: PasteTestCase; - newLineAndWhitespace: PasteTestCase; - simple: PasteTestCase; - whitespace: PasteTestCase; - }): void => { - it('should paste the clipboard contents into mention as text', () => - testPasteBasic(simple.data, simple.expected)); - - it('should merge lines', () => - testPasteBasic(newLine.data, newLine.expected)); - - it('should trim the text', () => - testPasteBasic(whitespace.data, whitespace.expected)); - - it('should trim every line before merging', () => - testPasteBasic( - newLineAndWhitespace.data, - newLineAndWhitespace.expected - )); - }; - - describe('html', () => { - basePasteTestSuite({ - newLine: { - data: new Map([ - ['text/html', 'hello
world'], - ]), - expected: 'helloworld', - }, - newLineAndWhitespace: { - data: new Map([ - ['text/html', ' hello
world '], - ]), - expected: 'helloworld', - }, - simple: { - data: new Map([['text/html', 'hello']]), - expected: 'hello', - }, - whitespace: { - data: new Map([['text/html', ' hello ']]), - expected: 'hello', - }, - }); - }); - - describe('plain text', () => { - basePasteTestSuite({ - newLine: { - data: new Map([['text/plain', 'hello\r\nworld\n!\r!']]), - expected: 'helloworld!!', - }, - newLineAndWhitespace: { - data: new Map([['text/plain', ' hello \r\n world \n ! \r ! ']]), - expected: 'helloworld!!', - }, - simple: { - data: new Map([['text/plain', 'hello']]), - expected: 'hello', - }, - whitespace: { - data: new Map([['text/plain', ' hello ']]), - expected: 'hello', - }, - }); - }); - }); -}); diff --git a/packages/mention/src/withMention.ts b/packages/mention/src/withMention.ts deleted file mode 100644 index 51e259b9a8..0000000000 --- a/packages/mention/src/withMention.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { comboboxActions } from '@udecode/plate-combobox'; -import { - type PlateEditor, - type TNode, - type TText, - type Value, - type WithPlatePlugin, - getEditorString, - getNodeString, - getPlugin, - getPointBefore, - getRange, - moveSelection, - setSelection, -} from '@udecode/plate-common'; -import { Range } from 'slate'; - -import type { MentionPlugin, TMentionInputElement } from './types'; - -import { ELEMENT_MENTION_INPUT } from './createMentionPlugin'; -import { - findMentionInput, - isNodeMentionInput, - isSelectionInMentionInput, -} from './queries/index'; -import { removeMentionInput } from './transforms/removeMentionInput'; - -export const withMention = < - V extends Value = Value, - E extends PlateEditor = PlateEditor, ->( - editor: E, - { - options: { id, inputCreation, query, trigger, triggerPreviousCharPattern }, - }: WithPlatePlugin -) => { - const { type } = getPlugin<{}, V>(editor, ELEMENT_MENTION_INPUT); - - const { - apply, - deleteBackward, - insertBreak, - insertFragment, - insertNode, - insertText, - insertTextData, - } = editor; - - const stripNewLineAndTrim: (text: string) => string = (text) => { - return text - .split(/\r\n|\r|\n/) - .map((line) => line.trim()) - .join(''); - }; - - editor.insertFragment = (fragment) => { - const inMentionInput = findMentionInput(editor) !== undefined; - - if (!inMentionInput) { - return insertFragment(fragment); - } - - return insertText( - fragment.map((node) => stripNewLineAndTrim(getNodeString(node))).join('') - ); - }; - - editor.insertTextData = (data) => { - const inMentionInput = findMentionInput(editor) !== undefined; - - if (!inMentionInput) { - return insertTextData(data); - } - - const text = data.getData('text/plain'); - - if (!text) { - return false; - } - - editor.insertText(stripNewLineAndTrim(text)); - - return true; - }; - - editor.deleteBackward = (unit) => { - const currentMentionInput = findMentionInput(editor); - - if (currentMentionInput && getNodeString(currentMentionInput[0]) === '') { - removeMentionInput(editor, currentMentionInput[1]); - - return moveSelection(editor, { unit: 'word' }); - } - - deleteBackward(unit); - }; - - editor.insertBreak = () => { - if (isSelectionInMentionInput(editor)) { - return; - } - - insertBreak(); - }; - - editor.insertText = (text) => { - if ( - !editor.selection || - text !== trigger || - (query && !query(editor as PlateEditor)) || - isSelectionInMentionInput(editor) - ) { - return insertText(text); - } - - // Make sure a mention input is created at the beginning of line or after a whitespace - const previousChar = getEditorString( - editor, - getRange( - editor, - editor.selection, - getPointBefore(editor, editor.selection) - ) - ); - const matchesPreviousCharPattern = - triggerPreviousCharPattern?.test(previousChar); - - if (matchesPreviousCharPattern && text === trigger) { - const data: TMentionInputElement = { - children: [{ text: '' }], - trigger, - type, - }; - - if (inputCreation) { - data[inputCreation.key] = inputCreation.value; - } - - return insertNode(data); - } - - return insertText(text); - }; - - editor.apply = (operation) => { - apply(operation); - - if (operation.type === 'insert_text' || operation.type === 'remove_text') { - const currentMentionInput = findMentionInput(editor); - - if (currentMentionInput) { - comboboxActions.text(getNodeString(currentMentionInput[0])); - } - } else if (operation.type === 'set_selection') { - const previousMentionInputPath = Range.isRange(operation.properties) - ? findMentionInput(editor, { at: operation.properties })?.[1] - : undefined; - - const currentMentionInputPath = Range.isRange(operation.newProperties) - ? findMentionInput(editor, { at: operation.newProperties })?.[1] - : undefined; - - if (previousMentionInputPath && !currentMentionInputPath) { - removeMentionInput(editor, previousMentionInputPath); - moveSelection(editor, { unit: 'word' }); - } - if (currentMentionInputPath) { - comboboxActions.targetRange(editor.selection); - } - } else if ( - operation.type === 'insert_node' && - isNodeMentionInput(editor, operation.node as TNode) - ) { - if ((operation.node as TMentionInputElement).trigger !== trigger) { - return; - } - - const text = - ((operation.node as TMentionInputElement).children as TText[])[0] - ?.text ?? ''; - - if ( - inputCreation === undefined || - operation.node[inputCreation.key] === inputCreation.value - ) { - // Needed for undo - after an undo a mention insert we only receive - // an insert_node with the mention input, i.e. nothing indicating that it - // was an undo. - setSelection(editor, { - anchor: { offset: text.length, path: operation.path.concat([0]) }, - focus: { offset: text.length, path: operation.path.concat([0]) }, - }); - - comboboxActions.open({ - activeId: id!, - targetRange: editor.selection, - text, - }); - } - } else if ( - operation.type === 'remove_node' && - isNodeMentionInput(editor, operation.node as TNode) - ) { - if ((operation.node as TMentionInputElement).trigger !== trigger) { - return; - } - - comboboxActions.reset(); - } - }; - - return editor; -}; diff --git a/packages/slash-command/src/createSlashPlugin.ts b/packages/slash-command/src/createSlashPlugin.ts index 95baac286e..bd901b0468 100644 --- a/packages/slash-command/src/createSlashPlugin.ts +++ b/packages/slash-command/src/createSlashPlugin.ts @@ -1,30 +1,20 @@ -import { createPluginFactory, removeNodes } from '@udecode/plate-common'; - -import type { SlashPlugin } from './types'; - -import { slashOnKeyDownHandler } from './handlers/slashOnKeyDownHandler'; -import { isSelectionInSlashInput } from './queries/index'; -import { withSlashCommand } from './withSlashCommand'; +import { + type TriggerComboboxPlugin, + withTriggerCombobox, +} from '@udecode/plate-combobox'; +import { createPluginFactory } from '@udecode/plate-common/server'; export const KEY_SLASH_COMMAND = 'slash_command'; export const ELEMENT_SLASH_INPUT = 'slash_input'; -/** Enables support for autocompleting /slash_command. */ -export const createSlashPlugin = createPluginFactory({ - handlers: { - onBlur: (editor) => () => { - // remove slash_input nodes from editor on blur - removeNodes(editor, { - at: [], - match: (n) => n.type === ELEMENT_SLASH_INPUT, - }); - }, - onKeyDown: slashOnKeyDownHandler({ query: isSelectionInSlashInput }), - }, +export const createSlashPlugin = createPluginFactory({ key: KEY_SLASH_COMMAND, options: { - createSlashNode: (item) => ({ value: item.text }), + createComboboxInput: () => ({ + children: [{ text: '' }], + type: ELEMENT_SLASH_INPUT, + }), trigger: '/', triggerPreviousCharPattern: /^\s?$/, }, @@ -32,13 +22,9 @@ export const createSlashPlugin = createPluginFactory({ { isElement: true, isInline: true, + isVoid: true, key: ELEMENT_SLASH_INPUT, }, ], - then: (editor, { key }) => ({ - options: { - id: key, - }, - }), - withOverrides: withSlashCommand, + withOverrides: withTriggerCombobox, }); diff --git a/packages/slash-command/src/getSlashOnSelectItem.ts b/packages/slash-command/src/getSlashOnSelectItem.ts deleted file mode 100644 index 11c8369bf8..0000000000 --- a/packages/slash-command/src/getSlashOnSelectItem.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - type ComboboxOnSelectItem, - type Data, - type NoData, - type TComboboxItem, - comboboxActions, - comboboxSelectors, -} from '@udecode/plate-combobox'; -import { - type PlatePluginKey, - type TNodeProps, - getBlockAbove, - getPlugin, - insertText, - isEndPoint, - moveSelection, - removeNodes, - withoutMergingHistory, - withoutNormalizing, -} from '@udecode/plate-common'; - -import type { SlashPlugin, TSlashElement } from './types'; - -import { KEY_SLASH_COMMAND } from './createSlashPlugin'; -import { isNodeSlashInput } from './queries/isNodeSlashInput'; - -export type CreateSlashNode = ( - item: TComboboxItem, - meta: CreateSlashNodeMeta -) => TNodeProps; - -export interface CreateSlashNodeMeta { - search: string; -} - -export const getSlashOnSelectItem = - ({ - key = KEY_SLASH_COMMAND, - }: PlatePluginKey = {}): ComboboxOnSelectItem => - (editor: any, item: any) => { - const targetRange = comboboxSelectors.targetRange(); - - if (!targetRange) return; - - const { - options: { insertSpaceAfterSlash, rules }, - } = getPlugin(editor, key); - - const pathAbove = getBlockAbove(editor)?.[1]; - const isBlockEnd = () => - editor.selection && - pathAbove && - isEndPoint(editor, editor.selection.anchor, pathAbove); - - withoutNormalizing(editor, () => { - // Selectors are sensitive to operations, it's better to create everything - // before the editor state is changed. For example, asking for text after - // removeNodes below will return null. - - withoutMergingHistory(editor, () => - removeNodes(editor, { - match: (node) => isNodeSlashInput(editor, node), - }) - ); - - if (rules) { - const target = rules.find((rule) => rule.key === item.key); - target && target.onTrigger(editor, target.key); - } - - // move the selection after the element - moveSelection(editor, { unit: 'offset' }); - - if (isBlockEnd() && insertSpaceAfterSlash) { - insertText(editor, ' '); - } - }); - - return comboboxActions.reset(); - }; diff --git a/packages/slash-command/src/handlers/index.ts b/packages/slash-command/src/handlers/index.ts deleted file mode 100644 index ded3e12b8f..0000000000 --- a/packages/slash-command/src/handlers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './slashOnKeyDownHandler'; diff --git a/packages/slash-command/src/handlers/slashOnKeyDownHandler.ts b/packages/slash-command/src/handlers/slashOnKeyDownHandler.ts deleted file mode 100644 index b171fdaf4b..0000000000 --- a/packages/slash-command/src/handlers/slashOnKeyDownHandler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - type KeyboardEventHandler, - type MoveSelectionByOffsetOptions, - type PlateEditor, - type Value, - isHotkey, - moveSelection, - moveSelectionByOffset, -} from '@udecode/plate-common'; - -import { findSlashInput } from '../queries/index'; -import { removeSlashInput } from '../transforms/index'; - -export const slashOnKeyDownHandler: ( - options?: MoveSelectionByOffsetOptions -) => (editor: PlateEditor) => KeyboardEventHandler = - (options) => (editor) => (event) => { - if (isHotkey('escape', event)) { - const currentSlashInput = findSlashInput(editor)!; - - if (currentSlashInput) { - event.preventDefault(); - removeSlashInput(editor, currentSlashInput[1]); - moveSelection(editor, { unit: 'word' }); - - return true; - } - - return false; - } - - return moveSelectionByOffset(editor, options)(event); - }; diff --git a/packages/slash-command/src/index.ts b/packages/slash-command/src/index.ts index c8f331807d..81e2206b22 100644 --- a/packages/slash-command/src/index.ts +++ b/packages/slash-command/src/index.ts @@ -3,9 +3,4 @@ */ export * from './createSlashPlugin'; -export * from './getSlashOnSelectItem'; export * from './types'; -export * from './withSlashCommand'; -export * from './handlers/index'; -export * from './queries/index'; -export * from './transforms/index'; diff --git a/packages/slash-command/src/queries/findSlashInput.ts b/packages/slash-command/src/queries/findSlashInput.ts deleted file mode 100644 index 2e50bc1fb1..0000000000 --- a/packages/slash-command/src/queries/findSlashInput.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - type FindNodeOptions, - type PlateEditor, - type Value, - findNode, - getPluginType, -} from '@udecode/plate-common'; - -import type { TSlashInputElement } from '../types'; - -import { ELEMENT_SLASH_INPUT } from '../createSlashPlugin'; - -export const findSlashInput = ( - editor: PlateEditor, - options?: Omit, 'match'> -) => - findNode(editor, { - ...options, - match: { type: getPluginType(editor, ELEMENT_SLASH_INPUT) }, - }); diff --git a/packages/slash-command/src/queries/index.ts b/packages/slash-command/src/queries/index.ts deleted file mode 100644 index e43bb26031..0000000000 --- a/packages/slash-command/src/queries/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './findSlashInput'; -export * from './isNodeSlashInput'; -export * from './isSelectionInSlashInput'; diff --git a/packages/slash-command/src/queries/isNodeSlashInput.ts b/packages/slash-command/src/queries/isNodeSlashInput.ts deleted file mode 100644 index f0117b33c9..0000000000 --- a/packages/slash-command/src/queries/isNodeSlashInput.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { - type PlateEditor, - type TNode, - type Value, - getPluginType, -} from '@udecode/plate-common'; - -import type { TSlashInputElement } from '../types'; - -import { ELEMENT_SLASH_INPUT } from '../createSlashPlugin'; - -export const isNodeSlashInput = ( - editor: PlateEditor, - node: TNode -): node is TSlashInputElement => { - return node.type === getPluginType(editor, ELEMENT_SLASH_INPUT); -}; diff --git a/packages/slash-command/src/queries/isSelectionInSlashInput.ts b/packages/slash-command/src/queries/isSelectionInSlashInput.ts deleted file mode 100644 index 48cebc57db..0000000000 --- a/packages/slash-command/src/queries/isSelectionInSlashInput.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { PlateEditor, Value } from '@udecode/plate-common'; - -import { findSlashInput } from '.'; - -export const isSelectionInSlashInput = ( - editor: PlateEditor -) => findSlashInput(editor) !== undefined; diff --git a/packages/slash-command/src/transforms/index.ts b/packages/slash-command/src/transforms/index.ts deleted file mode 100644 index acf25b6224..0000000000 --- a/packages/slash-command/src/transforms/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './removeSlashInput'; diff --git a/packages/slash-command/src/transforms/removeSlashInput.ts b/packages/slash-command/src/transforms/removeSlashInput.ts deleted file mode 100644 index e649f197b0..0000000000 --- a/packages/slash-command/src/transforms/removeSlashInput.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Path } from 'slate'; - -import { - type EText, - type PlateEditor, - type Value, - getNode, - getNodeString, - replaceNode, - withoutNormalizing, -} from '@udecode/plate-common'; - -import type { TSlashInputElement } from '../types'; - -export const removeSlashInput = ( - editor: PlateEditor, - path: Path -) => - withoutNormalizing(editor, () => { - const node = getNode(editor, path); - - if (!node) return; - - const { trigger } = node; - - const text = getNodeString(node); - - replaceNode(editor, { - at: path, - nodes: { text: `${trigger}${text}` } as EText, - }); - }); diff --git a/packages/slash-command/src/types.ts b/packages/slash-command/src/types.ts index 12607cda4f..35f03537eb 100644 --- a/packages/slash-command/src/types.ts +++ b/packages/slash-command/src/types.ts @@ -1,29 +1,3 @@ -import type { Data, NoData } from '@udecode/plate-combobox'; -import type { PlateEditor, TElement } from '@udecode/plate-common'; +import type { TElement } from '@udecode/plate-common/server'; -import type { CreateSlashNode } from './getSlashOnSelectItem'; - -export interface TSlashElement extends TElement { - value: string; -} - -export interface TSlashInputElement extends TElement { - trigger: string; -} - -export interface SlashRule { - key: string; - onTrigger: (editor: PlateEditor, key: string) => void; - text: React.ReactNode; -} - -export interface SlashPlugin { - createSlashNode?: CreateSlashNode; - id?: string; - inputCreation?: { key: string; value: string }; - insertSpaceAfterSlash?: boolean; - query?: (editor: PlateEditor) => boolean; - rules?: SlashRule[]; - trigger?: string; - triggerPreviousCharPattern?: RegExp; -} +export interface TSlashInputElement extends TElement {} diff --git a/packages/slash-command/src/withSlashCommand.ts b/packages/slash-command/src/withSlashCommand.ts deleted file mode 100644 index 562594d795..0000000000 --- a/packages/slash-command/src/withSlashCommand.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { comboboxActions } from '@udecode/plate-combobox'; -import { - type PlateEditor, - type TNode, - type TText, - type Value, - type WithPlatePlugin, - getEditorString, - getNodeString, - getPlugin, - getPointBefore, - getRange, - moveSelection, - setSelection, -} from '@udecode/plate-common'; -import { Range } from 'slate'; - -import type { SlashPlugin, TSlashInputElement } from './types'; - -import { ELEMENT_SLASH_INPUT } from './createSlashPlugin'; -import { - findSlashInput, - isNodeSlashInput, - isSelectionInSlashInput, -} from './queries/index'; -import { removeSlashInput } from './transforms'; - -export const withSlashCommand = < - V extends Value = Value, - E extends PlateEditor = PlateEditor, ->( - editor: E, - { - options: { id, inputCreation, query, trigger, triggerPreviousCharPattern }, - }: WithPlatePlugin -) => { - const { type } = getPlugin<{}, V>(editor, ELEMENT_SLASH_INPUT); - - const { - apply, - deleteBackward, - insertBreak, - insertFragment, - insertNode, - insertText, - insertTextData, - } = editor; - - const stripNewLineAndTrim: (text: string) => string = (text) => { - return text - .split(/\r\n|\r|\n/) - .map((line) => line.trim()) - .join(''); - }; - - editor.insertFragment = (fragment) => { - const inSlashInput = findSlashInput(editor) !== undefined; - - if (!inSlashInput) { - return insertFragment(fragment); - } - - return insertText( - fragment.map((node) => stripNewLineAndTrim(getNodeString(node))).join('') - ); - }; - - editor.insertTextData = (data) => { - const inSlashInput = findSlashInput(editor) !== undefined; - - if (!inSlashInput) { - return insertTextData(data); - } - - const text = data.getData('text/plain'); - - if (!text) { - return false; - } - - editor.insertText(stripNewLineAndTrim(text)); - - return true; - }; - - editor.deleteBackward = (unit) => { - const currentSlashInput = findSlashInput(editor); - - if (currentSlashInput && getNodeString(currentSlashInput[0]) === '') { - removeSlashInput(editor, currentSlashInput[1]); - - return moveSelection(editor, { unit: 'word' }); - } - - deleteBackward(unit); - }; - - editor.insertBreak = () => { - if (isSelectionInSlashInput(editor)) { - return; - } - - insertBreak(); - }; - - editor.insertText = (text) => { - if ( - !editor.selection || - text !== trigger || - (query && !query(editor as PlateEditor)) || - isSelectionInSlashInput(editor) - ) { - return insertText(text); - } - - // Make sure a slash input is created at the beginning of line or after a whitespace - const previousChar = getEditorString( - editor, - getRange( - editor, - editor.selection, - getPointBefore(editor, editor.selection) - ) - ); - const matchesPreviousCharPattern = - triggerPreviousCharPattern?.test(previousChar); - - if (matchesPreviousCharPattern && text === trigger) { - const data: TSlashInputElement = { - children: [{ text: '' }], - trigger, - type, - }; - - if (inputCreation) { - data[inputCreation.key] = inputCreation.value; - } - - return insertNode(data); - } - - return insertText(text); - }; - - editor.apply = (operation) => { - apply(operation); - - if (operation.type === 'insert_text' || operation.type === 'remove_text') { - const currentSlashInput = findSlashInput(editor); - - if (currentSlashInput) { - comboboxActions.text(getNodeString(currentSlashInput[0])); - } - } else if (operation.type === 'set_selection') { - const previousSlashInputPath = Range.isRange(operation.properties) - ? findSlashInput(editor, { - at: operation.properties, - })?.[1] - : undefined; - - const currentSlashInputPath = Range.isRange(operation.newProperties) - ? findSlashInput(editor, { - at: operation.newProperties, - })?.[1] - : undefined; - - if (previousSlashInputPath && !currentSlashInputPath) { - removeSlashInput(editor, previousSlashInputPath); - moveSelection(editor, { unit: 'word' }); - } - if (currentSlashInputPath) { - comboboxActions.targetRange(editor.selection); - } - } else if ( - operation.type === 'insert_node' && - isNodeSlashInput(editor, operation.node as TNode) - ) { - if ((operation.node as TSlashInputElement).trigger !== trigger) { - return; - } - - const text = - ((operation.node as TSlashInputElement).children as TText[])[0]?.text ?? - ''; - - if ( - inputCreation === undefined || - operation.node[inputCreation.key] === inputCreation.value - ) { - // Needed for undo - after an undo a slash insert we only receive - // an insert_node with the slash input, i.e. nothing indicating that it - // was an undo. - setSelection(editor, { - anchor: { offset: text.length, path: operation.path.concat([0]) }, - focus: { offset: text.length, path: operation.path.concat([0]) }, - }); - - comboboxActions.open({ - activeId: id!, - targetRange: editor.selection, - text, - }); - } - } else if ( - operation.type === 'remove_node' && - isNodeSlashInput(editor, operation.node as TNode) - ) { - if ((operation.node as TSlashInputElement).trigger !== trigger) { - return; - } - - comboboxActions.reset(); - } - }; - - return editor; -}; diff --git a/yarn.lock b/yarn.lock index f2106a199b..8bd0b6eef4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,6 +51,39 @@ __metadata: languageName: node linkType: hard +"@ariakit/core@npm:0.4.6": + version: 0.4.6 + resolution: "@ariakit/core@npm:0.4.6" + checksum: 10c0/ca95be5acfd55ad99fa2eaddfdcf2dd178622ac64634bec80709dc4c722f8f15ac6d321831c72ab034001fe00964f7a2531e519916f29cf885d8cf3ffdbb6776 + languageName: node + linkType: hard + +"@ariakit/react-core@npm:0.4.6": + version: 0.4.6 + resolution: "@ariakit/react-core@npm:0.4.6" + dependencies: + "@ariakit/core": "npm:0.4.6" + "@floating-ui/dom": "npm:^1.0.0" + use-sync-external-store: "npm:^1.2.0" + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: 10c0/cd24d020a380a5de48607119c7f46a1b64bc8780d7b4a18f09207b2a3c13957cfc1c5dc0256ad8dd0924714b074e30f4af5702d25b438885516d9c900cc8ce91 + languageName: node + linkType: hard + +"@ariakit/react@npm:0.4.6": + version: 0.4.6 + resolution: "@ariakit/react@npm:0.4.6" + dependencies: + "@ariakit/react-core": "npm:0.4.6" + peerDependencies: + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + checksum: 10c0/647d540c81d116de690e80544152471be59ced91ca1a31e81dbafea162397e3ce16844401eac708c06e8ad834ca6779eb373fc4634e28d6ecdb7e0f2fce4a061 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.21.4, @babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.1, @babel/code-frame@npm:^7.24.2": version: 7.24.2 resolution: "@babel/code-frame@npm:7.24.2" @@ -1535,6 +1568,16 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:^1.0.0": + version: 1.6.4 + resolution: "@floating-ui/dom@npm:1.6.4" + dependencies: + "@floating-ui/core": "npm:^1.0.0" + "@floating-ui/utils": "npm:^0.2.0" + checksum: 10c0/cee0b9e6efc1c6d978ec580c770078fdf416016fb03f3dd99630f7f32d0422722e608471fbc7578be86c783ad1c1e448c5fa5b9fdec889dfbf4b695f208730fd + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.2.1, @floating-ui/dom@npm:^1.6.1": version: 1.6.3 resolution: "@floating-ui/dom@npm:1.6.3" @@ -21557,6 +21600,7 @@ __metadata: version: 0.0.0-use.local resolution: "www@workspace:apps/www" dependencies: + "@ariakit/react": "npm:0.4.6" "@faker-js/faker": "npm:^8.4.1" "@radix-ui/colors": "npm:1.0.1" "@radix-ui/react-accessible-icon": "npm:^1.0.3"