diff --git a/.changeset/plate-floating-minor.md b/.changeset/plate-floating-minor.md
new file mode 100644
index 0000000000..0058435b51
--- /dev/null
+++ b/.changeset/plate-floating-minor.md
@@ -0,0 +1,5 @@
+---
+'@udecode/plate-floating': minor
+---
+
+Added `useVirtualRefState` hook for virtual reference positioning
\ No newline at end of file
diff --git a/.changeset/plate-lint-minor.md b/.changeset/plate-lint-minor.md
new file mode 100644
index 0000000000..c63b344fa7
--- /dev/null
+++ b/.changeset/plate-lint-minor.md
@@ -0,0 +1,11 @@
+---
+'@udecode/plate-lint': minor
+---
+
+Added **Lint Plugin** - A comprehensive text validation system for Plate editors, similar to ESLint for document content.
+
+- Added new lint plugin with customizable rule system
+- Built-in rules for text case validation and find/replace functionality
+- Real-time text validation with inline annotations and fix suggestions
+- Popover UI for viewing and applying fixes
+- Support for custom lint rules and parsers
\ No newline at end of file
diff --git a/.changeset/slate-utils-minor.md b/.changeset/slate-utils-minor.md
new file mode 100644
index 0000000000..6cf059b129
--- /dev/null
+++ b/.changeset/slate-utils-minor.md
@@ -0,0 +1,11 @@
+---
+'@udecode/slate-utils': minor
+---
+
+Added annotation utilities:
+- `annotationToDecorations` - Convert single annotation to decorations
+- `annotationsToDecorations` - Convert multiple annotations to decorations
+- `getNextRange` - Find next range matching criteria
+- `isSelectionInRange` - Check if selection intersects with range
+- `parseNode` - Parse node content with custom parser options
+- `replaceText` - Replace text at specified range
\ No newline at end of file
diff --git a/apps/www/content/docs/components/changelog.mdx b/apps/www/content/docs/components/changelog.mdx
index abfeefd01f..9a888ef7fc 100644
--- a/apps/www/content/docs/components/changelog.mdx
+++ b/apps/www/content/docs/components/changelog.mdx
@@ -9,6 +9,15 @@ Since Plate UI is not a component library, a changelog is maintained here.
Use the [CLI](https://platejs.org/docs/components/cli) to install the latest version of the components.
+## December 2024 #17
+
+### December 26 #17.1
+
+https://github.com/udecode/plate/pull/3833/files
+- Add `lint-leaf`, `lint-popover` - UI components for the new lint plugin
+ - `lint-leaf`: Renders text decorations with error/warning indicators for linted content
+ - `lint-popover`: Displays lint messages and fix suggestions in a popover interface
+
## November 2024 #16
### November 26 #16.9
diff --git a/apps/www/content/docs/lint.mdx b/apps/www/content/docs/lint.mdx
new file mode 100644
index 0000000000..14295a3a3c
--- /dev/null
+++ b/apps/www/content/docs/lint.mdx
@@ -0,0 +1,97 @@
+---
+title: Lint
+description: Lint your document with custom rules.
+---
+
+
+
+This package is experimental. Expect breaking changes in future releases.
+
+
+
+
+
+
+
+The Lint feature allows you to enforce custom rules and configurations on your documents.
+
+## Features
+
+- Customizable linting config, plugins and rules with a similar API to ESLint
+- Provides suggestions and fixes for each rule
+
+
+
+## Installation
+
+```bash
+npm install @udecode/plate-lint
+```
+
+## Usage
+
+```tsx
+import { resolveLintConfigs } from '@udecode/plate-lint/react';
+import { emojiLintPlugin } from '@udecode/plate-lint/plugins';
+
+const lintConfigs = resolveLintConfigs([
+ emojiLintPlugin.configs.all,
+ // ...otherConfigs
+]);
+```
+
+- [resolveLintConfigs](/docs/components/resolve-lint-configs)
+
+### Configuration
+
+To configure the linting rules, you can use the `resolveLintConfigs` function to merge multiple configurations:
+
+```tsx
+const configs = [
+ emojiLintPlugin.configs.all,
+ {
+ languageOptions: {
+ parserOptions: {
+ minLength: 4,
+ },
+ },
+ settings: {
+ emojiMap: wordToEmojisMap,
+ maxSuggestions: 5,
+ },
+ },
+];
+```
+
+### Plugins
+
+#### EmojiLintPlugin
+
+A plugin that provides linting rules for replacing text with emojis.
+
+
+
+Map of words to their corresponding emoji suggestions.
+
+
+Maximum number of emoji suggestions to provide. Default: `8`
+
+
+
+## API
+
+### resolveLintConfigs
+
+Merges multiple lint configurations into a single set of resolved rules.
+
+
+
+ Array of lint configurations to merge.
+
+
+
+
+
+ Object containing the resolved lint rules.
+
+
diff --git a/apps/www/package.json b/apps/www/package.json
index 4f73ba3fc1..cf3a2312e7 100644
--- a/apps/www/package.json
+++ b/apps/www/package.json
@@ -109,6 +109,7 @@
"@udecode/plate-layout": "workspace:^",
"@udecode/plate-line-height": "workspace:^",
"@udecode/plate-link": "workspace:^",
+ "@udecode/plate-lint": "workspace:^",
"@udecode/plate-list": "workspace:^",
"@udecode/plate-markdown": "workspace:^",
"@udecode/plate-media": "workspace:^",
diff --git a/apps/www/public/r/styles/default/lint-demo.json b/apps/www/public/r/styles/default/lint-demo.json
new file mode 100644
index 0000000000..e4c744b91a
--- /dev/null
+++ b/apps/www/public/r/styles/default/lint-demo.json
@@ -0,0 +1,38 @@
+{
+ "dependencies": [
+ "@udecode/plate-lint",
+ "@udecode/plate-basic-marks",
+ "@udecode/plate-node-id"
+ ],
+ "doc": {
+ "description": "Lint your document with emoji suggestions.",
+ "docs": [
+ {
+ "route": "/docs/lint",
+ "title": "Lint"
+ }
+ ]
+ },
+ "files": [
+ {
+ "content": "'use client';\n\nimport { BasicMarksPlugin } from '@udecode/plate-basic-marks/react';\nimport { Plate, useEditorPlugin } from '@udecode/plate-common/react';\nimport {\n ExperimentalLintPlugin,\n caseLintPlugin,\n replaceLintPlugin,\n} from '@udecode/plate-lint/react';\nimport { NodeIdPlugin } from '@udecode/plate-node-id';\nimport { type Gemoji, gemoji } from 'gemoji';\n\nimport {\n useCreateEditor,\n viewComponents,\n} from '@/components/editor/use-create-editor';\nimport { Button } from '@/components/plate-ui/button';\nimport { Editor, EditorContainer } from '@/components/plate-ui/editor';\nimport { LintLeaf } from '@/components/plate-ui/lint-leaf';\nimport { LintPopover } from '@/components/plate-ui/lint-popover';\n\nexport default function LintEmojiDemo() {\n const editor = useCreateEditor({\n override: {\n components: viewComponents,\n },\n plugins: [\n ExperimentalLintPlugin.configure({\n render: {\n afterEditable: LintPopover,\n node: LintLeaf,\n },\n }),\n NodeIdPlugin,\n BasicMarksPlugin,\n ],\n value: [\n {\n children: [\n {\n text: \"I'm happy to see my cat and dog. I love them even when I'm sad.\",\n },\n ],\n type: 'p',\n },\n {\n children: [\n {\n text: 'I like to eat pizza and ice cream.',\n },\n ],\n type: 'p',\n },\n {\n children: [\n {\n text: 'hello world! this is a test. new sentence here. the cat is happy.',\n },\n ],\n type: 'p',\n },\n ],\n });\n\n return (\n
\n );\n}\n\nfunction EmojiPlateEditorContent() {\n const { api, editor } = useEditorPlugin(ExperimentalLintPlugin);\n\n const runFirst = () => {\n api.lint.run([\n {\n ...replaceLintPlugin.configs.all,\n targets: [{ id: editor.children[0].id as string }],\n },\n {\n settings: {\n replace: {\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n const runMax = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n settings: {\n replace: {\n parserOptions: {\n maxLength: 4,\n },\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n const runCase = () => {\n api.lint.run([\n caseLintPlugin.configs.all,\n {\n settings: {\n case: {\n ignoredWords: ['iPhone', 'iOS', 'iPad'],\n },\n },\n },\n ]);\n };\n\n const runBoth = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n caseLintPlugin.configs.all,\n {\n settings: {\n case: {\n ignoredWords: ['iPhone', 'iOS', 'iPad'],\n },\n replace: {\n parserOptions: {\n maxLength: 4,\n },\n replaceMap: emojiMap,\n },\n },\n },\n ]);\n };\n\n return (\n <>\n \n \n Emoji\n \n \n Max Length\n \n \n Case\n \n \n Both\n \n \n Reset\n \n
\n \n \n \n >\n );\n}\n\nconst excludeWords = new Set([\n 'a',\n 'an',\n 'and',\n 'are',\n 'as',\n 'at',\n 'be',\n 'but',\n 'by',\n 'for',\n 'from',\n 'if',\n 'in',\n 'into',\n 'is',\n 'it',\n 'no',\n 'not',\n 'of',\n 'on',\n 'or',\n 'such',\n 'that',\n 'the',\n 'their',\n 'then',\n 'there',\n 'these',\n 'they',\n 'this',\n 'to',\n 'was',\n 'was',\n 'will',\n 'with',\n]);\n\ntype WordSource = 'description' | 'exact_name' | 'name' | 'tag';\n\nfunction splitWords(text: string): string[] {\n return text.toLowerCase().split(/[^\\d_a-z]+/);\n}\n\nconst emojiMap = new Map<\n string,\n (Gemoji & { text: string; type: 'emoji' })[]\n>();\n\ngemoji.forEach((emoji) => {\n const wordSources = new Map();\n\n // Priority 1: Exact name matches (highest priority)\n emoji.names.forEach((name) => {\n const nameLower = name.toLowerCase();\n splitWords(name).forEach((word) => {\n if (!excludeWords.has(word)) {\n // If the name is exactly this word, it gets highest priority\n wordSources.set(word, word === nameLower ? 'exact_name' : 'name');\n }\n });\n });\n\n // Priority 3: Tags\n emoji.tags.forEach((tag) => {\n splitWords(tag).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'tag');\n }\n });\n });\n\n // Priority 4: Description (lowest priority)\n if (emoji.description) {\n splitWords(emoji.description).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'description');\n }\n });\n }\n\n wordSources.forEach((source, word) => {\n if (!emojiMap.has(word)) {\n emojiMap.set(word, []);\n }\n\n const emojis = emojiMap.get(word)!;\n\n const insertIndex = emojis.findIndex((e) => {\n const existingSource = getWordSource(e, word);\n\n return source > existingSource;\n });\n\n if (insertIndex === -1) {\n emojis.push({\n ...emoji,\n text: emoji.emoji,\n type: 'emoji',\n });\n } else {\n emojis.splice(insertIndex, 0, {\n ...emoji,\n text: emoji.emoji,\n type: 'emoji',\n });\n }\n });\n});\n\nfunction getWordSource(emoji: Gemoji, word: string): WordSource {\n // Check for exact name match first\n if (emoji.names.some((name) => name.toLowerCase() === word))\n return 'exact_name';\n // Then check for partial name matches\n if (emoji.names.some((name) => splitWords(name).includes(word)))\n return 'name';\n if (emoji.tags.some((tag) => splitWords(tag).includes(word))) return 'tag';\n\n return 'description';\n}\n",
+ "path": "example/lint-demo.tsx",
+ "target": "components/lint-demo.tsx",
+ "type": "registry:example"
+ },
+ {
+ "content": "'use client';\n\nimport type { Value } from '@udecode/plate-common';\n\nimport { withProps } from '@udecode/cn';\nimport { AIPlugin } from '@udecode/plate-ai/react';\nimport {\n BoldPlugin,\n CodePlugin,\n ItalicPlugin,\n StrikethroughPlugin,\n SubscriptPlugin,\n SuperscriptPlugin,\n UnderlinePlugin,\n} from '@udecode/plate-basic-marks/react';\nimport { BlockquotePlugin } from '@udecode/plate-block-quote/react';\nimport {\n CodeBlockPlugin,\n CodeLinePlugin,\n CodeSyntaxPlugin,\n} from '@udecode/plate-code-block/react';\nimport { CommentsPlugin } from '@udecode/plate-comments/react';\nimport {\n type CreatePlateEditorOptions,\n ParagraphPlugin,\n PlateLeaf,\n usePlateEditor,\n} from '@udecode/plate-common/react';\nimport { DatePlugin } from '@udecode/plate-date/react';\nimport { EmojiInputPlugin } from '@udecode/plate-emoji/react';\nimport { HEADING_KEYS } from '@udecode/plate-heading';\nimport { TocPlugin } from '@udecode/plate-heading/react';\nimport { HighlightPlugin } from '@udecode/plate-highlight/react';\nimport { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react';\nimport { KbdPlugin } from '@udecode/plate-kbd/react';\nimport { ColumnItemPlugin, ColumnPlugin } from '@udecode/plate-layout/react';\nimport { LinkPlugin } from '@udecode/plate-link/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n MediaEmbedPlugin,\n PlaceholderPlugin,\n VideoPlugin,\n} from '@udecode/plate-media/react';\nimport {\n MentionInputPlugin,\n MentionPlugin,\n} from '@udecode/plate-mention/react';\nimport { SlashInputPlugin } from '@udecode/plate-slash-command/react';\nimport {\n TableCellHeaderPlugin,\n TableCellPlugin,\n TablePlugin,\n TableRowPlugin,\n} from '@udecode/plate-table/react';\nimport { TogglePlugin } from '@udecode/plate-toggle/react';\n\nimport { AILeaf } from '@/components/plate-ui/ai-leaf';\nimport { BlockquoteElement } from '@/components/plate-ui/blockquote-element';\nimport { CodeBlockElement } from '@/components/plate-ui/code-block-element';\nimport { CodeLeaf } from '@/components/plate-ui/code-leaf';\nimport { CodeLineElement } from '@/components/plate-ui/code-line-element';\nimport { CodeSyntaxLeaf } from '@/components/plate-ui/code-syntax-leaf';\nimport { ColumnElement } from '@/components/plate-ui/column-element';\nimport { ColumnGroupElement } from '@/components/plate-ui/column-group-element';\nimport { CommentLeaf } from '@/components/plate-ui/comment-leaf';\nimport { DateElement } from '@/components/plate-ui/date-element';\nimport { EmojiInputElement } from '@/components/plate-ui/emoji-input-element';\nimport { HeadingElement } from '@/components/plate-ui/heading-element';\nimport { HighlightLeaf } from '@/components/plate-ui/highlight-leaf';\nimport { HrElement } from '@/components/plate-ui/hr-element';\nimport { ImageElement } from '@/components/plate-ui/image-element';\nimport { KbdLeaf } from '@/components/plate-ui/kbd-leaf';\nimport { LinkElement } from '@/components/plate-ui/link-element';\nimport { MediaAudioElement } from '@/components/plate-ui/media-audio-element';\nimport { MediaEmbedElement } from '@/components/plate-ui/media-embed-element';\nimport { MediaFileElement } from '@/components/plate-ui/media-file-element';\nimport { MediaPlaceholderElement } from '@/components/plate-ui/media-placeholder-element';\nimport { MediaVideoElement } from '@/components/plate-ui/media-video-element';\nimport { MentionElement } from '@/components/plate-ui/mention-element';\nimport { MentionInputElement } from '@/components/plate-ui/mention-input-element';\nimport { ParagraphElement } from '@/components/plate-ui/paragraph-element';\nimport { withPlaceholders } from '@/components/plate-ui/placeholder';\nimport { SlashInputElement } from '@/components/plate-ui/slash-input-element';\nimport {\n TableCellElement,\n TableCellHeaderElement,\n} from '@/components/plate-ui/table-cell-element';\nimport { TableElement } from '@/components/plate-ui/table-element';\nimport { TableRowElement } from '@/components/plate-ui/table-row-element';\nimport { TocElement } from '@/components/plate-ui/toc-element';\nimport { ToggleElement } from '@/components/plate-ui/toggle-element';\nimport { withDraggables } from '@/components/plate-ui/with-draggables';\n\nimport { editorPlugins, viewPlugins } from './plugins/editor-plugins';\n\nexport const viewComponents = {\n [AudioPlugin.key]: MediaAudioElement,\n [BlockquotePlugin.key]: BlockquoteElement,\n [BoldPlugin.key]: withProps(PlateLeaf, { as: 'strong' }),\n [CodeBlockPlugin.key]: CodeBlockElement,\n [CodeLinePlugin.key]: CodeLineElement,\n [CodePlugin.key]: CodeLeaf,\n [CodeSyntaxPlugin.key]: CodeSyntaxLeaf,\n [ColumnItemPlugin.key]: ColumnElement,\n [ColumnPlugin.key]: ColumnGroupElement,\n [CommentsPlugin.key]: CommentLeaf,\n [DatePlugin.key]: DateElement,\n [FilePlugin.key]: MediaFileElement,\n [HEADING_KEYS.h1]: withProps(HeadingElement, { variant: 'h1' }),\n [HEADING_KEYS.h2]: withProps(HeadingElement, { variant: 'h2' }),\n [HEADING_KEYS.h3]: withProps(HeadingElement, { variant: 'h3' }),\n [HEADING_KEYS.h4]: withProps(HeadingElement, { variant: 'h4' }),\n [HEADING_KEYS.h5]: withProps(HeadingElement, { variant: 'h5' }),\n [HEADING_KEYS.h6]: withProps(HeadingElement, { variant: 'h6' }),\n [HighlightPlugin.key]: HighlightLeaf,\n [HorizontalRulePlugin.key]: HrElement,\n [ImagePlugin.key]: ImageElement,\n [ItalicPlugin.key]: withProps(PlateLeaf, { as: 'em' }),\n [KbdPlugin.key]: KbdLeaf,\n [LinkPlugin.key]: LinkElement,\n [MediaEmbedPlugin.key]: MediaEmbedElement,\n [MentionPlugin.key]: MentionElement,\n [ParagraphPlugin.key]: ParagraphElement,\n [PlaceholderPlugin.key]: MediaPlaceholderElement,\n [StrikethroughPlugin.key]: withProps(PlateLeaf, { as: 's' }),\n [SubscriptPlugin.key]: withProps(PlateLeaf, { as: 'sub' }),\n [SuperscriptPlugin.key]: withProps(PlateLeaf, { as: 'sup' }),\n [TableCellHeaderPlugin.key]: TableCellHeaderElement,\n [TableCellPlugin.key]: TableCellElement,\n [TablePlugin.key]: TableElement,\n [TableRowPlugin.key]: TableRowElement,\n [TocPlugin.key]: TocElement,\n [TogglePlugin.key]: ToggleElement,\n [UnderlinePlugin.key]: withProps(PlateLeaf, { as: 'u' }),\n [VideoPlugin.key]: MediaVideoElement,\n};\n\nexport const editorComponents = {\n ...viewComponents,\n [AIPlugin.key]: AILeaf,\n [EmojiInputPlugin.key]: EmojiInputElement,\n [MentionInputPlugin.key]: MentionInputElement,\n [SlashInputPlugin.key]: SlashInputElement,\n};\n\nexport const useCreateEditor = (\n {\n components,\n override,\n readOnly,\n ...options\n }: {\n components?: Record;\n plugins?: any[];\n readOnly?: boolean;\n } & Omit = {},\n deps: any[] = []\n) => {\n return usePlateEditor(\n {\n override: {\n components: {\n ...(readOnly\n ? viewComponents\n : withPlaceholders(withDraggables(editorComponents))),\n ...components,\n },\n ...override,\n },\n plugins: (readOnly ? viewPlugins : editorPlugins) as any,\n ...options,\n },\n deps\n );\n};\n",
+ "path": "components/editor/use-create-editor.ts",
+ "target": "components/use-create-editor.ts",
+ "type": "registry:example"
+ }
+ ],
+ "name": "lint-demo",
+ "registryDependencies": [
+ "editor",
+ "button",
+ "lint-leaf",
+ "lint-popover"
+ ],
+ "type": "registry:example"
+}
\ No newline at end of file
diff --git a/apps/www/public/r/styles/default/lint-emoji-demo.json b/apps/www/public/r/styles/default/lint-emoji-demo.json
new file mode 100644
index 0000000000..2734ab6619
--- /dev/null
+++ b/apps/www/public/r/styles/default/lint-emoji-demo.json
@@ -0,0 +1,38 @@
+{
+ "dependencies": [
+ "@udecode/plate-lint",
+ "@udecode/plate-basic-marks",
+ "@udecode/plate-node-id"
+ ],
+ "doc": {
+ "description": "Lint your document with emoji suggestions.",
+ "docs": [
+ {
+ "route": "/docs/lint",
+ "title": "Lint"
+ }
+ ]
+ },
+ "files": [
+ {
+ "content": "'use client';\n\nimport { BasicMarksPlugin } from '@udecode/plate-basic-marks/react';\nimport { Plate, useEditorPlugin } from '@udecode/plate-common/react';\nimport {\n ExperimentalLintPlugin,\n replaceLintPlugin,\n} from '@udecode/plate-lint/react';\nimport { NodeIdPlugin } from '@udecode/plate-node-id';\n\nimport {\n useCreateEditor,\n viewComponents,\n} from '@/components/editor/use-create-editor';\nimport { Button } from '@/components/plate-ui/button';\nimport { Editor, EditorContainer } from '@/components/plate-ui/editor';\nimport { LintLeaf } from '@/components/plate-ui/lint-leaf';\nimport { LintPopover } from '@/components/plate-ui/lint-popover';\n\nexport default function LintEmojiDemo() {\n const editor = useCreateEditor({\n override: {\n components: viewComponents,\n },\n plugins: [\n ExperimentalLintPlugin.configure({\n render: {\n afterEditable: LintPopover,\n node: LintLeaf,\n },\n }),\n NodeIdPlugin,\n BasicMarksPlugin,\n ],\n value: [\n {\n children: [\n {\n text: \"I'm happy to see my cat and dog. I love them even when I'm sad.\",\n },\n ],\n type: 'p',\n },\n {\n children: [\n {\n text: 'I like to eat pizza and ice cream.',\n },\n ],\n type: 'p',\n },\n ],\n });\n\n return (\n \n );\n}\n\nfunction EmojiPlateEditorContent() {\n const { api, editor } = useEditorPlugin(ExperimentalLintPlugin);\n\n const runFirst = () => {\n api.lint.run([\n {\n ...replaceLintPlugin.configs.all,\n targets: [\n { id: editor.children[0].id as string },\n { id: editor.children[1].id as string },\n ],\n },\n {\n languageOptions: {\n parserOptions: {\n minLength: 4,\n },\n },\n targets: [{ id: editor.children[0].id as string }],\n },\n {\n settings: {\n emojiMap: emojiMap,\n maxSuggestions: 5,\n },\n },\n ]);\n };\n\n const runMax = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n languageOptions: {\n parserOptions: {\n maxLength: 4,\n },\n },\n settings: {\n emojiMap: emojiMap,\n },\n },\n ]);\n };\n\n const runAll = () => {\n api.lint.run([\n replaceLintPlugin.configs.all,\n {\n settings: {\n emojiMap: emojiMap,\n },\n },\n ]);\n };\n\n return (\n <>\n \n \n First\n \n \n Max Length\n \n \n All\n \n \n Reset\n \n
\n \n \n \n >\n );\n}\n\nconst excludeWords = new Set([\n 'a',\n 'an',\n 'and',\n 'are',\n 'as',\n 'at',\n 'be',\n 'but',\n 'by',\n 'for',\n 'from',\n 'if',\n 'in',\n 'into',\n 'is',\n 'it',\n 'no',\n 'not',\n 'of',\n 'on',\n 'or',\n 'such',\n 'that',\n 'the',\n 'their',\n 'then',\n 'there',\n 'these',\n 'they',\n 'this',\n 'to',\n 'was',\n 'was',\n 'will',\n 'with',\n]);\n\ntype WordSource = 'description' | 'exact_name' | 'name' | 'tag';\n\nfunction splitWords(text: string): string[] {\n return text.toLowerCase().split(/[^\\d_a-z]+/);\n}\n\nconst emojiMap = new Map();\n\ngemoji.forEach((emoji) => {\n const wordSources = new Map();\n\n // Priority 1: Exact name matches (highest priority)\n emoji.names.forEach((name) => {\n const nameLower = name.toLowerCase();\n splitWords(name).forEach((word) => {\n if (!excludeWords.has(word)) {\n // If the name is exactly this word, it gets highest priority\n wordSources.set(word, word === nameLower ? 'exact_name' : 'name');\n }\n });\n });\n\n // Priority 3: Tags\n emoji.tags.forEach((tag) => {\n splitWords(tag).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'tag');\n }\n });\n });\n\n // Priority 4: Description (lowest priority)\n if (emoji.description) {\n splitWords(emoji.description).forEach((word) => {\n if (!excludeWords.has(word) && !wordSources.has(word)) {\n wordSources.set(word, 'description');\n }\n });\n }\n\n wordSources.forEach((source, word) => {\n if (!emojiMap.has(word)) {\n emojiMap.set(word, []);\n }\n\n const emojis = emojiMap.get(word)!;\n\n const insertIndex = emojis.findIndex((e) => {\n const existingSource = getWordSource(e, word);\n\n return source > existingSource;\n });\n\n if (insertIndex === -1) {\n emojis.push(emoji);\n } else {\n emojis.splice(insertIndex, 0, emoji);\n }\n });\n});\n\nfunction getWordSource(emoji: Gemoji, word: string): WordSource {\n // Check for exact name match first\n if (emoji.names.some((name) => name.toLowerCase() === word))\n return 'exact_name';\n // Then check for partial name matches\n if (emoji.names.some((name) => splitWords(name).includes(word)))\n return 'name';\n if (emoji.tags.some((tag) => splitWords(tag).includes(word))) return 'tag';\n\n return 'description';\n}\n",
+ "path": "example/lint-emoji-demo.tsx",
+ "target": "components/lint-emoji-demo.tsx",
+ "type": "registry:example"
+ },
+ {
+ "content": "'use client';\n\nimport type { Value } from '@udecode/plate-common';\n\nimport { withProps } from '@udecode/cn';\nimport { AIPlugin } from '@udecode/plate-ai/react';\nimport {\n BoldPlugin,\n CodePlugin,\n ItalicPlugin,\n StrikethroughPlugin,\n SubscriptPlugin,\n SuperscriptPlugin,\n UnderlinePlugin,\n} from '@udecode/plate-basic-marks/react';\nimport { BlockquotePlugin } from '@udecode/plate-block-quote/react';\nimport {\n CodeBlockPlugin,\n CodeLinePlugin,\n CodeSyntaxPlugin,\n} from '@udecode/plate-code-block/react';\nimport { CommentsPlugin } from '@udecode/plate-comments/react';\nimport {\n type CreatePlateEditorOptions,\n ParagraphPlugin,\n PlateLeaf,\n usePlateEditor,\n} from '@udecode/plate-common/react';\nimport { DatePlugin } from '@udecode/plate-date/react';\nimport { EmojiInputPlugin } from '@udecode/plate-emoji/react';\nimport { HEADING_KEYS } from '@udecode/plate-heading';\nimport { TocPlugin } from '@udecode/plate-heading/react';\nimport { HighlightPlugin } from '@udecode/plate-highlight/react';\nimport { HorizontalRulePlugin } from '@udecode/plate-horizontal-rule/react';\nimport { KbdPlugin } from '@udecode/plate-kbd/react';\nimport { ColumnItemPlugin, ColumnPlugin } from '@udecode/plate-layout/react';\nimport { LinkPlugin } from '@udecode/plate-link/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n MediaEmbedPlugin,\n PlaceholderPlugin,\n VideoPlugin,\n} from '@udecode/plate-media/react';\nimport {\n MentionInputPlugin,\n MentionPlugin,\n} from '@udecode/plate-mention/react';\nimport { SlashInputPlugin } from '@udecode/plate-slash-command/react';\nimport {\n TableCellHeaderPlugin,\n TableCellPlugin,\n TablePlugin,\n TableRowPlugin,\n} from '@udecode/plate-table/react';\nimport { TogglePlugin } from '@udecode/plate-toggle/react';\n\nimport { AILeaf } from '@/components/plate-ui/ai-leaf';\nimport { BlockquoteElement } from '@/components/plate-ui/blockquote-element';\nimport { CodeBlockElement } from '@/components/plate-ui/code-block-element';\nimport { CodeLeaf } from '@/components/plate-ui/code-leaf';\nimport { CodeLineElement } from '@/components/plate-ui/code-line-element';\nimport { CodeSyntaxLeaf } from '@/components/plate-ui/code-syntax-leaf';\nimport { ColumnElement } from '@/components/plate-ui/column-element';\nimport { ColumnGroupElement } from '@/components/plate-ui/column-group-element';\nimport { CommentLeaf } from '@/components/plate-ui/comment-leaf';\nimport { DateElement } from '@/components/plate-ui/date-element';\nimport { EmojiInputElement } from '@/components/plate-ui/emoji-input-element';\nimport { HeadingElement } from '@/components/plate-ui/heading-element';\nimport { HighlightLeaf } from '@/components/plate-ui/highlight-leaf';\nimport { HrElement } from '@/components/plate-ui/hr-element';\nimport { ImageElement } from '@/components/plate-ui/image-element';\nimport { KbdLeaf } from '@/components/plate-ui/kbd-leaf';\nimport { LinkElement } from '@/components/plate-ui/link-element';\nimport { MediaAudioElement } from '@/components/plate-ui/media-audio-element';\nimport { MediaEmbedElement } from '@/components/plate-ui/media-embed-element';\nimport { MediaFileElement } from '@/components/plate-ui/media-file-element';\nimport { MediaPlaceholderElement } from '@/components/plate-ui/media-placeholder-element';\nimport { MediaVideoElement } from '@/components/plate-ui/media-video-element';\nimport { MentionElement } from '@/components/plate-ui/mention-element';\nimport { MentionInputElement } from '@/components/plate-ui/mention-input-element';\nimport { ParagraphElement } from '@/components/plate-ui/paragraph-element';\nimport { withPlaceholders } from '@/components/plate-ui/placeholder';\nimport { SlashInputElement } from '@/components/plate-ui/slash-input-element';\nimport {\n TableCellElement,\n TableCellHeaderElement,\n} from '@/components/plate-ui/table-cell-element';\nimport { TableElement } from '@/components/plate-ui/table-element';\nimport { TableRowElement } from '@/components/plate-ui/table-row-element';\nimport { TocElement } from '@/components/plate-ui/toc-element';\nimport { ToggleElement } from '@/components/plate-ui/toggle-element';\nimport { withDraggables } from '@/components/plate-ui/with-draggables';\n\nimport { editorPlugins, viewPlugins } from './plugins/editor-plugins';\n\nexport const viewComponents = {\n [AudioPlugin.key]: MediaAudioElement,\n [BlockquotePlugin.key]: BlockquoteElement,\n [BoldPlugin.key]: withProps(PlateLeaf, { as: 'strong' }),\n [CodeBlockPlugin.key]: CodeBlockElement,\n [CodeLinePlugin.key]: CodeLineElement,\n [CodePlugin.key]: CodeLeaf,\n [CodeSyntaxPlugin.key]: CodeSyntaxLeaf,\n [ColumnItemPlugin.key]: ColumnElement,\n [ColumnPlugin.key]: ColumnGroupElement,\n [CommentsPlugin.key]: CommentLeaf,\n [DatePlugin.key]: DateElement,\n [FilePlugin.key]: MediaFileElement,\n [HEADING_KEYS.h1]: withProps(HeadingElement, { variant: 'h1' }),\n [HEADING_KEYS.h2]: withProps(HeadingElement, { variant: 'h2' }),\n [HEADING_KEYS.h3]: withProps(HeadingElement, { variant: 'h3' }),\n [HEADING_KEYS.h4]: withProps(HeadingElement, { variant: 'h4' }),\n [HEADING_KEYS.h5]: withProps(HeadingElement, { variant: 'h5' }),\n [HEADING_KEYS.h6]: withProps(HeadingElement, { variant: 'h6' }),\n [HighlightPlugin.key]: HighlightLeaf,\n [HorizontalRulePlugin.key]: HrElement,\n [ImagePlugin.key]: ImageElement,\n [ItalicPlugin.key]: withProps(PlateLeaf, { as: 'em' }),\n [KbdPlugin.key]: KbdLeaf,\n [LinkPlugin.key]: LinkElement,\n [MediaEmbedPlugin.key]: MediaEmbedElement,\n [MentionPlugin.key]: MentionElement,\n [ParagraphPlugin.key]: ParagraphElement,\n [PlaceholderPlugin.key]: MediaPlaceholderElement,\n [StrikethroughPlugin.key]: withProps(PlateLeaf, { as: 's' }),\n [SubscriptPlugin.key]: withProps(PlateLeaf, { as: 'sub' }),\n [SuperscriptPlugin.key]: withProps(PlateLeaf, { as: 'sup' }),\n [TableCellHeaderPlugin.key]: TableCellHeaderElement,\n [TableCellPlugin.key]: TableCellElement,\n [TablePlugin.key]: TableElement,\n [TableRowPlugin.key]: TableRowElement,\n [TocPlugin.key]: TocElement,\n [TogglePlugin.key]: ToggleElement,\n [UnderlinePlugin.key]: withProps(PlateLeaf, { as: 'u' }),\n [VideoPlugin.key]: MediaVideoElement,\n};\n\nexport const editorComponents = {\n ...viewComponents,\n [AIPlugin.key]: AILeaf,\n [EmojiInputPlugin.key]: EmojiInputElement,\n [MentionInputPlugin.key]: MentionInputElement,\n [SlashInputPlugin.key]: SlashInputElement,\n};\n\nexport const useCreateEditor = (\n {\n components,\n override,\n readOnly,\n ...options\n }: {\n components?: Record;\n plugins?: any[];\n readOnly?: boolean;\n } & Omit = {},\n deps: any[] = []\n) => {\n return usePlateEditor(\n {\n override: {\n components: {\n ...(readOnly\n ? viewComponents\n : withPlaceholders(withDraggables(editorComponents))),\n ...components,\n },\n ...override,\n },\n plugins: (readOnly ? viewPlugins : editorPlugins) as any,\n ...options,\n },\n deps\n );\n};\n",
+ "path": "components/editor/use-create-editor.ts",
+ "target": "components/use-create-editor.ts",
+ "type": "registry:example"
+ }
+ ],
+ "name": "lint-emoji-demo",
+ "registryDependencies": [
+ "editor",
+ "button",
+ "lint-leaf",
+ "lint-popover"
+ ],
+ "type": "registry:example"
+}
\ No newline at end of file
diff --git a/apps/www/public/r/styles/default/mode-toggle.json b/apps/www/public/r/styles/default/mode-toggle.json
index a73e7d9523..2d77d9f78f 100644
--- a/apps/www/public/r/styles/default/mode-toggle.json
+++ b/apps/www/public/r/styles/default/mode-toggle.json
@@ -1,7 +1,7 @@
{
"files": [
{
- "content": "'use client';\n\nimport * as React from 'react';\n\nimport { MoonIcon, SunIcon } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport { useMounted } from '@/hooks/use-mounted';\nimport { Button } from '@/components/plate-ui/button';\n\nexport default function ModeToggle() {\n const { setTheme, theme } = useTheme();\n\n const mounted = useMounted();\n\n return (\n setTheme(theme === 'dark' ? 'light' : 'dark')}\n >\n {mounted && theme === 'dark' ? (\n \n ) : (\n \n )}\n Toggle theme \n \n );\n}\n",
+ "content": "'use client';\n\nimport * as React from 'react';\n\nimport { MoonIcon, SunIcon } from 'lucide-react';\nimport { useTheme } from 'next-themes';\n\nimport { Button } from '@/components/plate-ui/button';\n\nexport default function ModeToggle() {\n const { setTheme, theme } = useTheme();\n\n return (\n setTheme(theme === 'dark' ? 'light' : 'dark')}\n >\n {theme === 'dark' ? (\n \n ) : (\n \n )}\n Toggle theme \n \n );\n}\n",
"path": "example/mode-toggle.tsx",
"target": "components/mode-toggle.tsx",
"type": "registry:example"
diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx
index 911bd6352e..ac75d2e57f 100644
--- a/apps/www/src/__registry__/index.tsx
+++ b/apps/www/src/__registry__/index.tsx
@@ -3733,6 +3733,26 @@ export const Index: Record = {
subcategory: "",
chunks: []
},
+ "lint-demo": {
+ name: "lint-demo",
+ description: "",
+ type: "registry:example",
+ registryDependencies: ["editor","button","lint-leaf","lint-popover"],
+ files: [{
+ path: "src/registry/default/example/lint-demo.tsx",
+ type: "registry:example",
+ target: ""
+ },{
+ path: "src/registry/default/components/editor/use-create-editor.ts",
+ type: "registry:example",
+ target: ""
+ }],
+ component: React.lazy(() => import("@/registry/default/example/lint-demo.tsx")),
+ source: "",
+ category: "",
+ subcategory: "",
+ chunks: []
+ },
"mode-toggle": {
name: "mode-toggle",
description: "",
diff --git a/apps/www/src/config/docs-plugins.ts b/apps/www/src/config/docs-plugins.ts
index 59e80a176d..e3229b6fc6 100644
--- a/apps/www/src/config/docs-plugins.ts
+++ b/apps/www/src/config/docs-plugins.ts
@@ -173,6 +173,12 @@ export const pluginsNavItems: SidebarNavItem[] = [
label: 'Element',
title: 'Link',
},
+ {
+ description: 'Lint your document with custom rules.',
+ href: '/docs/lint',
+ label: 'New',
+ title: 'Lint',
+ },
{
description: 'Organize nestable items in a bulleted or numbered list.',
href: '/docs/list',
diff --git a/apps/www/src/registry/default/example/lint-demo.tsx b/apps/www/src/registry/default/example/lint-demo.tsx
new file mode 100644
index 0000000000..49a7b76eef
--- /dev/null
+++ b/apps/www/src/registry/default/example/lint-demo.tsx
@@ -0,0 +1,285 @@
+'use client';
+
+import { BasicMarksPlugin } from '@udecode/plate-basic-marks/react';
+import { Plate, useEditorPlugin } from '@udecode/plate-common/react';
+import {
+ ExperimentalLintPlugin,
+ caseLintPlugin,
+ replaceLintPlugin,
+} from '@udecode/plate-lint/react';
+import { NodeIdPlugin } from '@udecode/plate-node-id';
+import { type Gemoji, gemoji } from 'gemoji';
+
+import {
+ useCreateEditor,
+ viewComponents,
+} from '@/registry/default/components/editor/use-create-editor';
+import { Button } from '@/registry/default/plate-ui/button';
+import { Editor, EditorContainer } from '@/registry/default/plate-ui/editor';
+import { LintLeaf } from '@/registry/default/plate-ui/lint-leaf';
+import { LintPopover } from '@/registry/default/plate-ui/lint-popover';
+
+export default function LintEmojiDemo() {
+ const editor = useCreateEditor({
+ override: {
+ components: viewComponents,
+ },
+ plugins: [
+ ExperimentalLintPlugin.configure({
+ render: {
+ afterEditable: LintPopover,
+ node: LintLeaf,
+ },
+ }),
+ NodeIdPlugin,
+ BasicMarksPlugin,
+ ],
+ value: [
+ {
+ children: [
+ {
+ text: "I'm happy to see my cat and dog. I love them even when I'm sad.",
+ },
+ ],
+ type: 'p',
+ },
+ {
+ children: [
+ {
+ text: 'I like to eat pizza and ice cream.',
+ },
+ ],
+ type: 'p',
+ },
+ {
+ children: [
+ {
+ text: 'hello world! this is a test. new sentence here. the cat is happy.',
+ },
+ ],
+ type: 'p',
+ },
+ ],
+ });
+
+ return (
+
+ );
+}
+
+function EmojiPlateEditorContent() {
+ const { api, editor } = useEditorPlugin(ExperimentalLintPlugin);
+
+ const runFirst = () => {
+ api.lint.run([
+ {
+ ...replaceLintPlugin.configs.all,
+ targets: [{ id: editor.children[0].id as string }],
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: emojiMap,
+ },
+ },
+ },
+ ]);
+ };
+
+ const runMax = () => {
+ api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ parserOptions: {
+ maxLength: 4,
+ },
+ replaceMap: emojiMap,
+ },
+ },
+ },
+ ]);
+ };
+
+ const runCase = () => {
+ api.lint.run([
+ caseLintPlugin.configs.all,
+ {
+ settings: {
+ case: {
+ ignoredWords: ['iPhone', 'iOS', 'iPad'],
+ },
+ },
+ },
+ ]);
+ };
+
+ const runBoth = () => {
+ api.lint.run([
+ replaceLintPlugin.configs.all,
+ caseLintPlugin.configs.all,
+ {
+ settings: {
+ case: {
+ ignoredWords: ['iPhone', 'iOS', 'iPad'],
+ },
+ replace: {
+ replaceMap: emojiMap,
+ },
+ },
+ },
+ ]);
+ };
+
+ return (
+ <>
+
+
+ Emoji
+
+
+ Max Length
+
+
+ Case
+
+
+ Both
+
+
+ Reset
+
+
+
+
+
+ >
+ );
+}
+
+const excludeWords = new Set([
+ 'a',
+ 'an',
+ 'and',
+ 'are',
+ 'as',
+ 'at',
+ 'be',
+ 'but',
+ 'by',
+ 'for',
+ 'from',
+ 'if',
+ 'in',
+ 'into',
+ 'is',
+ 'it',
+ 'no',
+ 'not',
+ 'of',
+ 'on',
+ 'or',
+ 'such',
+ 'that',
+ 'the',
+ 'their',
+ 'then',
+ 'there',
+ 'these',
+ 'they',
+ 'this',
+ 'to',
+ 'was',
+ 'was',
+ 'will',
+ 'with',
+]);
+
+type WordSource = 'description' | 'exact_name' | 'name' | 'tag';
+
+function splitWords(text: string): string[] {
+ return text.toLowerCase().split(/[^\d_a-z]+/);
+}
+
+const emojiMap = new Map<
+ string,
+ (Gemoji & { text: string; type: 'emoji' })[]
+>();
+
+gemoji.forEach((emoji) => {
+ const wordSources = new Map();
+
+ // Priority 1: Exact name matches (highest priority)
+ emoji.names.forEach((name) => {
+ const nameLower = name.toLowerCase();
+ splitWords(name).forEach((word) => {
+ if (!excludeWords.has(word)) {
+ // If the name is exactly this word, it gets highest priority
+ wordSources.set(word, word === nameLower ? 'exact_name' : 'name');
+ }
+ });
+ });
+
+ // Priority 3: Tags
+ emoji.tags.forEach((tag) => {
+ splitWords(tag).forEach((word) => {
+ if (!excludeWords.has(word) && !wordSources.has(word)) {
+ wordSources.set(word, 'tag');
+ }
+ });
+ });
+
+ // Priority 4: Description (lowest priority)
+ if (emoji.description) {
+ splitWords(emoji.description).forEach((word) => {
+ if (!excludeWords.has(word) && !wordSources.has(word)) {
+ wordSources.set(word, 'description');
+ }
+ });
+ }
+
+ wordSources.forEach((source, word) => {
+ if (!emojiMap.has(word)) {
+ emojiMap.set(word, []);
+ }
+
+ const emojis = emojiMap.get(word)!;
+
+ const insertIndex = emojis.findIndex((e) => {
+ const existingSource = getWordSource(e, word);
+
+ return source > existingSource;
+ });
+
+ if (insertIndex === -1) {
+ emojis.push({
+ ...emoji,
+ text: emoji.emoji,
+ type: 'emoji',
+ });
+ } else {
+ emojis.splice(insertIndex, 0, {
+ ...emoji,
+ text: emoji.emoji,
+ type: 'emoji',
+ });
+ }
+ });
+});
+
+function getWordSource(emoji: Gemoji, word: string): WordSource {
+ // Check for exact name match first
+ if (emoji.names.some((name) => name.toLowerCase() === word))
+ return 'exact_name';
+ // Then check for partial name matches
+ if (emoji.names.some((name) => splitWords(name).includes(word)))
+ return 'name';
+ if (emoji.tags.some((tag) => splitWords(tag).includes(word))) return 'tag';
+
+ return 'description';
+}
diff --git a/apps/www/src/registry/default/example/mode-toggle.tsx b/apps/www/src/registry/default/example/mode-toggle.tsx
index a60fa954b9..f9d8af150f 100644
--- a/apps/www/src/registry/default/example/mode-toggle.tsx
+++ b/apps/www/src/registry/default/example/mode-toggle.tsx
@@ -5,14 +5,11 @@ import * as React from 'react';
import { MoonIcon, SunIcon } from 'lucide-react';
import { useTheme } from 'next-themes';
-import { useMounted } from '@/registry/default/hooks/use-mounted';
import { Button } from '@/registry/default/plate-ui/button';
export default function ModeToggle() {
const { setTheme, theme } = useTheme();
- const mounted = useMounted();
-
return (
setTheme(theme === 'dark' ? 'light' : 'dark')}
>
- {mounted && theme === 'dark' ? (
+ {theme === 'dark' ? (
) : (
diff --git a/apps/www/src/registry/default/plate-ui/lint-leaf.tsx b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx
new file mode 100644
index 0000000000..cf693483f9
--- /dev/null
+++ b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+
+import type { LintDecoration } from '@udecode/plate-lint/react';
+
+import { cn, withRef } from '@udecode/cn';
+import { PlateLeaf, useEditorPlugin } from '@udecode/plate-common/react';
+import { ExperimentalLintPlugin } from '@udecode/plate-lint/react';
+
+export const LintLeaf = withRef(
+ ({ children, className, ...props }, ref) => {
+ const { setOption } = useEditorPlugin(ExperimentalLintPlugin);
+ const leaf = props.leaf as LintDecoration;
+
+ return (
+ annotation.type === 'emoji') &&
+ 'text-orange-400',
+ leaf.annotations.some(
+ (annotation) => annotation.type === undefined
+ ) &&
+ 'underline decoration-red-500 underline-offset-2 selection:underline selection:decoration-red-500',
+
+ className
+ )}
+ onMouseDown={() => {
+ setTimeout(() => {
+ setOption('activeAnnotations', leaf.annotations);
+ }, 0);
+ }}
+ {...props}
+ >
+ {children}
+
+ );
+ }
+);
diff --git a/apps/www/src/registry/default/plate-ui/lint-popover.tsx b/apps/www/src/registry/default/plate-ui/lint-popover.tsx
new file mode 100644
index 0000000000..4e0b4b0a1e
--- /dev/null
+++ b/apps/www/src/registry/default/plate-ui/lint-popover.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import React from 'react';
+
+import { cn } from '@udecode/cn';
+import {
+ focusEditor,
+ useEditorPlugin,
+ useHotkeys,
+} from '@udecode/plate-common/react';
+import { useVirtualRefState } from '@udecode/plate-floating';
+import {
+ ExperimentalLintPlugin,
+ useAnnotationSelected,
+} from '@udecode/plate-lint/react';
+
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from '@/registry/default/plate-ui/popover';
+import { Separator } from '@/registry/default/plate-ui/separator';
+import { Toolbar, ToolbarButton } from '@/registry/default/plate-ui/toolbar';
+
+export function LintPopover() {
+ const { api, editor, setOption, tf, useOption } = useEditorPlugin(
+ ExperimentalLintPlugin
+ );
+ const activeAnnotations = useOption('activeAnnotations');
+ const selected = useAnnotationSelected();
+ const toolbarRef = React.useRef(null);
+ const firstButtonRef = React.useRef(null);
+ const [virtualRef] = useVirtualRefState({
+ at: activeAnnotations?.[0]?.range,
+ });
+
+ const open =
+ selected &&
+ !!virtualRef?.current &&
+ activeAnnotations?.some((annotation) => annotation.suggest?.length);
+
+ useHotkeys(
+ 'ctrl+space',
+ (e) => {
+ if (api.lint.setSelectedActiveAnnotations()) {
+ e.preventDefault();
+ }
+ },
+ { enableOnContentEditable: true, enabled: !open }
+ );
+
+ useHotkeys(
+ 'enter',
+ (e) => {
+ const suggestion = activeAnnotations?.[0]?.suggest?.[0];
+
+ if (suggestion) {
+ e.preventDefault();
+
+ suggestion.fix({ goNext: true });
+ }
+ },
+ { enableOnContentEditable: true, enabled: open }
+ );
+
+ useHotkeys(
+ 'down',
+ (e) => {
+ e.preventDefault();
+ firstButtonRef.current?.focus();
+ },
+ { enableOnContentEditable: true, enabled: open }
+ );
+ useHotkeys(
+ 'up',
+ (e) => {
+ if (toolbarRef.current?.contains(document.activeElement)) {
+ e.preventDefault();
+ focusEditor(editor);
+ }
+ },
+ { enableOnContentEditable: true, enabled: open }
+ );
+
+ useHotkeys(
+ 'tab',
+ (e) => {
+ if (tf.lint.focusNextMatch()) {
+ e.preventDefault();
+ }
+ },
+ { enableOnContentEditable: true, enabled: open }
+ );
+
+ useHotkeys(
+ 'shift+tab',
+ (e) => {
+ if (tf.lint.focusNextMatch({ reverse: true })) {
+ e.preventDefault();
+ }
+ },
+ { enableOnContentEditable: true, enabled: open }
+ );
+
+ return (
+
+
+ {
+ e.preventDefault();
+ focusEditor(editor);
+ }}
+ onEscapeKeyDown={(e) => {
+ e.preventDefault();
+ setOption('activeAnnotations', null);
+ }}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ }}
+ >
+
+ {activeAnnotations?.map((annotation, anotIndex) => (
+
+
+ {annotation.suggest?.map((suggestion, suggIndex) => (
+ {
+ suggestion.fix();
+ }}
+ >
+ {suggestion.data?.text as string}
+
+ ))}
+
+
+ {anotIndex < activeAnnotations.length - 1 && (
+
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/www/src/registry/registry-examples.ts b/apps/www/src/registry/registry-examples.ts
index 69d005facc..d5c9987c06 100644
--- a/apps/www/src/registry/registry-examples.ts
+++ b/apps/www/src/registry/registry-examples.ts
@@ -1538,6 +1538,32 @@ export const examples: Registry = [
registryDependencies: [],
type: 'registry:example',
},
+ {
+ dependencies: [
+ '@udecode/plate-lint',
+ '@udecode/plate-basic-marks',
+ '@udecode/plate-node-id',
+ ],
+ doc: {
+ description: 'Lint your document with emoji suggestions.',
+ docs: [
+ {
+ route: '/docs/lint',
+ title: 'Lint',
+ },
+ ],
+ },
+ files: [
+ { path: 'example/lint-demo.tsx', type: 'registry:example' },
+ {
+ path: 'components/editor/use-create-editor.ts',
+ type: 'registry:example',
+ },
+ ],
+ name: 'lint-demo',
+ registryDependencies: ['editor', 'button', 'lint-leaf', 'lint-popover'],
+ type: 'registry:example',
+ },
{
files: [{ path: 'example/mode-toggle.tsx', type: 'registry:example' }],
name: 'mode-toggle',
diff --git a/package.json b/package.json
index 05c25d4f6a..9704ff8889 100644
--- a/package.json
+++ b/package.json
@@ -166,5 +166,8 @@
"node": ">=18.12.0",
"npm": "please-use-yarn",
"yarn": ">=1.22.0"
+ },
+ "dependencies": {
+ "gemoji": "8.1.0"
}
}
diff --git a/packages/floating/src/hooks/index.ts b/packages/floating/src/hooks/index.ts
index 8b64ee9ef8..34d4634028 100644
--- a/packages/floating/src/hooks/index.ts
+++ b/packages/floating/src/hooks/index.ts
@@ -4,3 +4,4 @@
export * from './useFloatingToolbar';
export * from './useVirtualFloating';
+export * from './useVirtualRefState';
diff --git a/packages/floating/src/hooks/useVirtualRefState.ts b/packages/floating/src/hooks/useVirtualRefState.ts
new file mode 100644
index 0000000000..6e3b0de3ac
--- /dev/null
+++ b/packages/floating/src/hooks/useVirtualRefState.ts
@@ -0,0 +1,22 @@
+import React, { useEffect } from 'react';
+
+import type { Range } from 'slate';
+
+import { useEditorRef } from '@udecode/plate-common/react';
+
+import { type VirtualRef, createVirtualRef } from '../utils';
+
+export const useVirtualRefState = ({ at }: { at?: Range | null }) => {
+ const editor = useEditorRef();
+ const [virtualRef, setVirtualRef] = React.useState();
+
+ useEffect(() => {
+ if (at) {
+ setVirtualRef(createVirtualRef(editor, at));
+ } else {
+ setVirtualRef(undefined);
+ }
+ }, [at, editor]);
+
+ return [virtualRef, setVirtualRef] as const;
+};
diff --git a/packages/lint/.npmignore b/packages/lint/.npmignore
new file mode 100644
index 0000000000..7d3b305b17
--- /dev/null
+++ b/packages/lint/.npmignore
@@ -0,0 +1,3 @@
+__tests__
+__test-utils__
+__mocks__
diff --git a/packages/lint/README.md b/packages/lint/README.md
new file mode 100644
index 0000000000..3a7b944da6
--- /dev/null
+++ b/packages/lint/README.md
@@ -0,0 +1,11 @@
+# Plate lint plugin
+
+This package implements the lint plugin for Plate.
+
+## Documentation
+
+Check out [Lint](https://platejs.org/docs/lint).
+
+## License
+
+[MIT](../../LICENSE)
diff --git a/packages/lint/package.json b/packages/lint/package.json
new file mode 100644
index 0000000000..cbb561c142
--- /dev/null
+++ b/packages/lint/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@udecode/plate-lint",
+ "version": "40.2.8",
+ "description": "Lint plugin for Plate",
+ "keywords": [
+ "plate",
+ "plugin",
+ "slate"
+ ],
+ "homepage": "https://platejs.org",
+ "bugs": {
+ "url": "https://github.com/udecode/plate/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/udecode/plate.git",
+ "directory": "packages/lint"
+ },
+ "license": "MIT",
+ "sideEffects": false,
+ "exports": {
+ "./react": {
+ "types": "./dist/react/index.d.ts",
+ "import": "./dist/react/index.mjs",
+ "module": "./dist/react/index.mjs",
+ "require": "./dist/react/index.js"
+ }
+ },
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist/**/*"
+ ],
+ "scripts": {
+ "brl": "yarn p:brl",
+ "build": "yarn p:build",
+ "build:watch": "yarn p:build:watch",
+ "clean": "yarn p:clean",
+ "lint": "yarn p:lint",
+ "lint:fix": "yarn p:lint:fix",
+ "test": "yarn p:test",
+ "test:watch": "yarn p:test:watch",
+ "typecheck": "yarn p:typecheck"
+ },
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "devDependencies": {
+ "@udecode/plate-common": "workspace:^"
+ },
+ "peerDependencies": {
+ "@udecode/plate-common": ">=40.2.8",
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0",
+ "slate": ">=0.103.0",
+ "slate-dom": ">=0.111.0",
+ "slate-history": ">=0.93.0",
+ "slate-hyperscript": ">=0.66.0",
+ "slate-react": ">=0.111.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts
new file mode 100644
index 0000000000..e7cccc036f
--- /dev/null
+++ b/packages/lint/src/index.ts
@@ -0,0 +1,5 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './lib/index';
diff --git a/packages/lint/src/lib/__empty.ts b/packages/lint/src/lib/__empty.ts
new file mode 100644
index 0000000000..30cf8f3bf0
--- /dev/null
+++ b/packages/lint/src/lib/__empty.ts
@@ -0,0 +1 @@
+export const __empty = 1;
diff --git a/packages/lint/src/lib/index.ts b/packages/lint/src/lib/index.ts
new file mode 100644
index 0000000000..e949021279
--- /dev/null
+++ b/packages/lint/src/lib/index.ts
@@ -0,0 +1,5 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './__empty';
diff --git a/packages/lint/src/react/decorateLint.spec.ts b/packages/lint/src/react/decorateLint.spec.ts
new file mode 100644
index 0000000000..60d2190353
--- /dev/null
+++ b/packages/lint/src/react/decorateLint.spec.ts
@@ -0,0 +1,255 @@
+import {
+ createPlateEditor,
+ getEditorPlugin,
+} from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from './lint-plugin';
+import { replaceLintPlugin } from './plugins/lint-plugin-replace';
+
+describe('decorateLint', () => {
+ it('should map annotations to leaf decorations', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ editor.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ ['world', [{ text: '🌍', type: 'emoji' }]],
+ ]),
+ },
+ },
+ },
+ ]);
+
+ const decorations = plugin.decorate?.({
+ ...getEditorPlugin(editor, plugin),
+ entry: [editor, []],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [
+ expect.objectContaining({
+ text: 'hello',
+ type: 'emoji',
+ }),
+ ],
+ focus: { offset: 5, path: [0, 0] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 6, path: [0, 0] },
+ annotations: [
+ expect.objectContaining({
+ text: 'world',
+ type: 'emoji',
+ }),
+ ],
+ focus: { offset: 11, path: [0, 0] },
+ lint: true,
+ },
+ ]);
+ });
+
+ it('should handle annotations spanning multiple leaves', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ { text: 'he' },
+ { bold: true, text: 'll' },
+ { text: 'o wo' },
+ { bold: true, text: 'r' },
+ { text: 'ld' },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ editor.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ ['world', [{ text: '🌍', type: 'emoji' }]],
+ ]),
+ },
+ },
+ },
+ ]);
+
+ const decorations = plugin.decorate?.({
+ ...getEditorPlugin(editor, plugin),
+ entry: [editor, []],
+ });
+
+ expect(decorations).toEqual([
+ // "hello" annotation spans 3 leaves
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [expect.objectContaining({ text: 'hello' })],
+ focus: { offset: 2, path: [0, 0] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [expect.objectContaining({ text: 'hello' })],
+ focus: { offset: 2, path: [0, 1] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 2] },
+ annotations: [expect.objectContaining({ text: 'hello' })],
+ focus: { offset: 1, path: [0, 2] },
+ lint: true,
+ },
+ // "world" annotation spans 3 leaves
+ {
+ anchor: { offset: 2, path: [0, 2] },
+ annotations: [expect.objectContaining({ text: 'world' })],
+ focus: { offset: 4, path: [0, 2] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 3] },
+ annotations: [expect.objectContaining({ text: 'world' })],
+ focus: { offset: 1, path: [0, 3] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 4] },
+ annotations: [expect.objectContaining({ text: 'world' })],
+ focus: { offset: 2, path: [0, 4] },
+ lint: true,
+ },
+ ]);
+ });
+
+ it('should handle annotations at leaf boundaries', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ { text: 'start ' },
+ { bold: true, text: 'hello' },
+ { text: ' middle ' },
+ { bold: true, text: 'world' },
+ { text: ' end' },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ editor.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ ['world', [{ text: '🌍', type: 'emoji' }]],
+ ]),
+ },
+ },
+ },
+ ]);
+
+ const decorations = plugin.decorate?.({
+ ...getEditorPlugin(editor, plugin),
+ entry: [editor, []],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [expect.objectContaining({ text: 'hello' })],
+ focus: { offset: 5, path: [0, 1] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 3] },
+ annotations: [expect.objectContaining({ text: 'world' })],
+ focus: { offset: 5, path: [0, 3] },
+ lint: true,
+ },
+ ]);
+ });
+
+ it('should handle empty leaves', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ { text: '' },
+ { bold: true, text: 'hello' },
+ { text: ' ' },
+ { bold: true, text: 'world' },
+ { text: '' },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ editor.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ ['world', [{ text: '🌍', type: 'emoji' }]],
+ ]),
+ },
+ },
+ },
+ ]);
+
+ const decorations = plugin.decorate?.({
+ ...getEditorPlugin(editor, plugin),
+ entry: [editor, []],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [expect.objectContaining({ text: 'hello' })],
+ focus: { offset: 5, path: [0, 1] },
+ lint: true,
+ },
+ {
+ anchor: { offset: 0, path: [0, 3] },
+ annotations: [expect.objectContaining({ text: 'world' })],
+ focus: { offset: 5, path: [0, 3] },
+ lint: true,
+ },
+ ]);
+ });
+});
diff --git a/packages/lint/src/react/decorateLint.ts b/packages/lint/src/react/decorateLint.ts
new file mode 100644
index 0000000000..3c63d73b93
--- /dev/null
+++ b/packages/lint/src/react/decorateLint.ts
@@ -0,0 +1,26 @@
+import type { Decorate } from '@udecode/plate-common/react';
+
+import { annotationsToDecorations, isEditor } from '@udecode/plate-common';
+
+import type { LintConfig } from './lint-plugin';
+
+export const decorateLint: Decorate = (ctx) => {
+ const {
+ editor,
+ entry: [node],
+ getOptions,
+ } = ctx;
+ const { annotations } = getOptions();
+
+ // Support only blocks for now
+ if (!isEditor(node)) {
+ return [];
+ }
+
+ return annotationsToDecorations(editor, { annotations: annotations }).flatMap(
+ (decoration) => ({
+ ...decoration,
+ lint: true,
+ })
+ );
+};
diff --git a/packages/lint/src/react/index.ts b/packages/lint/src/react/index.ts
new file mode 100644
index 0000000000..41140d56e2
--- /dev/null
+++ b/packages/lint/src/react/index.ts
@@ -0,0 +1,9 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './decorateLint';
+export * from './lint-plugin';
+export * from './types';
+export * from './plugins/index';
+export * from './utils/index';
diff --git a/packages/lint/src/react/lint-plugin.spec.ts b/packages/lint/src/react/lint-plugin.spec.ts
new file mode 100644
index 0000000000..10abcffe4c
--- /dev/null
+++ b/packages/lint/src/react/lint-plugin.spec.ts
@@ -0,0 +1,83 @@
+import { createPlateEditor } from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from './lint-plugin';
+
+jest.mock('@udecode/slate-react', () => ({
+ focusEditor: jest.fn(),
+}));
+
+describe('LintPlugin', () => {
+ it('should set selected active annotation', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [{ text: 'hello' }],
+ type: 'p',
+ },
+ ];
+
+ const activeAnnotation: any = {
+ rangeRef: {
+ current: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ },
+ text: 'hello',
+ };
+ editor.setOption(ExperimentalLintPlugin, 'annotations', [activeAnnotation]);
+ editor.setOption(ExperimentalLintPlugin, 'activeAnnotations', [
+ activeAnnotation,
+ ]);
+
+ editor.selection = {
+ anchor: { offset: 2, path: [0, 0] },
+ focus: { offset: 2, path: [0, 0] },
+ };
+ const result = editor.api.lint.setSelectedActiveAnnotations();
+ expect(result).toBe(true);
+ expect(
+ editor.getOption(ExperimentalLintPlugin, 'activeAnnotations')?.[0]?.text
+ ).toBe('hello');
+ });
+
+ it('should focus next match', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ const activeAnnotation = {
+ rangeRef: {
+ current: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ },
+ text: 'hello',
+ } as any;
+ editor.setOption(ExperimentalLintPlugin, 'annotations', [
+ activeAnnotation,
+ {
+ rangeRef: {
+ current: {
+ anchor: { offset: 6, path: [0, 0] },
+ focus: { offset: 11, path: [0, 0] },
+ },
+ },
+ text: 'world',
+ } as any,
+ ]);
+ editor.setOption(ExperimentalLintPlugin, 'activeAnnotations', [
+ activeAnnotation,
+ ]);
+
+ const match = editor.tf.lint.focusNextMatch();
+ expect(match?.text).toBe('world');
+ expect(
+ editor.getOption(ExperimentalLintPlugin, 'activeAnnotations')?.[0]?.text
+ ).toBe('world');
+ });
+});
diff --git a/packages/lint/src/react/lint-plugin.tsx b/packages/lint/src/react/lint-plugin.tsx
new file mode 100644
index 0000000000..744a751946
--- /dev/null
+++ b/packages/lint/src/react/lint-plugin.tsx
@@ -0,0 +1,119 @@
+import {
+ type PluginConfig,
+ bindFirst,
+ collapseSelection,
+ getNextRange,
+ isSelectionInRange,
+ setSelection,
+} from '@udecode/plate-common';
+import { createTPlatePlugin, focusEditor } from '@udecode/plate-common/react';
+
+import type { LintAnnotation, LintConfigArray } from './types';
+
+import { decorateLint } from './decorateLint';
+import { runLint } from './runLint';
+
+export type LintConfig = PluginConfig<
+ 'lint',
+ {
+ activeAnnotations: LintAnnotation[] | null;
+ annotations: LintAnnotation[];
+ configs: LintConfigArray;
+ },
+ {
+ lint: {
+ getNextMatch: (options?: {
+ reverse: boolean;
+ }) => LintAnnotation | undefined;
+ reset: () => void;
+ run: (configs: LintConfigArray) => void;
+ setSelectedActiveAnnotations: () => boolean | undefined;
+ };
+ },
+ {
+ lint: {
+ focusNextMatch: (options?: {
+ reverse: boolean;
+ }) => LintAnnotation | undefined;
+ };
+ }
+>;
+
+export const ExperimentalLintPlugin = createTPlatePlugin({
+ key: 'lint',
+ decorate: decorateLint,
+ node: {
+ isLeaf: true,
+ },
+ options: {
+ activeAnnotations: null,
+ annotations: [],
+ configs: [],
+ },
+ handlers: {
+ onChange: ({ api, getOptions }) => {
+ const { configs } = getOptions();
+ api.lint.run(configs);
+ },
+ },
+})
+ .extendApi((ctx) => {
+ const { editor, getOptions, setOption } = ctx;
+
+ return {
+ getNextMatch: (options) => {
+ const { activeAnnotations, annotations } = getOptions();
+
+ const ranges = annotations.map(
+ (annotation) => annotation.rangeRef.current!
+ );
+ const nextRange = getNextRange(editor, {
+ from: activeAnnotations?.[0]?.rangeRef.current,
+ ranges,
+ reverse: options?.reverse,
+ });
+
+ if (!nextRange) return;
+
+ return annotations[ranges.indexOf(nextRange)];
+ },
+ reset: () => {
+ setOption('configs', []);
+ setOption('annotations', []);
+ editor.api.redecorate();
+ },
+ run: bindFirst(runLint, editor),
+ setSelectedActiveAnnotations: () => {
+ if (!editor.selection) return false;
+
+ const activeAnnotations = getOptions().annotations.filter((match) =>
+ isSelectionInRange(editor, { at: match.rangeRef.current! })
+ );
+
+ if (activeAnnotations.length > 0) {
+ setOption('activeAnnotations', activeAnnotations);
+
+ return true;
+ }
+
+ return false;
+ },
+ };
+ })
+ .extendTransforms(
+ ({ api, editor, setOption }) => ({
+ focusNextMatch: (options) => {
+ const match = api.lint.getNextMatch(options);
+ // TODO: handle multiple active annotations
+ setOption('activeAnnotations', match ? [match] : null);
+
+ if (match) {
+ collapseSelection(editor);
+ setSelection(editor, match!.rangeRef.current!);
+ focusEditor(editor);
+ }
+
+ return match;
+ },
+ })
+ );
diff --git a/packages/lint/src/react/plugins/index.ts b/packages/lint/src/react/plugins/index.ts
new file mode 100644
index 0000000000..178218e7af
--- /dev/null
+++ b/packages/lint/src/react/plugins/index.ts
@@ -0,0 +1,6 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './lint-plugin-case';
+export * from './lint-plugin-replace';
diff --git a/packages/lint/src/react/plugins/lint-plugin-case.spec.ts b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts
new file mode 100644
index 0000000000..aa9b61383b
--- /dev/null
+++ b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts
@@ -0,0 +1,209 @@
+import { createPlateEditor } from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from '../lint-plugin';
+import { caseLintPlugin } from './lint-plugin-case';
+
+describe('caseLintPlugin', () => {
+ it('should suggest capitalization for sentence starts', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world. this is a test. new sentence.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(3);
+ expect(annotations[0].suggest?.[0].data?.text).toBe('Hello');
+ expect(annotations[1].suggest?.[0].data?.text).toBe('This');
+ expect(annotations[2].suggest?.[0].data?.text).toBe('New');
+ });
+
+ it('should respect ignored words', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'iPhone is great. app is here.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([
+ caseLintPlugin.configs.all,
+ {
+ settings: {
+ case: {
+ ignoredWords: ['iPhone', 'ios'],
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(1); // Only "app" should be flagged
+ expect(annotations[0].suggest?.[0].data?.text).toBe('App');
+ });
+
+ it('should handle fixer actions', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [{ text: 'hello world.' }],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ annotations[0].suggest?.[0].fix();
+ expect(editor.children[0].children[0].text).toBe('Hello world.');
+ });
+
+ it('should only capitalize words at sentence starts', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'The cat is here. cat is there. The cat.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(1);
+ expect(annotations[0].text).toBe('cat');
+ expect(annotations[0].suggest?.[0].data?.text).toBe('Cat');
+ });
+
+ it('should handle multiple sentence endings correctly', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'First sentence! second here? third now. fourth one',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(3); // second, third, fourth should be flagged
+ expect(annotations[0].text).toBe('second');
+ expect(annotations[1].text).toBe('third');
+ expect(annotations[2].text).toBe('fourth');
+ });
+
+ it('should handle multiple spaces after sentence endings', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'One sentence. two spaces. three spaces.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(2);
+ expect(annotations[0].text).toBe('two');
+ expect(annotations[1].text).toBe('three');
+ });
+
+ it('should handle special characters and punctuation', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'Hello world! "this" needs caps. (another) sentence.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(2);
+ expect(annotations[0].text).toBe('this');
+ expect(annotations[1].text).toBe('another');
+ });
+
+ it('should handle empty and whitespace-only strings', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: ' ',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([caseLintPlugin.configs.all]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toHaveLength(0);
+ });
+});
diff --git a/packages/lint/src/react/plugins/lint-plugin-case.ts b/packages/lint/src/react/plugins/lint-plugin-case.ts
new file mode 100644
index 0000000000..12f5899e28
--- /dev/null
+++ b/packages/lint/src/react/plugins/lint-plugin-case.ts
@@ -0,0 +1,149 @@
+import type { LintConfigPlugin, LintConfigPluginRule } from '../types';
+
+export type CaseLintPluginOptions = {
+ ignoredWords?: string[];
+ maxSuggestions?: number;
+};
+
+const caseMatchRule: LintConfigPluginRule = {
+ create: ({ fixer, options }) => {
+ const ignoredWords = options.ignoredWords ?? [];
+
+ return {
+ Annotation: (annotation) => {
+ const text = annotation.text;
+
+ // Skip if word is in ignored list or is part of URL/email
+ if (ignoredWords.includes(text) || /\.|@/.test(text)) {
+ return annotation;
+ }
+ // Skip if not a regular word or already capitalized
+ if (!/^[a-z][\da-z]*$/i.test(text) || /^[A-Z]/.test(text)) {
+ return annotation;
+ }
+ // Check if first letter is lowercase
+ if (text && /^[a-z]/.test(text)) {
+ const suggestion = text.charAt(0).toUpperCase() + text.slice(1);
+
+ return {
+ ...annotation,
+ messageId: 'capitalizeFirstLetter',
+ suggest: [
+ {
+ data: { text: suggestion },
+ fix: (options) => {
+ fixer.replaceText({
+ range: annotation.rangeRef.current!,
+ text: suggestion,
+ ...options,
+ });
+ },
+ },
+ ],
+ };
+ }
+
+ return annotation;
+ },
+ };
+ },
+ meta: {
+ defaultOptions: {
+ ignoredWords: [],
+ },
+ hasSuggestions: true,
+ type: 'suggestion',
+ },
+};
+
+const plugin = {
+ meta: {
+ name: 'case',
+ },
+ rules: {
+ 'capitalize-sentence': caseMatchRule,
+ },
+} satisfies LintConfigPlugin;
+
+export const caseLintPlugin = {
+ ...plugin,
+ configs: {
+ all: {
+ name: 'case/all',
+ plugins: { case: plugin },
+ rules: {
+ 'case/capitalize-sentence': ['error', {}],
+ },
+ settings: {
+ case: {
+ parserOptions: ({ options }) => {
+ // Helper to check if a word is part of URL/email
+ const isUrlOrEmail = (
+ text: string,
+ fullText: string,
+ start: number
+ ) => {
+ // Check if part of email
+ if (text.includes('@')) return true;
+
+ // Check if part of URL (look before and after)
+ const beforeDot = fullText.slice(Math.max(0, start - 10), start);
+ const afterDot = fullText.slice(
+ start + text.length,
+ start + text.length + 10
+ );
+
+ return (
+ /\.[a-z]/i.test(beforeDot + text) ||
+ /^[a-z]*\./.test(text + afterDot)
+ );
+ };
+
+ return {
+ match: (params) => {
+ const {
+ fullText,
+ getContext,
+ start,
+ text: annotation,
+ } = params;
+
+ // Skip ignored words and parts of URLs/emails
+ if (
+ options.ignoredWords?.includes(annotation) ||
+ isUrlOrEmail(annotation, fullText, start)
+ ) {
+ return false;
+ }
+ // Skip if already capitalized
+ if (/^[A-Z]/.test(annotation)) {
+ return false;
+ }
+ // Skip if not a regular word (contains special characters or mixed case)
+ if (
+ !/^[a-z][\da-z]*$/i.test(annotation) ||
+ /[A-Z]/.test(annotation.slice(1))
+ ) {
+ return false;
+ }
+
+ // Get previous context with enough characters for sentence boundaries
+ const prevText = getContext({ before: 5 });
+
+ // Check for sentence boundaries, including quotes and parentheses
+ const isStartOfSentence =
+ start === 0 || // First word in text
+ /[!.?]\s*(?:["')\]}]\s*)*$/.test(prevText) || // Punctuation followed by optional closing chars and whitespace
+ /[!.?]\s*["'([{]\s*$/.test(prevText); // Punctuation followed by opening chars and whitespace
+
+ return isStartOfSentence;
+ },
+ // Update pattern to better match words
+ splitPattern: /\b[A-Za-z][\dA-Za-z]*\b/g,
+ };
+ },
+ },
+ },
+ },
+ },
+} satisfies LintConfigPlugin;
diff --git a/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts b/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts
new file mode 100644
index 0000000000..e89123f215
--- /dev/null
+++ b/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts
@@ -0,0 +1,91 @@
+import { createPlateEditor } from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from '../lint-plugin';
+import { replaceLintPlugin } from './lint-plugin-replace';
+
+describe('replaceLintPlugin', () => {
+ const replaceMap = new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ [
+ 'world',
+ [
+ { text: '🌍', type: 'emoji' },
+ { text: '🌎', type: 'emoji' },
+ ],
+ ],
+ ]);
+
+ it('should decorate matching text', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+
+ // Call run instead of decorate
+ plugin.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap,
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toEqual([
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: {
+ text: '👋',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'hello',
+ type: 'emoji',
+ },
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: {
+ text: '🌍',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ {
+ data: {
+ text: '🌎',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'world',
+ type: 'emoji',
+ },
+ ]);
+ });
+});
diff --git a/packages/lint/src/react/plugins/lint-plugin-replace.ts b/packages/lint/src/react/plugins/lint-plugin-replace.ts
new file mode 100644
index 0000000000..9d7f3a9385
--- /dev/null
+++ b/packages/lint/src/react/plugins/lint-plugin-replace.ts
@@ -0,0 +1,85 @@
+import type {
+ LintAnnotationSuggestion,
+ LintConfigPlugin,
+ LintConfigPluginRule,
+} from '../types';
+
+export type ReplaceLintPluginOptions = {
+ maxSuggestions?: number;
+ replaceMap?: Map;
+};
+
+const replaceMatchRule: LintConfigPluginRule = {
+ create: ({ fixer, options }) => {
+ const replaceMap = options.replaceMap;
+ const maxSuggestions = options.maxSuggestions;
+
+ return {
+ Annotation: (annotation) => {
+ const replacements = replaceMap?.get(annotation.text.toLowerCase());
+
+ return {
+ ...annotation,
+ messageId: 'replaceWithText',
+ suggest: replacements?.slice(0, maxSuggestions).map(
+ (replacement): LintAnnotationSuggestion => ({
+ data: {
+ text: replacement.text,
+ type: replacement.type,
+ },
+ fix: (options) => {
+ fixer.replaceText({
+ range: annotation.rangeRef.current!,
+ text: replacement.text,
+ ...options,
+ });
+ },
+ })
+ ),
+ type: replacements?.[0]?.type,
+ };
+ },
+ };
+ },
+ meta: {
+ defaultOptions: {
+ maxSuggestions: 8,
+ },
+ hasSuggestions: true,
+ type: 'suggestion',
+ },
+};
+
+const plugin = {
+ meta: {
+ name: 'replace',
+ },
+ rules: {
+ text: replaceMatchRule,
+ },
+} satisfies LintConfigPlugin;
+
+export const replaceLintPlugin = {
+ ...plugin,
+ configs: {
+ all: {
+ name: 'replace/all',
+ plugins: { replace: plugin },
+ rules: {
+ 'replace/text': ['error', {}],
+ },
+ settings: {
+ replace: {
+ parserOptions: ({ options }) => {
+ return {
+ match: ({ text }) => {
+ return !!options.replaceMap?.has(text.toLowerCase());
+ },
+ splitPattern: /\b[\dA-Za-z]+(?:['-]\w+)*\b/g,
+ };
+ },
+ },
+ },
+ },
+ },
+} satisfies LintConfigPlugin;
diff --git a/packages/lint/src/react/runLint.spec.ts b/packages/lint/src/react/runLint.spec.ts
new file mode 100644
index 0000000000..868c5355cc
--- /dev/null
+++ b/packages/lint/src/react/runLint.spec.ts
@@ -0,0 +1,332 @@
+import { createPlateEditor } from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from './lint-plugin';
+import { caseLintPlugin } from './plugins';
+import { replaceLintPlugin } from './plugins/lint-plugin-replace';
+
+describe('runLint', () => {
+ const replaceMap = new Map([
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ [
+ 'world',
+ [
+ { text: '🌍', type: 'emoji' },
+ { text: '🌎', type: 'emoji' },
+ ],
+ ],
+ ]);
+
+ it('should decorate matching text', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap,
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toEqual([
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: {
+ text: '👋',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'hello',
+ type: 'emoji',
+ },
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: {
+ text: '🌍',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ {
+ data: {
+ text: '🌎',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'world',
+ type: 'emoji',
+ },
+ ]);
+ });
+
+ it('should handle targets in config', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ const nodeId = 'test-node';
+ editor.children = [
+ {
+ id: nodeId,
+ children: [
+ {
+ text: 'hello',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([
+ {
+ ...replaceLintPlugin.configs.all,
+ targets: [{ id: nodeId }],
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap,
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toEqual([
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: {
+ text: '👋',
+ type: 'emoji',
+ },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'hello',
+ type: 'emoji',
+ },
+ ]);
+ });
+
+ it('should skip non-block nodes', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap,
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toEqual([]);
+ });
+
+ it('should handle empty configs', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations).toEqual([]);
+ });
+
+ it('should handle fixer actions', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [{ text: 'hello' }],
+ type: 'p',
+ },
+ ];
+
+ const plugin = editor.getPlugin(ExperimentalLintPlugin);
+ plugin.api.lint.run([
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: { replaceMap: new Map([['hello', [{ text: '👋' }]]]) },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+ expect(annotations[0]?.suggest?.[0].fix).toBeDefined();
+
+ annotations[0]?.suggest?.[0].fix();
+ expect(editor.children[0].children[0].text).toBe('👋');
+ });
+
+ it('should handle multiple plugins (case and replace)', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world. this is a test.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ editor.api.lint.run([
+ replaceLintPlugin.configs.all,
+ caseLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([['world', [{ text: '🌍', type: 'emoji' }]]]),
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+
+ // Should have both emoji replacements and case fixes
+ expect(annotations).toEqual([
+ // Replace annotations
+ {
+ messageId: 'replaceWithText',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: { text: '🌍', type: 'emoji' },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'world',
+ type: 'emoji',
+ },
+ // Case annotation for 'hello'
+ {
+ messageId: 'capitalizeFirstLetter',
+ range: expect.any(Object),
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: { text: 'Hello' },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'hello',
+ },
+ // Case annotation for 'this'
+ {
+ messageId: 'capitalizeFirstLetter',
+ range: {
+ anchor: { offset: 13, path: [0, 0] },
+ focus: { offset: 17, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ suggest: [
+ {
+ data: { text: 'This' },
+ fix: expect.any(Function),
+ },
+ ],
+ text: 'this',
+ },
+ ]);
+ });
+
+ it('should only lint targeted block when target specified', () => {
+ const editor = createPlateEditor({
+ plugins: [ExperimentalLintPlugin],
+ });
+
+ // Create two blocks with different content
+ const firstBlockId = 'block-1';
+ const secondBlockId = 'block-2';
+ editor.children = [
+ {
+ id: firstBlockId,
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ {
+ id: secondBlockId,
+ children: [{ text: 'hello earth' }],
+ type: 'p',
+ },
+ ];
+
+ editor.api.lint.run([
+ {
+ ...replaceLintPlugin.configs.all,
+ targets: [{ id: firstBlockId }], // Only target first block
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([
+ ['earth', [{ text: '🌏', type: 'emoji' }]],
+ ['hello', [{ text: '👋', type: 'emoji' }]],
+ ['world', [{ text: '�', type: 'emoji' }]],
+ ]),
+ },
+ },
+ },
+ ]);
+
+ const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations');
+
+ // Should only have annotations for "hello" and "world" from first block
+ expect(annotations).toHaveLength(2);
+ expect(annotations[0].text).toBe('hello');
+ expect(annotations[1].text).toBe('world');
+ expect(annotations.some((a) => a.text === 'earth')).toBe(false);
+ });
+});
diff --git a/packages/lint/src/react/runLint.ts b/packages/lint/src/react/runLint.ts
new file mode 100644
index 0000000000..ff9106ab43
--- /dev/null
+++ b/packages/lint/src/react/runLint.ts
@@ -0,0 +1,102 @@
+import {
+ collapseSelection,
+ deleteText,
+ experimental_parseNode,
+ replaceText,
+} from '@udecode/plate-common';
+import {
+ type PlateEditor,
+ type PlatePluginContext,
+ getEditorPlugin,
+} from '@udecode/plate-common/react';
+import { Range } from 'slate';
+
+import type {
+ LintConfigArray,
+ LintConfigPluginRuleContext,
+ LintFixer,
+} from './types';
+
+import { ExperimentalLintPlugin } from './lint-plugin';
+import { resolveLintConfigs } from './utils/resolveLintConfigs';
+
+export const runLint = (editor: PlateEditor, configs: LintConfigArray) => {
+ const ctx = getEditorPlugin(editor, ExperimentalLintPlugin);
+ const { setOption, tf } = ctx;
+
+ setOption('configs', configs);
+
+ // Create fixer actions once
+ const fixerActions: LintFixer = {
+ insertTextAfter: ({ range, text }) => {
+ const point = Range.end(range);
+ editor.insertText(text, { at: point });
+ },
+ insertTextBefore: ({ range, text }) => {
+ const point = Range.start(range);
+ editor.insertText(text, { at: point });
+ },
+ remove: ({ range }) => {
+ deleteText(editor, { at: range });
+ },
+ replaceText: ({ range, text }) => {
+ replaceText(editor, {
+ at: range,
+ text,
+ });
+ },
+ };
+
+ const annotations = editor.children.flatMap((node, index) => {
+ const resolvedRules = resolveLintConfigs(configs, {
+ id: node.id as string,
+ });
+
+ // Process each rule
+ return Object.entries(resolvedRules).flatMap(([ruleId, rule]) => {
+ const fixer = Object.fromEntries(
+ Object.entries(fixerActions).map(([key, fn]) => [
+ key,
+ (options: any) => {
+ fn(options);
+
+ if (options.goNext) {
+ setTimeout(() => {
+ tf.lint.focusNextMatch();
+ }, 0);
+ } else {
+ collapseSelection(editor);
+ setTimeout(() => {
+ setOption('activeAnnotations', null);
+ }, 0);
+ }
+ },
+ ])
+ ) as LintFixer;
+
+ const context: LintConfigPluginRuleContext = {
+ ...(ctx as unknown as PlatePluginContext),
+ id: ruleId,
+ fixer,
+ options: rule.options ?? [],
+ };
+
+ const parserOptions = rule.options.parserOptions(context);
+
+ const { Annotation } = rule.create(context);
+ const { annotations } = experimental_parseNode(editor, {
+ at: [index],
+ match: parserOptions?.match ?? (() => false),
+ maxLength: parserOptions?.maxLength,
+ minLength: parserOptions?.minLength,
+ splitPattern: parserOptions?.splitPattern,
+ transform: Annotation,
+ });
+
+ return annotations;
+ });
+ });
+
+ setOption('annotations', annotations);
+ editor.api.redecorate();
+};
diff --git a/packages/lint/src/react/types.ts b/packages/lint/src/react/types.ts
new file mode 100644
index 0000000000..ee314f3016
--- /dev/null
+++ b/packages/lint/src/react/types.ts
@@ -0,0 +1,230 @@
+import type {
+ Annotation,
+ AnyObject,
+ TText,
+ UnknownObject,
+} from '@udecode/plate-common';
+import type { PlatePluginContext } from '@udecode/plate-common/react';
+import type { Range } from 'slate';
+
+// ─── Plate ──────────────────────────────────────────────────────────────────
+
+export type LintDecoration = TText & {
+ annotations: LintAnnotation[];
+ lint: boolean;
+};
+
+export type LintAnnotation = Annotation<{
+ messageId?: string;
+ suggest?: LintAnnotationSuggestion[];
+}>;
+
+export type LintAnnotationSuggestion = {
+ fix: (options?: { goNext?: boolean }) => void;
+ data?: UnknownObject;
+ messageId?: string;
+};
+
+// ─── Config ──────────────────────────────────────────────────────────────────
+export type LintConfigArray = LintConfigObject[];
+
+export type LintConfigObject = {
+ /** An object containing settings related to the linting process. */
+ linterOptions?: AnyObject;
+ /**
+ * A name for the configuration object. This is used in error messages and
+ * config inspector to help identify which configuration object is being
+ * used.
+ */
+ name?: string;
+ /**
+ * An object containing a name-value mapping of plugin names to plugin
+ * objects.
+ */
+ plugins?: LintConfigPlugins;
+ /**
+ * An object containing the configured rules. These rule configurations are
+ * only available to the matching targets
+ */
+ rules?: LintConfigRules;
+ /**
+ * An object containing name-value pairs of information that should be
+ * available to all rules.
+ */
+ settings?: LintConfigSettings>;
+ /** The targets to match. */
+ targets?: { id?: string }[];
+};
+
+export type LintConfigRuleOptions = {
+ parserOptions?:
+ | ((context: LintConfigPluginRuleContext) => LintParserOptions)
+ | LintParserOptions;
+} & T &
+ UnknownObject;
+
+export type LintConfigSettings = Record<
+ string,
+ LintConfigRuleOptions
+>;
+
+export type LintAnnotationOptions = {
+ annotations?: {
+ match?: (annotation: string) => boolean;
+ splitPattern?: RegExp;
+ };
+};
+
+// ─── Config Rules ─────────────────────────────────────────────────────────────
+
+export type LintConfigRule =
+ | LintConfigRuleLevel
+ | LintConfigRuleLevelAndOptions
+ | [LintConfigRuleLevel];
+
+export type LintConfigRuleLevel =
+ | LintConfigRuleSeverity
+ | LintConfigRuleSeverityString;
+
+export type LintConfigRuleLevelAndOptions = [
+ LintConfigRuleLevel,
+ LintConfigRuleOptions,
+];
+
+export type LintConfigRules = Partial>;
+
+export type LintConfigRuleSeverity = 0 | 1 | 2;
+
+export type LintConfigRuleSeverityString = 'error' | 'off' | 'warn';
+
+// ─── Config Plugin ────────────────────────────────────────────────────────────
+
+export type LintConfigPlugin = {
+ /**
+ * The definition of plugin rules. The key must be the name of the rule that
+ * users will use. Users can stringly reference the rule using the key they
+ * registered the plugin under combined with the rule name. i.e. for the user
+ * config `plugins: { foo: pluginReference }` - the reference would be
+ * `"foo/ruleName"`.
+ */
+ rules: Record;
+ meta?: {
+ name?: string;
+ version?: string;
+ };
+ configs?: Record>;
+};
+
+export type LintConfigPluginRule = {
+ /**
+ * Returns an object with methods that the linter calls to process text
+ * annotations while traversing the document during decoration.
+ */
+ create: (context: LintConfigPluginRuleContext) => {
+ /** A function that transforms an annotation. */
+ Annotation: (annotation: LintAnnotation) => LintAnnotation;
+ };
+ meta: {
+ docs?: {
+ description?: string;
+ };
+ /**
+ * Specifies default options for the rule. If present, any user-provided
+ * options in their config will be merged on top of them recursively.
+ */
+ defaultOptions?: LintConfigRuleOptions>;
+ hasSuggestions?: boolean;
+ messages?: Record;
+ type?: 'problem' | 'suggestion';
+ };
+};
+
+export type LintConfigPluginRuleContext = Pick<
+ ResolvedLintRule,
+ 'options'
+> & {
+ /** The id of the rule. */
+ id: string;
+ /** A function that fixes the linting issue. */
+ fixer: LintFixer;
+} & PlatePluginContext;
+
+export type LintConfigPluginRules = Record;
+
+export type LintConfigPlugins = Record;
+
+export type LintFixer = {
+ insertTextAfter: ({ range, text }: { range: Range; text: string }) => void;
+ insertTextBefore: ({ range, text }: { range: Range; text: string }) => void;
+ remove: ({ range }: { range: Range }) => void;
+ replaceText: ({ range, text }: { range: Range; text: string }) => void;
+};
+
+// ─── Parser ──────────────────────────────────────────────────────────────────
+
+export type LintParserOptions = {
+ /** Function to match annotations and return match result */
+ match?: (params: {
+ end: number;
+ fullText: string;
+ getContext: (options: { after?: number; before?: number }) => string;
+ start: number;
+ text: string;
+ }) => AnyObject | boolean;
+ /** Maximum length of annotations to process */
+ maxLength?: number;
+ /** Minimum length of annotations to process */
+ minLength?: number;
+ /** Pattern for matching annotations in text */
+ splitPattern?: RegExp;
+};
+
+// ─── Resolved Rules ────────────────────────────────────────────────────────────
+
+export type ResolvedLintRule = Pick<
+ LintConfigPluginRule,
+ 'create' | 'meta'
+> &
+ Pick, 'targets'> & {
+ linterOptions: {
+ severity: LintConfigRuleSeverityString;
+ };
+ context: LintConfigPluginRuleContext;
+ name: string;
+ /**
+ * An array of the configured options for this rule. This array does not
+ * include the rule severity.
+ */
+ options: ResolvedLintRuleOptions;
+ };
+
+export type ResolvedParserOptions = (
+ context: LintConfigPluginRuleContext
+) => LintParserOptions;
+
+export type ResolvedLintRuleOptions = {
+ parserOptions: ResolvedParserOptions;
+} & T &
+ UnknownObject;
+
+export type ResolvedLintRules = Record;
+
+// export type LintAnalysisType =
+// | 'word' // Single words (like emoji matching)
+// | 'phrase' // Multiple words (like grammar checking)
+// | 'sentence' // Full sentences (like style suggestions)
+// | 'paragraph' // Whole paragraphs (like structure analysis)
+// | 'custom'; // Custom analysis
+
+// export type LintParserOptions = {
+// /** Type of analysis to perform */
+// analysisType?: LintAnalysisType;
+// /** Custom pattern for annotation extraction */
+// pattern?: RegExp;
+// /** Additional conditions for matching */
+// match?: (annotation: string) => boolean;
+// /** Minimum annotation length */
+// minLength?: number;
+// /** Maximum annotation length */
+// maxLength?: number;
+// };
diff --git a/packages/lint/src/react/utils/index.ts b/packages/lint/src/react/utils/index.ts
new file mode 100644
index 0000000000..08756c0bd9
--- /dev/null
+++ b/packages/lint/src/react/utils/index.ts
@@ -0,0 +1,6 @@
+/**
+ * @file Automatically generated by barrelsby.
+ */
+
+export * from './resolveLintConfigs';
+export * from './useAnnotationSelected';
diff --git a/packages/lint/src/react/utils/resolveLintConfigs.spec.ts b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts
new file mode 100644
index 0000000000..37f444b096
--- /dev/null
+++ b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts
@@ -0,0 +1,495 @@
+/* eslint-disable jest/no-conditional-expect */
+import type { LintConfigArray } from '../types';
+
+import { caseLintPlugin } from '../plugins';
+import { replaceLintPlugin } from '../plugins/lint-plugin-replace';
+import { resolveLintConfigs } from './resolveLintConfigs';
+
+/**
+ * - ✅ Basic config merging
+ * - ✅ Settings merging
+ * - ✅ Language options merging
+ * - ✅ Rule severity handling (both numeric and string)
+ * - ✅ Disabled rules
+ * - ✅ Invalid configs
+ * - ✅ Empty/undefined configs
+ * - ✅ Targets handling
+ * - ✅ Function merging in parser options
+ */
+
+describe('resolveLintConfigs', () => {
+ const replaceMap = new Map([
+ ['hello', [{ emoji: '👋' }]],
+ ['world', [{ emoji: '🌍' }, { emoji: '🌎' }]],
+ ]);
+
+ it('should merge multiple configs correctly', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey]).toBeDefined();
+ expect(result[ruleKey].name).toBe(ruleKey);
+ expect(result[ruleKey].linterOptions.severity).toBe('error');
+ expect(result[ruleKey].options.replaceMap).toBe(replaceMap);
+ expect(result[ruleKey].options.maxSuggestions).toBe(8);
+ });
+
+ it('should handle language options merging', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ parserOptions: {
+ match: (params) => true,
+ minLength: 4,
+ },
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey].options.parserOptions).toBeDefined();
+
+ const parserOptions = result[ruleKey].options.parserOptions({
+ options: result[ruleKey].options,
+ } as any);
+
+ // Should have both default and user settings
+ expect(parserOptions.minLength).toBe(4); // User setting
+ expect(parserOptions.splitPattern).toBeDefined(); // Default setting
+ expect(typeof parserOptions.match).toBe('function');
+
+ // Test the merged match function
+ expect(
+ parserOptions.match?.({
+ end: 5,
+ fullText: 'hello world',
+ getContext: () => '',
+ start: 0,
+ text: 'hello',
+ })
+ ).toBe(true);
+ });
+
+ it('should handle rule severity levels', () => {
+ const configs: LintConfigArray = [
+ {
+ ...replaceLintPlugin.configs.all,
+ rules: {
+ 'replace/text': ['warn'],
+ },
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ expect(result['replace/text'].linterOptions.severity).toBe('warn');
+ });
+
+ it('should handle numeric severity levels', () => {
+ const configs: LintConfigArray = [
+ {
+ ...replaceLintPlugin.configs.all,
+ rules: {
+ 'replace/text': [2],
+ },
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ expect(result['replace/text'].linterOptions.severity).toBe('error');
+ });
+
+ it('should skip disabled rules', () => {
+ const configs: LintConfigArray = [
+ {
+ ...replaceLintPlugin.configs.all,
+ rules: {
+ 'replace/text': 'off',
+ },
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ expect(result['replace/text']).toBeUndefined();
+ });
+
+ it('should return empty object for invalid configs', () => {
+ const configs: LintConfigArray = [
+ {
+ rules: {
+ 'nonexistent/rule': ['error'],
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ expect(result).toEqual({});
+ });
+
+ it('should merge multiple configs with different settings', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ maxSuggestions: 5,
+ parserOptions: {
+ match: (params) => true,
+ maxLength: 4,
+ },
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey].options.maxSuggestions).toBe(5);
+ expect(result[ruleKey].options.replaceMap).toBe(replaceMap);
+ expect(result[ruleKey].options.parserOptions).toBeDefined();
+ });
+
+ it('should merge settings from multiple configs', () => {
+ const configs: LintConfigArray = [
+ {
+ plugins: { replace: replaceLintPlugin },
+ rules: {
+ 'replace/text': ['error', {}],
+ },
+ settings: {
+ replace: {
+ parserOptions: {
+ match: ({ text }) => text.length > 2,
+ },
+ setting1: 'value1',
+ },
+ },
+ },
+ {
+ settings: {
+ replace: {
+ parserOptions: {
+ minLength: 3,
+ },
+ setting2: 'value2',
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+ expect(result[ruleKey].options.parserOptions({} as any)).toEqual({
+ match: expect.any(Function),
+ minLength: 3,
+ });
+
+ expect(result[ruleKey]).toBeDefined();
+ expect(result[ruleKey].options).toMatchObject({
+ maxSuggestions: 8,
+ setting1: 'value1',
+ setting2: 'value2',
+ });
+ });
+
+ it('should handle targets in config objects', () => {
+ const configs: LintConfigArray = [
+ {
+ ...replaceLintPlugin.configs.all,
+ targets: [{ id: 'target1' }, { id: 'target2' }],
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey]).toBeDefined();
+ expect(result[ruleKey].options.replaceMap).toBe(replaceMap);
+ });
+
+ it('should merge parser options correctly when both are objects', () => {
+ const configs: LintConfigArray = [
+ {
+ plugins: { replace: replaceLintPlugin },
+ rules: {
+ 'replace/text': ['error'],
+ },
+ settings: {
+ replace: {
+ parserOptions: {
+ match: (params) => params.text.length > 2,
+ splitPattern: /\w+/g,
+ },
+ },
+ },
+ },
+ {
+ settings: {
+ replace: {
+ parserOptions: {
+ match: (params) => params.text.length > 4,
+ minLength: 3,
+ },
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey]).toBeDefined();
+ const parserOptions = result[ruleKey].options.parserOptions({} as any);
+
+ expect(parserOptions).toBeDefined();
+ expect(parserOptions).toHaveProperty('match');
+ expect(parserOptions).toHaveProperty('splitPattern');
+ expect(parserOptions).toHaveProperty('minLength');
+ });
+
+ it('should handle empty or undefined configs gracefully', () => {
+ const configs: LintConfigArray = [{}, replaceLintPlugin.configs.all, {}];
+
+ const result = resolveLintConfigs(configs);
+ expect(result).toBeDefined();
+ expect(Object.keys(result).length).toBeGreaterThan(0);
+ });
+
+ it('should handle function merging in parser options', () => {
+ const configs: LintConfigArray = [
+ {
+ plugins: { replace: replaceLintPlugin },
+ rules: {
+ 'replace/text': ['error'],
+ },
+ settings: {
+ replace: {
+ parserOptions: (context) => ({
+ match: (params) => true,
+ splitPattern: /\w+/g,
+ }),
+ },
+ },
+ },
+ {
+ settings: {
+ replace: {
+ parserOptions: (context) => ({
+ match: (params) => true,
+ splitPattern: /\w+/g,
+ }),
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey]).toBeDefined();
+ const parserOptions = result[ruleKey].options.parserOptions;
+ expect(typeof parserOptions).toBe('function');
+ });
+
+ it('should preserve each plugin parserOptions when merging multiple plugins', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all,
+ caseLintPlugin.configs.all,
+ {
+ settings: {
+ case: {
+ ignoredWords: ['test'],
+ },
+ replace: {
+ replaceMap: new Map([['hello', [{ text: '👋' }]]]),
+ },
+ },
+ },
+ ];
+
+ const resolvedRules = resolveLintConfigs(configs);
+
+ // Check replace rule's parserOptions
+ const replaceRule = resolvedRules['replace/text'];
+
+ const replaceParserOptions = replaceRule.options.parserOptions({
+ options: replaceRule.options,
+ } as any);
+
+ // Verify replace plugin's splitPattern
+ expect(replaceParserOptions.splitPattern).toEqual(
+ /\b[\dA-Za-z]+(?:['-]\w+)*\b/g
+ );
+
+ // Check case rule's parserOptions
+ const caseRule = resolvedRules['case/capitalize-sentence'];
+ const caseParsed = caseRule.options.parserOptions({
+ options: {
+ ignoredWords: ['test'],
+ },
+ } as any);
+
+ // Verify case plugin's splitPattern
+ expect(caseParsed.splitPattern).toEqual(/\b[A-Za-z][\dA-Za-z]*\b/g);
+
+ // Test that each plugin's match function works correctly
+ expect(
+ replaceParserOptions.match?.({
+ end: 5,
+ fullText: 'hello world',
+ getContext: () => '',
+ start: 0,
+ text: 'hello',
+ })
+ ).toBe(true);
+
+ expect(
+ caseParsed.match?.({
+ end: 5,
+ fullText: 'hello world. test here.',
+ getContext: ({ before = 0 }) => '',
+ start: 0,
+ text: 'hello',
+ })
+ ).toBe(true);
+ });
+
+ it('should handle parser options in settings', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all,
+ {
+ settings: {
+ replace: {
+ parserOptions: {
+ match: ({ text }) => text.length > 4,
+ minLength: 3,
+ },
+ replaceMap: replaceMap,
+ },
+ },
+ },
+ ];
+
+ const result = resolveLintConfigs(configs);
+ const ruleKey = 'replace/text';
+
+ expect(result[ruleKey]).toBeDefined();
+ const parsedOptions = result[ruleKey].options.parserOptions({} as any);
+
+ expect(parsedOptions).toBeDefined();
+ expect(typeof parsedOptions.match).toBe('function');
+ expect(parsedOptions.minLength).toBe(3);
+
+ // Test the match function
+ expect(
+ parsedOptions.match?.({
+ end: 6,
+ fullText: 'longer',
+ getContext: () => '',
+ start: 0,
+ text: 'longer',
+ })
+ ).toBe(true);
+ });
+
+ it('should filter configs by targets', () => {
+ const configs: LintConfigArray = [
+ {
+ ...replaceLintPlugin.configs.all,
+ targets: [{ id: 'node1' }],
+ },
+ {
+ ...caseLintPlugin.configs.all,
+ targets: [{ id: 'node2' }],
+ },
+ {
+ settings: {
+ replace: {
+ replaceMap: new Map([['hello', [{ text: '👋' }]]]),
+ },
+ },
+ },
+ ];
+
+ // Test node1 target
+ const node1Result = resolveLintConfigs(configs, { id: 'node1' });
+ expect(node1Result['replace/text']).toBeDefined();
+ expect(node1Result['case/capitalize-sentence']).toBeUndefined();
+
+ // Test node2 target
+ const node2Result = resolveLintConfigs(configs, { id: 'node2' });
+ expect(node2Result['replace/text']).toBeUndefined();
+ expect(node2Result['case/capitalize-sentence']).toBeDefined();
+
+ // Test no target (should apply all)
+ const allResult = resolveLintConfigs(configs);
+ expect(allResult['replace/text']).toBeDefined();
+ expect(allResult['case/capitalize-sentence']).toBeDefined();
+ });
+
+ it('should handle configs with no targets', () => {
+ const configs: LintConfigArray = [
+ replaceLintPlugin.configs.all, // No targets = applies to all
+ {
+ ...caseLintPlugin.configs.all,
+ targets: [{ id: 'node1' }],
+ },
+ ];
+
+ // Test specific target
+ const node1Result = resolveLintConfigs(configs, { id: 'node1' });
+ expect(node1Result['replace/text']).toBeDefined(); // No targets = applies to all
+ expect(node1Result['case/capitalize-sentence']).toBeDefined();
+
+ // Test different target
+ const node2Result = resolveLintConfigs(configs, { id: 'node2' });
+ expect(node2Result['replace/text']).toBeDefined(); // No targets = applies to all
+ expect(node2Result['case/capitalize-sentence']).toBeUndefined();
+ });
+});
diff --git a/packages/lint/src/react/utils/resolveLintConfigs.ts b/packages/lint/src/react/utils/resolveLintConfigs.ts
new file mode 100644
index 0000000000..d7aa5b5372
--- /dev/null
+++ b/packages/lint/src/react/utils/resolveLintConfigs.ts
@@ -0,0 +1,126 @@
+import type {
+ LintConfigArray,
+ LintConfigRule,
+ LintConfigRuleLevel,
+ ResolvedLintRules,
+} from '../types';
+
+/** Convert parserOptions to a function */
+const toParserFunction = (parserOptions: any) => {
+ if (typeof parserOptions === 'function') {
+ return parserOptions;
+ }
+ if (parserOptions) {
+ return () => parserOptions;
+ }
+
+ return () => ({});
+};
+
+/** Merge plugin settings from multiple configs */
+const mergePluginSettings = (configs: LintConfigArray, pluginName: string) => {
+ return configs.reduce((acc, config) => {
+ const pluginSettings = config.settings?.[pluginName];
+
+ if (!pluginSettings) return acc;
+ // Special handling for parserOptions
+ if (pluginSettings.parserOptions) {
+ const accParserFn = toParserFunction(acc.parserOptions);
+ const newParserFn = toParserFunction(pluginSettings.parserOptions);
+
+ return {
+ ...acc,
+ ...pluginSettings,
+ parserOptions: (ctx: any) => ({
+ ...accParserFn(ctx),
+ ...newParserFn(ctx),
+ }),
+ };
+ }
+
+ // Merge other settings
+ return {
+ ...acc,
+ ...pluginSettings,
+ };
+ }, {} as any);
+};
+
+export function resolveLintConfigs(
+ configs: LintConfigArray,
+ target?: { id: string }
+): ResolvedLintRules {
+ // Filter configs by target
+ const filteredConfigs = target
+ ? configs.filter(
+ (config) =>
+ !config?.targets || // No targets = applies to all
+ config.targets.some((t) => t.id === target.id)
+ )
+ : configs;
+
+ // Merge plugins and rules in order
+ const { plugins, rules } = filteredConfigs.reduce(
+ (acc, config) => ({
+ plugins: { ...acc.plugins, ...config.plugins },
+ rules: { ...acc.rules, ...config.rules },
+ }),
+ { plugins: {}, rules: {} }
+ );
+
+ if (!plugins || !rules) return {};
+
+ // Resolve rules
+ return Object.entries(rules).reduce((rulesAcc, [ruleId, entry]) => {
+ const [pluginName, ruleName] = ruleId.split('/');
+ const plugin = plugins[pluginName];
+ const rule = plugin?.rules?.[ruleName];
+
+ // Skip if plugin or rule not found
+ if (!plugin || !rule) return rulesAcc;
+
+ const ruleConfig = entry as LintConfigRule;
+ const severity = Array.isArray(ruleConfig)
+ ? normalizeSeverity(ruleConfig[0])
+ : normalizeSeverity(ruleConfig);
+
+ if (severity === 'off') return rulesAcc;
+
+ const userOptions: any = Array.isArray(ruleConfig)
+ ? (ruleConfig[1] ?? {})
+ : {};
+ const defaultOptions: any = rule.meta.defaultOptions ?? {};
+ const settings: any = mergePluginSettings(configs, pluginName);
+
+ // Merge options, preserving plugin's parserOptions
+ const options = {
+ ...defaultOptions,
+ ...settings,
+ ...userOptions,
+ parserOptions: (ctx: any) => ({
+ ...defaultOptions.parserOptions?.(ctx),
+ ...settings.parserOptions?.(ctx),
+ ...userOptions.parserOptions?.(ctx),
+ }),
+ };
+
+ return {
+ ...rulesAcc,
+ [ruleId]: {
+ create: rule.create,
+ linterOptions: { severity },
+ meta: rule.meta,
+ name: ruleId,
+ options,
+ },
+ };
+ }, {} as any);
+}
+
+function normalizeSeverity(level: LintConfigRuleLevel) {
+ if (typeof level === 'number') {
+ return level === 0 ? 'off' : level === 1 ? 'warn' : 'error';
+ }
+
+ return level;
+}
diff --git a/packages/lint/src/react/utils/useAnnotationSelected.ts b/packages/lint/src/react/utils/useAnnotationSelected.ts
new file mode 100644
index 0000000000..fbd9a1934f
--- /dev/null
+++ b/packages/lint/src/react/utils/useAnnotationSelected.ts
@@ -0,0 +1,32 @@
+import { isSelectionInRange } from '@udecode/plate-common';
+import {
+ useEditorPlugin,
+ useEditorSelector,
+} from '@udecode/plate-common/react';
+
+import { ExperimentalLintPlugin } from '../lint-plugin';
+
+export const useAnnotationSelected = () => {
+ const { useOption } = useEditorPlugin(ExperimentalLintPlugin);
+ const activeAnnotations = useOption('activeAnnotations');
+
+ return useEditorSelector(
+ (editor) => {
+ if (!editor.selection || !activeAnnotations?.length) return false;
+
+ const range = activeAnnotations[0].rangeRef.current;
+
+ if (
+ range &&
+ isSelectionInRange(editor, {
+ at: range,
+ })
+ ) {
+ return true;
+ }
+
+ return false;
+ },
+ [activeAnnotations]
+ );
+};
diff --git a/packages/lint/tsconfig.build.json b/packages/lint/tsconfig.build.json
new file mode 100644
index 0000000000..425481e027
--- /dev/null
+++ b/packages/lint/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../config/tsconfig.build.json",
+ "compilerOptions": {
+ "declarationDir": "./dist",
+ "outDir": "./dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/lint/tsconfig.json b/packages/lint/tsconfig.json
new file mode 100644
index 0000000000..ad83d092a5
--- /dev/null
+++ b/packages/lint/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../config/tsconfig.base.json",
+ "include": ["src"],
+ "exclude": []
+}
diff --git a/packages/slate-utils/src/queries/__tests__/getNextSiblingNodes/two-siblings.spec.tsx b/packages/slate-utils/src/queries/__tests__/getNextSiblingNodes/two-siblings.spec.tsx
index b278ec7f6d..9642776d17 100644
--- a/packages/slate-utils/src/queries/__tests__/getNextSiblingNodes/two-siblings.spec.tsx
+++ b/packages/slate-utils/src/queries/__tests__/getNextSiblingNodes/two-siblings.spec.tsx
@@ -1,12 +1,13 @@
/** @jsx jsxt */
+import type { SlateEditor } from '@udecode/plate-core';
import type { Range } from 'slate';
-import { type SlateEditor, getBlockAbove } from '@udecode/plate-common';
import { createPlateEditor } from '@udecode/plate-common/react';
import { LinkPlugin } from '@udecode/plate-link/react';
import { jsxt } from '@udecode/plate-test-utils';
+import { getBlockAbove } from '../../getBlockAbove';
import { getNextSiblingNodes } from '../../getNextSiblingNodes';
jsxt;
diff --git a/packages/slate-utils/src/queries/annotation.ts b/packages/slate-utils/src/queries/annotation.ts
new file mode 100644
index 0000000000..b1b9517397
--- /dev/null
+++ b/packages/slate-utils/src/queries/annotation.ts
@@ -0,0 +1,13 @@
+import type { UnknownObject } from '@udecode/utils';
+import type { Range, RangeRef } from 'slate';
+
+export type Annotation = {
+ range: Range;
+ rangeRef: RangeRef;
+ text: string;
+} & T &
+ UnknownObject;
+
+export type DecorationWithAnnotations = Range & {
+ annotations: Annotation[];
+};
diff --git a/packages/slate-utils/src/queries/annotationToDecorations.spec.ts b/packages/slate-utils/src/queries/annotationToDecorations.spec.ts
new file mode 100644
index 0000000000..820d816430
--- /dev/null
+++ b/packages/slate-utils/src/queries/annotationToDecorations.spec.ts
@@ -0,0 +1,246 @@
+import { createPlateEditor } from '@udecode/plate-core/react';
+import { createRangeRef } from '@udecode/slate';
+
+import { annotationToDecorations } from './annotationToDecorations';
+
+describe('annotationToDecorations', () => {
+ const editor = createPlateEditor();
+
+ it('should create decoration for single leaf annotation', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ }),
+ text: 'hello',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ ]);
+ });
+
+ it('should create decorations for annotation spanning multiple leaves', () => {
+ editor.children = [
+ {
+ children: [{ text: 'he' }, { bold: true, text: 'll' }, { text: 'o' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 1, path: [0, 2] },
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 1, path: [0, 2] },
+ }),
+ text: 'hello',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation],
+ focus: { offset: 2, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [annotation],
+ focus: { offset: 2, path: [0, 1] },
+ },
+ {
+ anchor: { offset: 0, path: [0, 2] },
+ annotations: [annotation],
+ focus: { offset: 1, path: [0, 2] },
+ },
+ ]);
+ });
+
+ it('should handle annotation at leaf boundaries', () => {
+ editor.children = [
+ {
+ children: [
+ { text: 'start ' },
+ { bold: true, text: 'hello' },
+ { text: ' end' },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 0, path: [0, 1] },
+ focus: { offset: 5, path: [0, 1] },
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 0, path: [0, 1] },
+ focus: { offset: 5, path: [0, 1] },
+ }),
+ text: 'hello',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [annotation],
+ focus: { offset: 5, path: [0, 1] },
+ },
+ ]);
+ });
+
+ it('should handle empty leaves', () => {
+ editor.children = [
+ {
+ children: [{ text: '' }, { bold: true, text: 'hello' }, { text: '' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 0, path: [0, 1] },
+ focus: { offset: 5, path: [0, 1] },
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 0, path: [0, 1] },
+ focus: { offset: 5, path: [0, 1] },
+ }),
+ text: 'hello',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 1] },
+ annotations: [annotation],
+ focus: { offset: 5, path: [0, 1] },
+ },
+ ]);
+ });
+
+ it('should handle non-existent node', () => {
+ editor.children = [];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ }),
+ text: 'hello',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('should create decorations for annotation spanning multiple blocks', () => {
+ editor.children = [
+ {
+ children: [{ text: 'first' }],
+ type: 'p',
+ },
+ {
+ children: [{ text: 'second' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 3, path: [0, 0] }, // 'st' from 'first'
+ focus: { offset: 3, path: [1, 0] }, // 'ond' from 'second'
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 3, path: [0, 0] },
+ focus: { offset: 3, path: [1, 0] },
+ }),
+ text: 'st second',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 3, path: [0, 0] },
+ annotations: [annotation],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 0, path: [1, 0] },
+ annotations: [annotation],
+ focus: { offset: 3, path: [1, 0] },
+ },
+ ]);
+ });
+
+ it('should handle annotations at block edges', () => {
+ editor.children = [
+ {
+ children: [{ text: 'first' }],
+ type: 'p',
+ },
+ {
+ children: [{ text: 'second' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation = {
+ range: {
+ anchor: { offset: 3, path: [0, 0] }, // "t" from first
+ focus: { offset: 1, path: [1, 0] }, // "s" from second
+ },
+ rangeRef: createRangeRef(editor, {
+ anchor: { offset: 3, path: [0, 0] },
+ focus: { offset: 1, path: [1, 0] },
+ }),
+ text: 't s',
+ };
+
+ const decorations = annotationToDecorations(editor, { annotation });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 3, path: [0, 0] },
+ annotations: [annotation],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 0, path: [1, 0] },
+ annotations: [annotation],
+ focus: { offset: 1, path: [1, 0] },
+ },
+ ]);
+ });
+});
diff --git a/packages/slate-utils/src/queries/annotationToDecorations.ts b/packages/slate-utils/src/queries/annotationToDecorations.ts
new file mode 100644
index 0000000000..956e4a8d44
--- /dev/null
+++ b/packages/slate-utils/src/queries/annotationToDecorations.ts
@@ -0,0 +1,65 @@
+import {
+ type TEditor,
+ type TText,
+ getNodeEntries,
+ isText,
+} from '@udecode/slate';
+import { Point } from 'slate';
+
+import type { Annotation, DecorationWithAnnotations } from './annotation';
+
+export const annotationToDecorations = (
+ editor: TEditor,
+ options: {
+ annotation: Annotation;
+ decorations?: Map;
+ }
+): DecorationWithAnnotations[] => {
+ const { annotation, decorations = new Map() } = options;
+ const { range } = annotation;
+ const results: DecorationWithAnnotations[] = [];
+
+ const textEntries = getNodeEntries(editor, {
+ at: range,
+ match: (n) => isText(n),
+ });
+
+ for (const [node, path] of textEntries) {
+ const textStart = { offset: 0, path };
+ const textEnd = { offset: node.text.length, path };
+
+ if (
+ Point.isAfter(range.anchor, textEnd) ||
+ Point.isBefore(range.focus, textStart)
+ ) {
+ continue;
+ }
+
+ const overlapStart = Point.isAfter(range.anchor, textStart)
+ ? range.anchor
+ : textStart;
+ const overlapEnd = Point.isBefore(range.focus, textEnd)
+ ? range.focus
+ : textEnd;
+
+ if (Point.isBefore(overlapStart, overlapEnd)) {
+ const key = `${path.join(',')}-${overlapStart.offset}-${overlapEnd.offset}`;
+
+ let decoration = decorations.get(key);
+
+ if (!decoration) {
+ decoration = {
+ anchor: overlapStart,
+ annotations: [],
+ focus: overlapEnd,
+ };
+ decorations.set(key, decoration);
+ results.push(decoration);
+ }
+
+ decoration.annotations.push(annotation);
+ }
+ }
+
+ return results;
+};
diff --git a/packages/slate-utils/src/queries/annotationsToDecorations.spec.ts b/packages/slate-utils/src/queries/annotationsToDecorations.spec.ts
new file mode 100644
index 0000000000..c14d55ec16
--- /dev/null
+++ b/packages/slate-utils/src/queries/annotationsToDecorations.spec.ts
@@ -0,0 +1,276 @@
+import { createPlateEditor } from '@udecode/plate-core/react';
+
+import { annotationsToDecorations } from './annotationsToDecorations';
+
+describe('annotationsToDecorations', () => {
+ const editor = createPlateEditor();
+
+ it('should handle multiple annotations with different ranges', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation1 = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] }, // "hello"
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const annotation2 = {
+ range: {
+ anchor: { offset: 6, path: [0, 0] },
+ focus: { offset: 11, path: [0, 0] }, // "world"
+ },
+ rangeRef: expect.any(Object),
+ text: 'world',
+ };
+
+ const decorations = annotationsToDecorations(editor, {
+ annotations: [annotation1, annotation2],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation1],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 6, path: [0, 0] },
+ annotations: [annotation2],
+ focus: { offset: 11, path: [0, 0] },
+ },
+ ]);
+ });
+
+ it('should handle overlapping annotations', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation1 = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 7, path: [0, 0] }, // "hello w"
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello w',
+ };
+
+ const annotation2 = {
+ range: {
+ anchor: { offset: 6, path: [0, 0] },
+ focus: { offset: 11, path: [0, 0] }, // "world"
+ },
+ rangeRef: expect.any(Object),
+ text: 'world',
+ };
+
+ const decorations = annotationsToDecorations(editor, {
+ annotations: [annotation1, annotation2],
+ });
+
+ // Should include both annotations for the overlapping region
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation1],
+ focus: { offset: 7, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 6, path: [0, 0] },
+ annotations: [annotation2],
+ focus: { offset: 11, path: [0, 0] },
+ },
+ ]);
+ });
+
+ it('should handle multiple annotations with same range', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation1 = {
+ data: { type: 'spelling' },
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const annotation2 = {
+ data: { type: 'grammar' },
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const decorations = annotationsToDecorations(editor, {
+ annotations: [annotation1, annotation2],
+ });
+
+ // Should include both annotations for the same range
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation1, annotation2],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ ]);
+ });
+});
+
+describe('annotationToDecorations with multiple annotations', () => {
+ const editor = createPlateEditor();
+
+ it('should merge overlapping annotations into single decoration', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation1 = {
+ data: { type: 'spelling' },
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const annotation2 = {
+ data: { type: 'grammar' },
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const decorations = annotationsToDecorations(editor, {
+ annotations: [annotation1, annotation2],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation1, annotation2],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ ]);
+ });
+
+ it('should handle multiple non-overlapping annotations', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello world' }],
+ type: 'p',
+ },
+ ];
+
+ const annotation1 = {
+ range: {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'hello',
+ };
+
+ const annotation2 = {
+ range: {
+ anchor: { offset: 6, path: [0, 0] },
+ focus: { offset: 11, path: [0, 0] },
+ },
+ rangeRef: expect.any(Object),
+ text: 'world',
+ };
+
+ const decorations = annotationsToDecorations(editor, {
+ annotations: [annotation1, annotation2],
+ });
+
+ expect(decorations).toEqual([
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ annotations: [annotation1],
+ focus: { offset: 5, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 6, path: [0, 0] },
+ annotations: [annotation2],
+ focus: { offset: 11, path: [0, 0] },
+ },
+ ]);
+ });
+
+ // it('should handle partially overlapping annotations', () => {
+ // editor.children = [
+ // {
+ // children: [{ text: 'hello world' }],
+ // type: 'p',
+ // },
+ // ];
+
+ // const annotation1 = {
+ // range: {
+ // anchor: { offset: 0, path: [0, 0] },
+ // focus: { offset: 7, path: [0, 0] }, // "hello w"
+ // },
+ // rangeRef: expect.any(Object),
+ // text: 'hello w',
+ // };
+
+ // const annotation2 = {
+ // range: {
+ // anchor: { offset: 6, path: [0, 0] },
+ // focus: { offset: 11, path: [0, 0] }, // "world"
+ // },
+ // rangeRef: expect.any(Object),
+ // text: 'world',
+ // };
+
+ // const decorations = annotationsToDecorations(editor, {
+ // annotations: [annotation1, annotation2],
+ // });
+
+ // expect(decorations).toEqual([
+ // {
+ // anchor: { offset: 0, path: [0, 0] },
+ // annotations: [annotation1],
+ // focus: { offset: 6, path: [0, 0] },
+ // },
+ // {
+ // anchor: { offset: 6, path: [0, 0] },
+ // annotations: [annotation1, annotation2],
+ // focus: { offset: 7, path: [0, 0] },
+ // },
+ // {
+ // anchor: { offset: 7, path: [0, 0] },
+ // annotations: [annotation2],
+ // focus: { offset: 11, path: [0, 0] },
+ // },
+ // ]);
+ // });
+});
diff --git a/packages/slate-utils/src/queries/annotationsToDecorations.ts b/packages/slate-utils/src/queries/annotationsToDecorations.ts
new file mode 100644
index 0000000000..cbecd13c55
--- /dev/null
+++ b/packages/slate-utils/src/queries/annotationsToDecorations.ts
@@ -0,0 +1,22 @@
+import type { TEditor } from '@udecode/slate';
+
+import type { Annotation, DecorationWithAnnotations } from './annotation';
+
+import { annotationToDecorations } from './annotationToDecorations';
+
+export const annotationsToDecorations = (
+ editor: TEditor,
+ options: {
+ annotations: Annotation[];
+ }
+): DecorationWithAnnotations[] => {
+ const { annotations } = options;
+ const decorations = new Map();
+
+ // Process all annotations and merge overlapping decorations
+ annotations.forEach((annotation) => {
+ annotationToDecorations(editor, { annotation, decorations });
+ });
+
+ return Array.from(decorations.values());
+};
diff --git a/packages/slate-utils/src/queries/getNextRange.spec.tsx b/packages/slate-utils/src/queries/getNextRange.spec.tsx
new file mode 100644
index 0000000000..5a0e7f78c9
--- /dev/null
+++ b/packages/slate-utils/src/queries/getNextRange.spec.tsx
@@ -0,0 +1,156 @@
+import { createSlateEditor } from '@udecode/plate-core';
+
+import { getNextRange } from './getNextRange';
+
+describe('getNextRange', () => {
+ const editor = createSlateEditor();
+
+ const ranges = [
+ { anchor: { offset: 0, path: [0, 0] }, focus: { offset: 5, path: [0, 0] } },
+ {
+ anchor: { offset: 10, path: [0, 0] },
+ focus: { offset: 15, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 20, path: [0, 0] },
+ focus: { offset: 25, path: [0, 0] },
+ },
+ ];
+
+ it('returns undefined for empty ranges', () => {
+ expect(getNextRange(editor, { ranges: [] })).toBeUndefined();
+ });
+
+ describe('without from range', () => {
+ it('returns first range when no selection and not reverse', () => {
+ editor.selection = null;
+ expect(getNextRange(editor, { ranges })).toBe(ranges[0]);
+ });
+
+ it('returns last range when no selection and reverse', () => {
+ editor.selection = null;
+ expect(getNextRange(editor, { ranges, reverse: true })).toBe(ranges[2]);
+ });
+
+ it('finds next range after selection', () => {
+ editor.selection = {
+ anchor: { offset: 7, path: [0, 0] },
+ focus: { offset: 8, path: [0, 0] },
+ };
+ expect(getNextRange(editor, { ranges })).toBe(ranges[1]);
+ });
+
+ it('finds previous range before selection when reverse', () => {
+ editor.selection = {
+ anchor: { offset: 17, path: [0, 0] },
+ focus: { offset: 18, path: [0, 0] },
+ };
+ expect(getNextRange(editor, { ranges, reverse: true })).toBe(ranges[1]);
+ });
+
+ it('returns first range when no next range found', () => {
+ editor.selection = {
+ anchor: { offset: 30, path: [0, 0] },
+ focus: { offset: 31, path: [0, 0] },
+ };
+ expect(getNextRange(editor, { ranges })).toBe(ranges[0]);
+ });
+
+ it('returns last range when no previous range found in reverse', () => {
+ editor.selection = {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 1, path: [0, 0] },
+ };
+ expect(getNextRange(editor, { ranges, reverse: true })).toBe(ranges[2]);
+ });
+ });
+
+ describe('with from range', () => {
+ it('returns next range in sequence', () => {
+ expect(getNextRange(editor, { from: ranges[0], ranges })).toBe(ranges[1]);
+ expect(getNextRange(editor, { from: ranges[1], ranges })).toBe(ranges[2]);
+ });
+
+ it('wraps to first range when at end', () => {
+ expect(getNextRange(editor, { from: ranges[2], ranges })).toBe(ranges[0]);
+ });
+
+ it('returns previous range in sequence when reverse', () => {
+ expect(
+ getNextRange(editor, { from: ranges[2], ranges, reverse: true })
+ ).toBe(ranges[1]);
+ expect(
+ getNextRange(editor, { from: ranges[1], ranges, reverse: true })
+ ).toBe(ranges[0]);
+ });
+
+ it('wraps to last range when at start and reverse', () => {
+ expect(
+ getNextRange(editor, { from: ranges[0], ranges, reverse: true })
+ ).toBe(ranges[2]);
+ });
+
+ it('returns first range when from range not found in ranges', () => {
+ const unknownRange = {
+ anchor: { offset: 0, path: [1, 0] },
+ focus: { offset: 5, path: [1, 0] },
+ };
+ expect(getNextRange(editor, { from: unknownRange, ranges })).toBe(
+ ranges[0]
+ );
+ });
+ });
+
+ describe('with multiple blocks', () => {
+ const multiBlockRanges = [
+ {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 5, path: [0, 0] },
+ },
+ {
+ anchor: { offset: 0, path: [1, 0] },
+ focus: { offset: 5, path: [1, 0] },
+ },
+ ];
+
+ it('finds next range in next block', () => {
+ editor.selection = {
+ anchor: { offset: 7, path: [0, 0] },
+ focus: { offset: 8, path: [0, 0] },
+ };
+ expect(getNextRange(editor, { ranges: multiBlockRanges })).toBe(
+ multiBlockRanges[1]
+ );
+ });
+
+ it('finds previous range in previous block when reverse', () => {
+ editor.selection = {
+ anchor: { offset: 2, path: [1, 0] },
+ focus: { offset: 3, path: [1, 0] },
+ };
+ expect(
+ getNextRange(editor, { ranges: multiBlockRanges, reverse: true })
+ ).toBe(multiBlockRanges[0]);
+ });
+
+ it('wraps to first block when at end', () => {
+ editor.selection = {
+ anchor: { offset: 7, path: [1, 0] },
+ focus: { offset: 8, path: [1, 0] },
+ };
+ expect(getNextRange(editor, { ranges: multiBlockRanges })).toBe(
+ multiBlockRanges[0]
+ );
+ });
+
+ it('wraps to last block when at start and reverse', () => {
+ editor.selection = {
+ anchor: { offset: 0, path: [0, 0] },
+ focus: { offset: 1, path: [0, 0] },
+ };
+ expect(
+ getNextRange(editor, { ranges: multiBlockRanges, reverse: true })
+ ).toBe(multiBlockRanges[1]);
+ });
+ });
+});
diff --git a/packages/slate-utils/src/queries/getNextRange.ts b/packages/slate-utils/src/queries/getNextRange.ts
new file mode 100644
index 0000000000..1c276fb908
--- /dev/null
+++ b/packages/slate-utils/src/queries/getNextRange.ts
@@ -0,0 +1,74 @@
+import type { TEditor } from '@udecode/slate';
+
+import { Point, Range } from 'slate';
+
+/**
+ * Get the next range from a list of ranges.
+ *
+ * - Find the next range after/before the `from` range
+ * - If no `from` range and no selection, select first/last depending on direction
+ * - If no `from` range and selection, find the next range after/before the
+ * selection
+ */
+export const getNextRange = (
+ editor: TEditor,
+ {
+ from,
+ ranges,
+ reverse,
+ }: {
+ ranges: Range[];
+ from?: Range | null;
+ reverse?: boolean;
+ }
+) => {
+ if (ranges.length === 0) return;
+ // Handle the case when there's no 'from' range
+ if (!from) {
+ if (!editor.selection) {
+ return reverse ? ranges.at(-1) : ranges[0];
+ }
+
+ const selectionPoint = Range.start(editor.selection);
+
+ // Find the closest range
+ let nextRange: Range | undefined;
+
+ // eslint-disable-next-line unicorn/prefer-ternary
+ if (reverse) {
+ // When going backwards, find the last range that ends before the selection
+ nextRange = [...ranges].reverse().find((range) => {
+ const rangeEnd = Range.end(range);
+
+ return (
+ Point.isBefore(rangeEnd, selectionPoint) ||
+ (Point.equals(rangeEnd, selectionPoint) &&
+ Point.isBefore(Range.start(range), selectionPoint))
+ );
+ });
+ } else {
+ // When going forwards, find the first range that starts after the selection
+ nextRange = ranges.find((range) =>
+ Point.isAfter(Range.start(range), selectionPoint)
+ );
+ }
+
+ return nextRange ?? (reverse ? ranges.at(-1) : ranges[0]);
+ }
+
+ // When there is a 'from' range, find the next/previous range
+ const currentIndex = ranges.findIndex((range) => Range.equals(range, from));
+
+ if (currentIndex === -1) return ranges[0]; // Return first range if current not found
+
+ // Calculate next index
+ let nextIndex: number;
+
+ if (reverse) {
+ nextIndex = currentIndex - 1 < 0 ? ranges.length - 1 : currentIndex - 1;
+ } else {
+ nextIndex = currentIndex + 1 >= ranges.length ? 0 : currentIndex + 1;
+ }
+
+ return ranges[nextIndex];
+};
diff --git a/packages/slate-utils/src/queries/index.ts b/packages/slate-utils/src/queries/index.ts
index 0ba55547b0..e2964e1502 100644
--- a/packages/slate-utils/src/queries/index.ts
+++ b/packages/slate-utils/src/queries/index.ts
@@ -2,6 +2,9 @@
* @file Automatically generated by barrelsby.
*/
+export * from './annotation';
+export * from './annotationToDecorations';
+export * from './annotationsToDecorations';
export * from './findDescendant';
export * from './getAncestorNode';
export * from './getBlockAbove';
@@ -13,6 +16,7 @@ export * from './getLastChild';
export * from './getLastNodeByLevel';
export * from './getMark';
export * from './getNextNodeStartPoint';
+export * from './getNextRange';
export * from './getNextSiblingNodes';
export * from './getNodesRange';
export * from './getOperations';
@@ -42,6 +46,9 @@ export * from './isSelectionAtBlockEnd';
export * from './isSelectionAtBlockStart';
export * from './isSelectionCoverBlock';
export * from './isSelectionExpanded';
+export * from './isSelectionInRange';
export * from './isTextByPath';
export * from './isWordAfterTrigger';
+export * from './parseNode';
export * from './queryEditor';
+export * from './replaceText';
diff --git a/packages/slate-utils/src/queries/isSelectionInRange.ts b/packages/slate-utils/src/queries/isSelectionInRange.ts
new file mode 100644
index 0000000000..1e78ea0d55
--- /dev/null
+++ b/packages/slate-utils/src/queries/isSelectionInRange.ts
@@ -0,0 +1,35 @@
+import type { TEditor } from '@udecode/slate';
+
+import { Point, Range } from 'slate';
+
+/**
+ * Check if the selection is in the range.
+ *
+ * - `contain`: Check if the selection is strictly inside the range
+ * - `intersect`: Check if the selection intersects the range
+ */
+export const isSelectionInRange = (
+ editor: TEditor,
+ {
+ at,
+ mode = 'contain',
+ }: {
+ at: Range;
+ mode?: 'contain' | 'intersect';
+ }
+) => {
+ const selection = editor.selection;
+
+ if (!selection) return false;
+ if (mode === 'contain') {
+ // Check if the selection is strictly inside the range
+ return (
+ (Point.isAfter(selection.anchor, Range.start(at)) ||
+ Point.equals(selection.anchor, Range.start(at))) &&
+ (Point.isBefore(selection.focus, Range.end(at)) ||
+ Point.equals(selection.focus, Range.end(at)))
+ );
+ }
+
+ return Range.includes(at, selection);
+};
diff --git a/packages/slate-utils/src/queries/parseNode.spec.ts b/packages/slate-utils/src/queries/parseNode.spec.ts
new file mode 100644
index 0000000000..d2f3e13611
--- /dev/null
+++ b/packages/slate-utils/src/queries/parseNode.spec.ts
@@ -0,0 +1,207 @@
+/* eslint-disable jest/no-conditional-expect */
+import { createPlateEditor } from '@udecode/plate-core/react';
+
+import { experimental_parseNode } from './parseNode';
+
+describe('experimental_parseNode', () => {
+ const editor = createPlateEditor();
+
+ beforeEach(() => {
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world. this is a test.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+ });
+
+ it('should find all matches in a single block', () => {
+ const { annotations } = experimental_parseNode(editor, {
+ at: [0],
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(annotations).toHaveLength(6); // hello, world, this, is, a, test
+
+ // Check annotation positions
+ expect(annotations[0].text).toBe('hello');
+ expect(annotations[0].range.anchor.offset).toBe(0);
+ expect(annotations[0].range.focus.offset).toBe(5);
+
+ expect(annotations[1].text).toBe('world');
+ expect(annotations[1].range.anchor.offset).toBe(6);
+ expect(annotations[1].range.focus.offset).toBe(11);
+ });
+
+ it('should respect minLength and maxLength', () => {
+ const { annotations } = experimental_parseNode(editor, {
+ at: [0],
+ match: () => true,
+ maxLength: 5,
+ minLength: 4,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ // Words with length 4-5: 'hello', 'world', 'this', 'test'
+ expect(annotations).toHaveLength(4);
+ expect(annotations.map((t) => t.text)).toEqual([
+ 'hello',
+ 'world',
+ 'this',
+ 'test',
+ ]);
+ });
+
+ it('should apply match function correctly', () => {
+ const { annotations } = experimental_parseNode(editor, {
+ at: [0],
+ match: ({ text }) => text.length > 4, // Only match words longer than 4 chars
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ // Only words longer than 4 chars: 'hello', 'world'
+ expect(annotations).toHaveLength(2);
+ expect(annotations.map((t) => t.text)).toEqual(['hello', 'world']);
+ });
+
+ it('should provide correct context in match function', () => {
+ const matchFn = jest.fn(({ end, fullText, getContext, start, text }) => {
+ // Test context for 'world'
+ if (text === 'world') {
+ expect(start).toBe(6);
+ expect(end).toBe(11);
+ expect(fullText).toBe('hello world. this is a test.');
+
+ const prevContext = getContext({ before: 2 });
+ expect(prevContext).toContain('o '); // Previous chars before 'world'
+
+ const nextContext = getContext({ after: 2 });
+ expect(nextContext).toContain('. '); // Next chars after 'world'
+ }
+
+ return true;
+ });
+
+ experimental_parseNode(editor, {
+ at: [0],
+ match: matchFn,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(matchFn).toHaveBeenCalled();
+ });
+
+ it('should handle transform function', () => {
+ const { annotations } = experimental_parseNode(editor, {
+ at: [0],
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ transform: (annotation) => ({
+ ...annotation,
+ data: { transformed: true },
+ }),
+ });
+
+ annotations.forEach((annotation) => {
+ expect(annotation.data).toEqual({ transformed: true });
+ });
+ });
+
+ it('should parse entire editor when at is undefined', () => {
+ editor.children = [
+ {
+ children: [
+ {
+ text: 'hello world.',
+ },
+ ],
+ type: 'p',
+ },
+ {
+ children: [
+ {
+ text: 'another block.',
+ },
+ ],
+ type: 'p',
+ },
+ ];
+
+ const { annotations } = experimental_parseNode(editor, {
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(annotations).toHaveLength(4); // hello, world, another, block
+ expect(annotations.map((t) => t.text)).toEqual([
+ 'hello',
+ 'world',
+ 'another',
+ 'block',
+ ]);
+
+ // Check paths are correct - now they should be nested under root []
+ expect(annotations[0].range.anchor.path).toEqual([0, 0]);
+ expect(annotations[2].range.anchor.path).toEqual([1, 0]);
+ });
+
+ it('should handle empty editor', () => {
+ editor.children = [];
+
+ const { annotations } = experimental_parseNode(editor, {
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(annotations).toHaveLength(0);
+ });
+
+ it('should handle root block with at=[]', () => {
+ editor.children = [
+ {
+ children: [{ text: 'hello' }],
+ type: 'p',
+ },
+ {
+ children: [{ text: 'world' }],
+ type: 'p',
+ },
+ ];
+
+ const { annotations } = experimental_parseNode(editor, {
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(annotations.map((t) => t.text)).toEqual(['hello', 'world']);
+ expect(annotations[0].range.anchor.path).toEqual([0, 0]);
+ expect(annotations[1].range.anchor.path).toEqual([1, 0]);
+ });
+
+ it('should handle nested blocks with at=[]', () => {
+ editor.children = [
+ {
+ children: [
+ {
+ children: [{ text: 'nested' }],
+ type: 'p',
+ },
+ ],
+ type: 'block',
+ },
+ ];
+
+ const { annotations } = experimental_parseNode(editor, {
+ match: () => true,
+ splitPattern: /\b[A-Za-z]+\b/g,
+ });
+
+ expect(annotations.map((t) => t.text)).toEqual(['nested']);
+ expect(annotations[0].range.anchor.path).toEqual([0, 0, 0]);
+ });
+});
diff --git a/packages/slate-utils/src/queries/parseNode.ts b/packages/slate-utils/src/queries/parseNode.ts
new file mode 100644
index 0000000000..6f5354c844
--- /dev/null
+++ b/packages/slate-utils/src/queries/parseNode.ts
@@ -0,0 +1,187 @@
+import type { AnyObject } from '@udecode/utils';
+import type { Path, Range } from 'slate';
+
+import {
+ type TDescendant,
+ type TEditor,
+ type TElement,
+ createRangeRef,
+ getNode,
+ getNodeTexts,
+ isBlock,
+ isEditor,
+} from '@udecode/slate';
+
+import type { Annotation } from './annotation';
+
+export type ParseNodeOptions = {
+ /** Function to match annotations and return match result */
+ match: (params: {
+ end: number;
+ fullText: string;
+ getContext: (options: { after?: number; before?: number }) => string;
+ start: number;
+ text: string;
+ }) => AnyObject | boolean;
+ /** Target path or range. If undefined, parses entire editor */
+ at?: Path | Range;
+ /** Maximum length of annotations to process */
+ maxLength?: number;
+ /** Minimum length of annotations to process */
+ minLength?: number;
+ /** Pattern for matching annotations in text */
+ splitPattern?: RegExp;
+ /** Function to transform matched annotations */
+ transform?: (annotation: Annotation) => Annotation;
+};
+
+export type ParseNodeResult = {
+ annotations: Annotation[];
+};
+
+export const experimental_parseNode = (
+ editor: TEditor,
+ options: ParseNodeOptions
+): ParseNodeResult => {
+ if (!options.at) {
+ options.at = [];
+ }
+
+ // Get target path
+ const at: Path = Array.isArray(options.at)
+ ? options.at
+ : options.at.anchor.path;
+ const node = getNode(editor, at);
+
+ if (!node) return { annotations: [] };
+ // If node is editor or block and path is not leaf, parse children
+ if ((isEditor(node) || isBlock(editor, node)) && at.length === 0) {
+ const element = node as TElement;
+ const results = element.children.flatMap(
+ (child: TDescendant, index: number) => {
+ if (!isBlock(editor, child)) return [];
+
+ return experimental_parseNode(editor, {
+ ...options,
+ at: [...at, index],
+ });
+ }
+ );
+
+ return {
+ annotations: results.flatMap((r: ParseNodeResult) => r.annotations),
+ };
+ }
+
+ // Parse single block
+ const texts = [...getNodeTexts(node)];
+ const fullText = texts.map((text) => text[0].text).join('');
+
+ const createContextGetter = (start: number, end: number) => {
+ return ({ after = 0, before = 0 }) => {
+ const beforeText = fullText.slice(Math.max(0, start - before), start);
+ const afterText = fullText.slice(
+ end,
+ Math.min(fullText.length, end + after)
+ );
+
+ return beforeText + afterText;
+ };
+ };
+
+ // Process matches
+ const splitPattern = options.splitPattern ?? /\b[\dA-Za-z]+(?:['-]\w+)*\b/g;
+ const matches = Array.from(fullText.matchAll(splitPattern));
+
+ const uniqueAnnotations = new Map();
+
+ matches.forEach((match) => {
+ const annotationText = match[0];
+ const start = match.index!;
+ const end = start + annotationText.length;
+
+ // Skip annotations that don't meet length requirements
+ if (
+ annotationText.length < (options.minLength ?? 0) ||
+ annotationText.length > (options.maxLength ?? Infinity)
+ ) {
+ return;
+ }
+
+ // Apply match function
+ const matchResult = options.match({
+ end,
+ fullText,
+ getContext: createContextGetter(start, end),
+ start,
+ text: annotationText,
+ });
+
+ // Skip if match function returns false
+ if (!matchResult) {
+ return;
+ }
+
+ let startPath: Path | null = null;
+ let endPath: Path | null = null;
+ let startOffset = 0;
+ let endOffset = 0;
+ let cumulativeLength = 0;
+
+ // Find the correct start and end positions across leaves
+ for (const [text, path] of texts) {
+ const textLength = text.text.length;
+ const textEnd = cumulativeLength + textLength;
+
+ // Find start position
+ if (startPath === null && start < textEnd) {
+ startPath = [...at, ...path];
+ startOffset = start - cumulativeLength;
+ }
+ // Find end position
+ if (endPath === null && end <= textEnd) {
+ endPath = [...at, ...path];
+ endOffset = end - cumulativeLength;
+ }
+ if (startPath !== null && endPath !== null) break;
+
+ cumulativeLength = textEnd;
+ }
+
+ if (startPath && endPath) {
+ const annotationRange = {
+ anchor: { offset: startOffset, path: startPath },
+ focus: { offset: endOffset, path: endPath },
+ };
+ const annotationRangeRef = createRangeRef(editor, annotationRange);
+
+ let annotation = {
+ range: annotationRange,
+ rangeRef: annotationRangeRef,
+ text: annotationText,
+ };
+
+ if (options.transform) {
+ annotation = options.transform(annotation);
+ }
+
+ // Store unique annotation
+ const annotationKey = `${annotationRange.anchor.path.join('-')}-${annotationRange.anchor.offset}-${annotationRange.focus.offset}`;
+
+ if (!uniqueAnnotations.has(annotationKey)) {
+ uniqueAnnotations.set(annotationKey, annotation);
+ }
+ }
+ });
+
+ return {
+ annotations: Array.from(uniqueAnnotations.values()),
+ };
+};
+
+// const DEFAULT_PATTERNS = {
+// word: /\b[a-zA-Z0-9]+(?:[''-]\w+)*\b/g,
+// phrase: /\b[a-zA-Z0-9]+(?:[''-]\w+)*(?:\s+[a-zA-Z0-9]+(?:[''-]\w+)*){1,5}\b/g,
+// sentence: /[^.!?]+[.!?]+/g,
+// paragraph: /[^\n\r]+/g,
+// } as const;
diff --git a/packages/slate-utils/src/queries/replaceText.ts b/packages/slate-utils/src/queries/replaceText.ts
new file mode 100644
index 0000000000..bdd07c20f7
--- /dev/null
+++ b/packages/slate-utils/src/queries/replaceText.ts
@@ -0,0 +1,29 @@
+import type { Range } from 'slate';
+
+import {
+ type TEditor,
+ deleteText,
+ insertText,
+ withoutNormalizing,
+} from '@udecode/slate';
+
+/** Replace text at a specific range. */
+export const replaceText = (
+ editor: TEditor,
+ {
+ at,
+ text,
+ }: {
+ at: Range;
+ text: string;
+ }
+) => {
+ withoutNormalizing(editor, () => {
+ deleteText(editor, {
+ at,
+ });
+ insertText(editor, text, {
+ at: at.anchor,
+ });
+ });
+};
diff --git a/yarn.lock b/yarn.lock
index e47c9970e7..31240cca88 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6370,7 +6370,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@udecode/cn@workspace:packages/cn"
dependencies:
- "@udecode/react-utils": "npm:39.0.0"
+ "@udecode/react-utils": "npm:40.2.8"
peerDependencies:
class-variance-authority: ">=0.7.0"
react: ">=16.8.0"
@@ -6385,7 +6385,7 @@ __metadata:
dependencies:
"@udecode/plate-combobox": "npm:40.0.0"
"@udecode/plate-markdown": "npm:40.2.2"
- "@udecode/plate-selection": "npm:40.1.0"
+ "@udecode/plate-selection": "npm:40.2.9"
ai: "npm:^3.4.10"
lodash: "npm:^4.17.21"
peerDependencies:
@@ -6406,7 +6406,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6424,7 +6424,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6444,7 +6444,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
"@udecode/plate-heading": "npm:40.2.6"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6461,7 +6461,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6478,7 +6478,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6495,7 +6495,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6512,7 +6512,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6530,7 +6530,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
react-textarea-autosize: "npm:^8.5.3"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6550,7 +6550,7 @@ __metadata:
delay: "npm:5.0.0"
p-defer: "npm:^4.0.1"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6568,7 +6568,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
prismjs: "npm:^1.29.0"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6585,7 +6585,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6603,7 +6603,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6614,17 +6614,17 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-common@npm:40.0.3, @udecode/plate-common@workspace:^, @udecode/plate-common@workspace:packages/common":
+"@udecode/plate-common@npm:40.2.8, @udecode/plate-common@workspace:^, @udecode/plate-common@workspace:packages/common":
version: 0.0.0-use.local
resolution: "@udecode/plate-common@workspace:packages/common"
dependencies:
- "@udecode/plate-core": "npm:40.0.3"
- "@udecode/plate-utils": "npm:40.0.3"
+ "@udecode/plate-core": "npm:40.2.8"
+ "@udecode/plate-utils": "npm:40.2.8"
"@udecode/react-hotkeys": "npm:37.0.0"
- "@udecode/react-utils": "npm:39.0.0"
+ "@udecode/react-utils": "npm:40.2.8"
"@udecode/slate": "npm:39.2.1"
- "@udecode/slate-react": "npm:40.0.0"
- "@udecode/slate-utils": "npm:39.2.20"
+ "@udecode/slate-react": "npm:40.2.8"
+ "@udecode/slate-utils": "npm:40.2.7"
"@udecode/utils": "npm:37.0.0"
peerDependencies:
react: ">=16.8.0"
@@ -6637,15 +6637,15 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-core@npm:40.0.3, @udecode/plate-core@workspace:^, @udecode/plate-core@workspace:packages/core":
+"@udecode/plate-core@npm:40.2.8, @udecode/plate-core@workspace:^, @udecode/plate-core@workspace:packages/core":
version: 0.0.0-use.local
resolution: "@udecode/plate-core@workspace:packages/core"
dependencies:
"@udecode/react-hotkeys": "npm:37.0.0"
- "@udecode/react-utils": "npm:39.0.0"
+ "@udecode/react-utils": "npm:40.2.8"
"@udecode/slate": "npm:39.2.1"
- "@udecode/slate-react": "npm:40.0.0"
- "@udecode/slate-utils": "npm:39.2.20"
+ "@udecode/slate-react": "npm:40.2.8"
+ "@udecode/slate-utils": "npm:40.2.7"
"@udecode/utils": "npm:37.0.0"
clsx: "npm:^2.1.1"
is-hotkey: "npm:^0.2.0"
@@ -6678,7 +6678,7 @@ __metadata:
"@udecode/plate-table": "npm:40.0.0"
papaparse: "npm:^5.4.1"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6695,7 +6695,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6712,7 +6712,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.94.0"
@@ -6730,7 +6730,7 @@ __metadata:
diff-match-patch-ts: "npm:^0.6.0"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6749,7 +6749,7 @@ __metadata:
lodash: "npm:^4.17.21"
raf: "npm:^3.4.1"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dnd: ">=14.0.0"
react-dnd-html5-backend: ">=14.0.0"
@@ -6762,7 +6762,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-docx@npm:40.2.6, @udecode/plate-docx@workspace:^, @udecode/plate-docx@workspace:packages/docx":
+"@udecode/plate-docx@npm:40.2.7, @udecode/plate-docx@workspace:^, @udecode/plate-docx@workspace:packages/docx":
version: 0.0.0-use.local
resolution: "@udecode/plate-docx@workspace:packages/docx"
dependencies:
@@ -6770,11 +6770,11 @@ __metadata:
"@udecode/plate-heading": "npm:40.2.6"
"@udecode/plate-indent": "npm:40.0.0"
"@udecode/plate-indent-list": "npm:40.0.0"
- "@udecode/plate-media": "npm:40.2.4"
+ "@udecode/plate-media": "npm:40.2.7"
"@udecode/plate-table": "npm:40.0.0"
validator: "npm:^13.12.0"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6793,7 +6793,7 @@ __metadata:
"@udecode/plate-combobox": "npm:40.0.0"
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6811,7 +6811,7 @@ __metadata:
"@excalidraw/excalidraw": "npm:0.16.4"
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6822,13 +6822,13 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-find-replace@npm:40.0.0, @udecode/plate-find-replace@workspace:^, @udecode/plate-find-replace@workspace:packages/find-replace":
+"@udecode/plate-find-replace@npm:40.2.8, @udecode/plate-find-replace@workspace:^, @udecode/plate-find-replace@workspace:packages/find-replace":
version: 0.0.0-use.local
resolution: "@udecode/plate-find-replace@workspace:packages/find-replace"
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6847,7 +6847,7 @@ __metadata:
"@floating-ui/react": "npm:^0.26.23"
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6865,7 +6865,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6882,7 +6882,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6899,7 +6899,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6916,7 +6916,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6935,7 +6935,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
html-entities: "npm:^2.5.2"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6955,7 +6955,7 @@ __metadata:
"@udecode/plate-list": "npm:40.0.0"
clsx: "npm:^2.1.1"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6972,7 +6972,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -6990,7 +6990,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
juice: "npm:^8.1.0"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7007,7 +7007,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7024,7 +7024,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7041,7 +7041,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7060,7 +7060,25 @@ __metadata:
"@udecode/plate-floating": "npm:40.0.0"
"@udecode/plate-normalizers": "npm:40.0.0"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
+ react: ">=16.8.0"
+ react-dom: ">=16.8.0"
+ slate: ">=0.103.0"
+ slate-dom: ">=0.111.0"
+ slate-history: ">=0.93.0"
+ slate-hyperscript: ">=0.66.0"
+ slate-react: ">=0.111.0"
+ languageName: unknown
+ linkType: soft
+
+"@udecode/plate-lint@workspace:^, @udecode/plate-lint@workspace:packages/lint":
+ version: 0.0.0-use.local
+ resolution: "@udecode/plate-lint@workspace:packages/lint"
+ dependencies:
+ "@udecode/plate-common": "workspace:^"
+ lodash: "npm:^4.17.21"
+ peerDependencies:
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7079,7 +7097,7 @@ __metadata:
"@udecode/plate-reset-node": "npm:40.0.0"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7100,7 +7118,7 @@ __metadata:
remark-parse: "npm:^11.0.0"
unified: "npm:^11.0.5"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7119,7 +7137,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
katex: "npm:0.16.11"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7130,14 +7148,14 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-media@npm:40.2.4, @udecode/plate-media@workspace:^, @udecode/plate-media@workspace:packages/media":
+"@udecode/plate-media@npm:40.2.7, @udecode/plate-media@workspace:^, @udecode/plate-media@workspace:packages/media":
version: 0.0.0-use.local
resolution: "@udecode/plate-media@workspace:packages/media"
dependencies:
"@udecode/plate-common": "workspace:^"
js-video-url-parser: "npm:^0.5.1"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7155,7 +7173,7 @@ __metadata:
"@udecode/plate-combobox": "npm:40.0.0"
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7173,7 +7191,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7191,7 +7209,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7209,7 +7227,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
peerDependencies:
"@playwright/test": ">=1.42.1"
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7226,7 +7244,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7243,7 +7261,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7260,7 +7278,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7271,14 +7289,14 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-selection@npm:40.1.0, @udecode/plate-selection@workspace:^, @udecode/plate-selection@workspace:packages/selection":
+"@udecode/plate-selection@npm:40.2.9, @udecode/plate-selection@workspace:^, @udecode/plate-selection@workspace:packages/selection":
version: 0.0.0-use.local
resolution: "@udecode/plate-selection@workspace:packages/selection"
dependencies:
"@udecode/plate-common": "workspace:^"
copy-to-clipboard: "npm:^3.3.3"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7296,7 +7314,7 @@ __metadata:
"@udecode/plate-combobox": "npm:40.0.0"
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7307,7 +7325,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-suggestion@npm:40.0.0, @udecode/plate-suggestion@workspace:^, @udecode/plate-suggestion@workspace:packages/suggestion":
+"@udecode/plate-suggestion@npm:40.2.8, @udecode/plate-suggestion@workspace:^, @udecode/plate-suggestion@workspace:packages/suggestion":
version: 0.0.0-use.local
resolution: "@udecode/plate-suggestion@workspace:packages/suggestion"
dependencies:
@@ -7315,7 +7333,7 @@ __metadata:
"@udecode/plate-diff": "npm:40.0.0"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7333,7 +7351,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
tabbable: "npm:^6.2.0"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7352,7 +7370,7 @@ __metadata:
"@udecode/plate-resizable": "npm:40.0.0"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7368,7 +7386,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7397,7 +7415,7 @@ __metadata:
"@udecode/plate-node-id": "npm:40.0.0"
lodash: "npm:^4.17.21"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7414,7 +7432,7 @@ __metadata:
dependencies:
"@udecode/plate-common": "workspace:^"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7425,15 +7443,15 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/plate-utils@npm:40.0.3, @udecode/plate-utils@workspace:^, @udecode/plate-utils@workspace:packages/plate-utils":
+"@udecode/plate-utils@npm:40.2.8, @udecode/plate-utils@workspace:^, @udecode/plate-utils@workspace:packages/plate-utils":
version: 0.0.0-use.local
resolution: "@udecode/plate-utils@workspace:packages/plate-utils"
dependencies:
- "@udecode/plate-core": "npm:40.0.3"
- "@udecode/react-utils": "npm:39.0.0"
+ "@udecode/plate-core": "npm:40.2.8"
+ "@udecode/react-utils": "npm:40.2.8"
"@udecode/slate": "npm:39.2.1"
- "@udecode/slate-react": "npm:40.0.0"
- "@udecode/slate-utils": "npm:39.2.20"
+ "@udecode/slate-react": "npm:40.2.8"
+ "@udecode/slate-utils": "npm:40.2.7"
"@udecode/utils": "npm:37.0.0"
clsx: "npm:^2.1.1"
lodash: "npm:^4.17.21"
@@ -7456,7 +7474,7 @@ __metadata:
"@udecode/plate-common": "workspace:^"
yjs: "npm:^13.6.19"
peerDependencies:
- "@udecode/plate-common": ">=40.0.3"
+ "@udecode/plate-common": ">=40.2.8"
react: ">=16.8.0"
react-dom: ">=16.8.0"
slate: ">=0.103.0"
@@ -7480,11 +7498,11 @@ __metadata:
"@udecode/plate-code-block": "npm:40.0.0"
"@udecode/plate-combobox": "npm:40.0.0"
"@udecode/plate-comments": "npm:40.0.0"
- "@udecode/plate-common": "npm:40.0.3"
+ "@udecode/plate-common": "npm:40.2.8"
"@udecode/plate-csv": "npm:40.0.0"
"@udecode/plate-diff": "npm:40.0.0"
- "@udecode/plate-docx": "npm:40.2.6"
- "@udecode/plate-find-replace": "npm:40.0.0"
+ "@udecode/plate-docx": "npm:40.2.7"
+ "@udecode/plate-find-replace": "npm:40.2.8"
"@udecode/plate-floating": "npm:40.0.0"
"@udecode/plate-font": "npm:40.0.0"
"@udecode/plate-heading": "npm:40.2.6"
@@ -7499,16 +7517,16 @@ __metadata:
"@udecode/plate-link": "npm:40.0.0"
"@udecode/plate-list": "npm:40.0.0"
"@udecode/plate-markdown": "npm:40.2.2"
- "@udecode/plate-media": "npm:40.2.4"
+ "@udecode/plate-media": "npm:40.2.7"
"@udecode/plate-mention": "npm:40.0.0"
"@udecode/plate-node-id": "npm:40.0.0"
"@udecode/plate-normalizers": "npm:40.0.0"
"@udecode/plate-reset-node": "npm:40.0.0"
"@udecode/plate-resizable": "npm:40.0.0"
"@udecode/plate-select": "npm:40.0.0"
- "@udecode/plate-selection": "npm:40.1.0"
+ "@udecode/plate-selection": "npm:40.2.9"
"@udecode/plate-slash-command": "npm:40.0.0"
- "@udecode/plate-suggestion": "npm:40.0.0"
+ "@udecode/plate-suggestion": "npm:40.2.8"
"@udecode/plate-tabbable": "npm:40.0.0"
"@udecode/plate-table": "npm:40.0.0"
"@udecode/plate-toggle": "npm:40.0.0"
@@ -7533,7 +7551,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/react-utils@npm:39.0.0, @udecode/react-utils@workspace:^, @udecode/react-utils@workspace:packages/react-utils":
+"@udecode/react-utils@npm:40.2.8, @udecode/react-utils@workspace:^, @udecode/react-utils@workspace:packages/react-utils":
version: 0.0.0-use.local
resolution: "@udecode/react-utils@workspace:packages/react-utils"
dependencies:
@@ -7546,11 +7564,11 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/slate-react@npm:40.0.0, @udecode/slate-react@workspace:^, @udecode/slate-react@workspace:packages/slate-react":
+"@udecode/slate-react@npm:40.2.8, @udecode/slate-react@workspace:^, @udecode/slate-react@workspace:packages/slate-react":
version: 0.0.0-use.local
resolution: "@udecode/slate-react@workspace:packages/slate-react"
dependencies:
- "@udecode/react-utils": "npm:39.0.0"
+ "@udecode/react-utils": "npm:40.2.8"
"@udecode/slate": "npm:39.2.1"
"@udecode/utils": "npm:37.0.0"
peerDependencies:
@@ -7562,7 +7580,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@udecode/slate-utils@npm:39.2.20, @udecode/slate-utils@workspace:^, @udecode/slate-utils@workspace:packages/slate-utils":
+"@udecode/slate-utils@npm:40.2.7, @udecode/slate-utils@workspace:^, @udecode/slate-utils@workspace:packages/slate-utils":
version: 0.0.0-use.local
resolution: "@udecode/slate-utils@workspace:packages/slate-utils"
dependencies:
@@ -11939,6 +11957,13 @@ __metadata:
languageName: node
linkType: hard
+"gemoji@npm:8.1.0":
+ version: 8.1.0
+ resolution: "gemoji@npm:8.1.0"
+ checksum: 10c0/7d70bb3c3f5fa0e8ceef0934d45b03353de54474963092b1859732e43f4b2187eb70c7798af60a5373fb4099829ec1100cf9240182a3676ea74e8cb9e3b1942b
+ languageName: node
+ linkType: hard
+
"gensync@npm:^1.0.0-beta.2":
version: 1.0.0-beta.2
resolution: "gensync@npm:1.0.0-beta.2"
@@ -17414,6 +17439,7 @@ __metadata:
eslint-plugin-testing-library: "npm:^6.3.0"
eslint-plugin-unicorn: "npm:^55.0.0"
eslint-plugin-unused-imports: "npm:^4.1.3"
+ gemoji: "npm:8.1.0"
jest: "npm:29.7.0"
jest-environment-jsdom: "npm:^29.7.0"
patch-package: "npm:^8.0.0"
@@ -22514,6 +22540,7 @@ __metadata:
"@udecode/plate-layout": "workspace:^"
"@udecode/plate-line-height": "workspace:^"
"@udecode/plate-link": "workspace:^"
+ "@udecode/plate-lint": "workspace:^"
"@udecode/plate-list": "workspace:^"
"@udecode/plate-markdown": "workspace:^"
"@udecode/plate-media": "workspace:^"