From c112c1a7e8ca3d626e341aabedb64400c5509410 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Sat, 7 Dec 2024 22:06:56 +0100 Subject: [PATCH] feat --- apps/www/content/docs/lint.mdx | 2 +- .../public/r/styles/default/lint-demo.json | 38 ++ .../public/r/styles/default/mode-toggle.json | 2 +- apps/www/src/__registry__/index.tsx | 8 +- .../{lint-emoji-demo.tsx => lint-demo.tsx} | 32 +- .../registry/default/plate-ui/lint-leaf.tsx | 20 +- .../default/plate-ui/lint-popover.tsx | 77 ++-- apps/www/src/registry/registry-examples.ts | 4 +- packages/lint/src/react/decorateLint.spec.ts | 80 +++-- packages/lint/src/react/lint-plugin.spec.ts | 22 +- packages/lint/src/react/lint-plugin.tsx | 21 +- .../react/plugins/lint-plugin-case.spec.ts | 4 +- .../src/react/plugins/lint-plugin-case.ts | 148 ++++---- .../react/plugins/lint-plugin-replace.spec.ts | 12 +- .../src/react/plugins/lint-plugin-replace.ts | 43 +-- packages/lint/src/react/runLint.spec.ts | 105 ++++-- packages/lint/src/react/runLint.ts | 38 +- packages/lint/src/react/types.ts | 80 +++-- .../react/utils/resolveLintConfigs.spec.ts | 335 ++++++++++++++---- .../src/react/utils/resolveLintConfigs.ts | 226 ++++++------ .../src/react/utils/useAnnotationSelected.ts | 17 +- .../slate-utils/src/queries/annotation.ts | 13 + .../queries/annotationToDecorations.spec.ts | 253 ++----------- .../src/queries/annotationToDecorations.ts | 89 ++--- .../queries/annotationsToDecorations.spec.ts | 276 +++++++++++++++ .../src/queries/annotationsToDecorations.ts | 22 ++ packages/slate-utils/src/queries/index.ts | 2 + packages/slate-utils/src/queries/parseNode.ts | 2 +- 28 files changed, 1165 insertions(+), 806 deletions(-) create mode 100644 apps/www/public/r/styles/default/lint-demo.json rename apps/www/src/registry/default/example/{lint-emoji-demo.tsx => lint-demo.tsx} (92%) create mode 100644 packages/slate-utils/src/queries/annotation.ts create mode 100644 packages/slate-utils/src/queries/annotationsToDecorations.spec.ts create mode 100644 packages/slate-utils/src/queries/annotationsToDecorations.ts diff --git a/apps/www/content/docs/lint.mdx b/apps/www/content/docs/lint.mdx index 4089970b86..14295a3a3c 100644 --- a/apps/www/content/docs/lint.mdx +++ b/apps/www/content/docs/lint.mdx @@ -9,7 +9,7 @@ This package is experimental. Expect breaking changes in future releases. - + 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 \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 \n \n \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/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 fcad75476d..ac75d2e57f 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -3733,13 +3733,13 @@ export const Index: Record = { subcategory: "", chunks: [] }, - "lint-emoji-demo": { - name: "lint-emoji-demo", + "lint-demo": { + name: "lint-demo", description: "", type: "registry:example", registryDependencies: ["editor","button","lint-leaf","lint-popover"], files: [{ - path: "src/registry/default/example/lint-emoji-demo.tsx", + path: "src/registry/default/example/lint-demo.tsx", type: "registry:example", target: "" },{ @@ -3747,7 +3747,7 @@ export const Index: Record = { type: "registry:example", target: "" }], - component: React.lazy(() => import("@/registry/default/example/lint-emoji-demo.tsx")), + component: React.lazy(() => import("@/registry/default/example/lint-demo.tsx")), source: "", category: "", subcategory: "", diff --git a/apps/www/src/registry/default/example/lint-emoji-demo.tsx b/apps/www/src/registry/default/example/lint-demo.tsx similarity index 92% rename from apps/www/src/registry/default/example/lint-emoji-demo.tsx rename to apps/www/src/registry/default/example/lint-demo.tsx index 069d72ed85..49a7b76eef 100644 --- a/apps/www/src/registry/default/example/lint-emoji-demo.tsx +++ b/apps/www/src/registry/default/example/lint-demo.tsx @@ -78,10 +78,14 @@ function EmojiPlateEditorContent() { api.lint.run([ { ...replaceLintPlugin.configs.all, + targets: [{ id: editor.children[0].id as string }], + }, + { settings: { - replaceMap: emojiMap, + replace: { + replaceMap: emojiMap, + }, }, - targets: [{ id: editor.children[0].id as string }], }, ]); }; @@ -90,13 +94,13 @@ function EmojiPlateEditorContent() { api.lint.run([ replaceLintPlugin.configs.all, { - languageOptions: { - parserOptions: { - maxLength: 4, - }, - }, settings: { - replaceMap: emojiMap, + replace: { + parserOptions: { + maxLength: 4, + }, + replaceMap: emojiMap, + }, }, }, ]); @@ -107,7 +111,9 @@ function EmojiPlateEditorContent() { caseLintPlugin.configs.all, { settings: { - ignoredWords: ['iPhone', 'iOS', 'iPad'], + case: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + }, }, }, ]); @@ -119,8 +125,12 @@ function EmojiPlateEditorContent() { caseLintPlugin.configs.all, { settings: { - ignoredWords: ['iPhone', 'iOS', 'iPad'], - replaceMap: emojiMap, + case: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + }, + replace: { + replaceMap: emojiMap, + }, }, }, ]); diff --git a/apps/www/src/registry/default/plate-ui/lint-leaf.tsx b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx index 4f44298eb3..cf693483f9 100644 --- a/apps/www/src/registry/default/plate-ui/lint-leaf.tsx +++ b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx @@ -17,11 +17,21 @@ export const LintLeaf = withRef( { - e.preventDefault(); - console.log(leaf.annotation); - setOption('activeAnnotation', leaf.annotation); + className={cn( + 'bg-inherit', + leaf.annotations.some((annotation) => 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} > diff --git a/apps/www/src/registry/default/plate-ui/lint-popover.tsx b/apps/www/src/registry/default/plate-ui/lint-popover.tsx index f8f989bb6d..4e0b4b0a1e 100644 --- a/apps/www/src/registry/default/plate-ui/lint-popover.tsx +++ b/apps/www/src/registry/default/plate-ui/lint-popover.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect } from 'react'; +import React from 'react'; import { cn } from '@udecode/cn'; import { @@ -19,33 +19,30 @@ import { 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 activeAnnotation = useOption('activeAnnotation'); - console.log(activeAnnotation); + const activeAnnotations = useOption('activeAnnotations'); const selected = useAnnotationSelected(); const toolbarRef = React.useRef(null); const firstButtonRef = React.useRef(null); const [virtualRef] = useVirtualRefState({ - at: activeAnnotation?.range, + at: activeAnnotations?.[0]?.range, }); - const suggestions = activeAnnotation?.suggest ?? []; - const open = selected && !!virtualRef?.current && suggestions.length > 0; - useEffect(() => { - if (!selected) { - setOption('activeAnnotation', null); - } - }, [selected, setOption]); + const open = + selected && + !!virtualRef?.current && + activeAnnotations?.some((annotation) => annotation.suggest?.length); useHotkeys( 'ctrl+space', (e) => { - if (api.lint.setSelectedactiveAnnotation()) { + if (api.lint.setSelectedActiveAnnotations()) { e.preventDefault(); } }, @@ -55,7 +52,7 @@ export function LintPopover() { useHotkeys( 'enter', (e) => { - const suggestion = activeAnnotation?.suggest?.[0]; + const suggestion = activeAnnotations?.[0]?.suggest?.[0]; if (suggestion) { e.preventDefault(); @@ -110,42 +107,48 @@ export function LintPopover() { { e.preventDefault(); - // focusEditor(editor); + focusEditor(editor); }} onEscapeKeyDown={(e) => { e.preventDefault(); - setOption('activeAnnotation', null); + setOption('activeAnnotations', null); }} onOpenAutoFocus={(e) => { e.preventDefault(); }} > - - {suggestions.map((suggestion, index) => ( - + {activeAnnotations?.map((annotation, anotIndex) => ( + +
+ {annotation.suggest?.map((suggestion, suggIndex) => ( + { + suggestion.fix(); + }} + > + {suggestion.data?.text as string} + + ))} +
+ + {anotIndex < activeAnnotations.length - 1 && ( + )} - onClick={() => { - suggestion.fix(); - }} - > - {suggestion.data?.text} -
+ ))}
diff --git a/apps/www/src/registry/registry-examples.ts b/apps/www/src/registry/registry-examples.ts index 02ddc2cff6..d5c9987c06 100644 --- a/apps/www/src/registry/registry-examples.ts +++ b/apps/www/src/registry/registry-examples.ts @@ -1554,13 +1554,13 @@ export const examples: Registry = [ ], }, files: [ - { path: 'example/lint-emoji-demo.tsx', type: 'registry:example' }, + { path: 'example/lint-demo.tsx', type: 'registry:example' }, { path: 'components/editor/use-create-editor.ts', type: 'registry:example', }, ], - name: 'lint-emoji-demo', + name: 'lint-demo', registryDependencies: ['editor', 'button', 'lint-leaf', 'lint-popover'], type: 'registry:example', }, diff --git a/packages/lint/src/react/decorateLint.spec.ts b/packages/lint/src/react/decorateLint.spec.ts index 9a079e74b1..60d2190353 100644 --- a/packages/lint/src/react/decorateLint.spec.ts +++ b/packages/lint/src/react/decorateLint.spec.ts @@ -24,10 +24,12 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: new Map([ - ['hello', [{ text: '👋', type: 'emoji' }]], - ['world', [{ text: '🌍', type: 'emoji' }]], - ]), + replace: { + replaceMap: new Map([ + ['hello', [{ text: '👋', type: 'emoji' }]], + ['world', [{ text: '🌍', type: 'emoji' }]], + ]), + }, }, }, ]); @@ -40,19 +42,23 @@ describe('decorateLint', () => { expect(decorations).toEqual([ { anchor: { offset: 0, path: [0, 0] }, - annotation: expect.objectContaining({ - data: { type: 'emoji' }, - text: 'hello', - }), + annotations: [ + expect.objectContaining({ + text: 'hello', + type: 'emoji', + }), + ], focus: { offset: 5, path: [0, 0] }, lint: true, }, { anchor: { offset: 6, path: [0, 0] }, - annotation: expect.objectContaining({ - data: { type: 'emoji' }, - text: 'world', - }), + annotations: [ + expect.objectContaining({ + text: 'world', + type: 'emoji', + }), + ], focus: { offset: 11, path: [0, 0] }, lint: true, }, @@ -82,10 +88,12 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: new Map([ - ['hello', [{ text: '👋', type: 'emoji' }]], - ['world', [{ text: '🌍', type: 'emoji' }]], - ]), + replace: { + replaceMap: new Map([ + ['hello', [{ text: '👋', type: 'emoji' }]], + ['world', [{ text: '🌍', type: 'emoji' }]], + ]), + }, }, }, ]); @@ -99,38 +107,38 @@ describe('decorateLint', () => { // "hello" annotation spans 3 leaves { anchor: { offset: 0, path: [0, 0] }, - annotation: expect.objectContaining({ text: 'hello' }), + annotations: [expect.objectContaining({ text: 'hello' })], focus: { offset: 2, path: [0, 0] }, lint: true, }, { anchor: { offset: 0, path: [0, 1] }, - annotation: expect.objectContaining({ text: 'hello' }), + annotations: [expect.objectContaining({ text: 'hello' })], focus: { offset: 2, path: [0, 1] }, lint: true, }, { anchor: { offset: 0, path: [0, 2] }, - annotation: expect.objectContaining({ text: 'hello' }), + annotations: [expect.objectContaining({ text: 'hello' })], focus: { offset: 1, path: [0, 2] }, lint: true, }, // "world" annotation spans 3 leaves { anchor: { offset: 2, path: [0, 2] }, - annotation: expect.objectContaining({ text: 'world' }), + annotations: [expect.objectContaining({ text: 'world' })], focus: { offset: 4, path: [0, 2] }, lint: true, }, { anchor: { offset: 0, path: [0, 3] }, - annotation: expect.objectContaining({ text: 'world' }), + annotations: [expect.objectContaining({ text: 'world' })], focus: { offset: 1, path: [0, 3] }, lint: true, }, { anchor: { offset: 0, path: [0, 4] }, - annotation: expect.objectContaining({ text: 'world' }), + annotations: [expect.objectContaining({ text: 'world' })], focus: { offset: 2, path: [0, 4] }, lint: true, }, @@ -160,10 +168,12 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: new Map([ - ['hello', [{ text: '👋', type: 'emoji' }]], - ['world', [{ text: '🌍', type: 'emoji' }]], - ]), + replace: { + replaceMap: new Map([ + ['hello', [{ text: '👋', type: 'emoji' }]], + ['world', [{ text: '🌍', type: 'emoji' }]], + ]), + }, }, }, ]); @@ -176,13 +186,13 @@ describe('decorateLint', () => { expect(decorations).toEqual([ { anchor: { offset: 0, path: [0, 1] }, - annotation: expect.objectContaining({ text: 'hello' }), + annotations: [expect.objectContaining({ text: 'hello' })], focus: { offset: 5, path: [0, 1] }, lint: true, }, { anchor: { offset: 0, path: [0, 3] }, - annotation: expect.objectContaining({ text: 'world' }), + annotations: [expect.objectContaining({ text: 'world' })], focus: { offset: 5, path: [0, 3] }, lint: true, }, @@ -212,10 +222,12 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: new Map([ - ['hello', [{ text: '👋', type: 'emoji' }]], - ['world', [{ text: '🌍', type: 'emoji' }]], - ]), + replace: { + replaceMap: new Map([ + ['hello', [{ text: '👋', type: 'emoji' }]], + ['world', [{ text: '🌍', type: 'emoji' }]], + ]), + }, }, }, ]); @@ -228,13 +240,13 @@ describe('decorateLint', () => { expect(decorations).toEqual([ { anchor: { offset: 0, path: [0, 1] }, - annotation: expect.objectContaining({ text: 'hello' }), + annotations: [expect.objectContaining({ text: 'hello' })], focus: { offset: 5, path: [0, 1] }, lint: true, }, { anchor: { offset: 0, path: [0, 3] }, - annotation: expect.objectContaining({ text: 'world' }), + annotations: [expect.objectContaining({ text: 'world' })], focus: { offset: 5, path: [0, 3] }, lint: true, }, diff --git a/packages/lint/src/react/lint-plugin.spec.ts b/packages/lint/src/react/lint-plugin.spec.ts index 304fa3325c..10abcffe4c 100644 --- a/packages/lint/src/react/lint-plugin.spec.ts +++ b/packages/lint/src/react/lint-plugin.spec.ts @@ -29,20 +29,18 @@ describe('LintPlugin', () => { text: 'hello', }; editor.setOption(ExperimentalLintPlugin, 'annotations', [activeAnnotation]); - editor.setOption( - ExperimentalLintPlugin, - 'activeAnnotation', - 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.setSelectedactiveAnnotation(); + const result = editor.api.lint.setSelectedActiveAnnotations(); expect(result).toBe(true); expect( - editor.getOption(ExperimentalLintPlugin, 'activeAnnotation')?.text + editor.getOption(ExperimentalLintPlugin, 'activeAnnotations')?.[0]?.text ).toBe('hello'); }); @@ -72,16 +70,14 @@ describe('LintPlugin', () => { text: 'world', } as any, ]); - editor.setOption( - ExperimentalLintPlugin, - 'activeAnnotation', - activeAnnotation - ); + editor.setOption(ExperimentalLintPlugin, 'activeAnnotations', [ + activeAnnotation, + ]); const match = editor.tf.lint.focusNextMatch(); expect(match?.text).toBe('world'); expect( - editor.getOption(ExperimentalLintPlugin, 'activeAnnotation')?.text + 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 index 9e1c9816f2..744a751946 100644 --- a/packages/lint/src/react/lint-plugin.tsx +++ b/packages/lint/src/react/lint-plugin.tsx @@ -16,7 +16,7 @@ import { runLint } from './runLint'; export type LintConfig = PluginConfig< 'lint', { - activeAnnotation: LintAnnotation | null; + activeAnnotations: LintAnnotation[] | null; annotations: LintAnnotation[]; configs: LintConfigArray; }, @@ -27,7 +27,7 @@ export type LintConfig = PluginConfig< }) => LintAnnotation | undefined; reset: () => void; run: (configs: LintConfigArray) => void; - setSelectedactiveAnnotation: () => boolean | undefined; + setSelectedActiveAnnotations: () => boolean | undefined; }; }, { @@ -46,7 +46,7 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ isLeaf: true, }, options: { - activeAnnotation: null, + activeAnnotations: null, annotations: [], configs: [], }, @@ -62,13 +62,13 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ return { getNextMatch: (options) => { - const { activeAnnotation, annotations } = getOptions(); + const { activeAnnotations, annotations } = getOptions(); const ranges = annotations.map( (annotation) => annotation.rangeRef.current! ); const nextRange = getNextRange(editor, { - from: activeAnnotation?.rangeRef.current, + from: activeAnnotations?.[0]?.rangeRef.current, ranges, reverse: options?.reverse, }); @@ -83,15 +83,15 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ editor.api.redecorate(); }, run: bindFirst(runLint, editor), - setSelectedactiveAnnotation: () => { + setSelectedActiveAnnotations: () => { if (!editor.selection) return false; - const activeAnnotation = getOptions().annotations.find((match) => + const activeAnnotations = getOptions().annotations.filter((match) => isSelectionInRange(editor, { at: match.rangeRef.current! }) ); - if (activeAnnotation) { - setOption('activeAnnotation', activeAnnotation); + if (activeAnnotations.length > 0) { + setOption('activeAnnotations', activeAnnotations); return true; } @@ -104,7 +104,8 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ ({ api, editor, setOption }) => ({ focusNextMatch: (options) => { const match = api.lint.getNextMatch(options); - setOption('activeAnnotation', match ?? null); + // TODO: handle multiple active annotations + setOption('activeAnnotations', match ? [match] : null); if (match) { collapseSelection(editor); diff --git a/packages/lint/src/react/plugins/lint-plugin-case.spec.ts b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts index d8a01da905..aa9b61383b 100644 --- a/packages/lint/src/react/plugins/lint-plugin-case.spec.ts +++ b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts @@ -51,7 +51,9 @@ describe('caseLintPlugin', () => { caseLintPlugin.configs.all, { settings: { - ignoredWords: ['iPhone', 'ios'], + case: { + ignoredWords: ['iPhone', 'ios'], + }, }, }, ]); diff --git a/packages/lint/src/react/plugins/lint-plugin-case.ts b/packages/lint/src/react/plugins/lint-plugin-case.ts index 530ec3673f..12f5899e28 100644 --- a/packages/lint/src/react/plugins/lint-plugin-case.ts +++ b/packages/lint/src/react/plugins/lint-plugin-case.ts @@ -7,7 +7,7 @@ export type CaseLintPluginOptions = { const caseMatchRule: LintConfigPluginRule = { create: ({ fixer, options }) => { - const ignoredWords = options[0].ignoredWords ?? []; + const ignoredWords = options.ignoredWords ?? []; return { Annotation: (annotation) => { @@ -48,11 +48,9 @@ const caseMatchRule: LintConfigPluginRule = { }; }, meta: { - defaultOptions: [ - { - ignoredWords: [], - }, - ], + defaultOptions: { + ignoredWords: [], + }, hasSuggestions: true, type: 'suggestion', }, @@ -71,76 +69,80 @@ export const caseLintPlugin = { ...plugin, configs: { all: { - languageOptions: { - parserOptions: (context) => { - const { options } = context; - const ignoredWords = options[0]?.ignoredWords ?? []; - - // 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 ( - 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, - }; - }, - }, name: 'case/all', plugins: { case: plugin }, rules: { - 'case/capitalize-sentence': ['error'], + '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, + }; + }, + }, }, }, }, diff --git a/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts b/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts index f97912628b..e89123f215 100644 --- a/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts +++ b/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts @@ -38,7 +38,9 @@ describe('replaceLintPlugin', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap, + replace: { + replaceMap, + }, }, }, ]); @@ -46,9 +48,6 @@ describe('replaceLintPlugin', () => { const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations'); expect(annotations).toEqual([ { - data: { - type: 'emoji', - }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), @@ -62,11 +61,9 @@ describe('replaceLintPlugin', () => { }, ], text: 'hello', + type: 'emoji', }, { - data: { - type: 'emoji', - }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), @@ -87,6 +84,7 @@ describe('replaceLintPlugin', () => { }, ], 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 index aca4682db7..9d7f3a9385 100644 --- a/packages/lint/src/react/plugins/lint-plugin-replace.ts +++ b/packages/lint/src/react/plugins/lint-plugin-replace.ts @@ -11,8 +11,8 @@ export type ReplaceLintPluginOptions = { const replaceMatchRule: LintConfigPluginRule = { create: ({ fixer, options }) => { - const replaceMap = options[0].replaceMap; - const maxSuggestions = options[0].maxSuggestions; + const replaceMap = options.replaceMap; + const maxSuggestions = options.maxSuggestions; return { Annotation: (annotation) => { @@ -20,10 +20,6 @@ const replaceMatchRule: LintConfigPluginRule = { return { ...annotation, - data: { - ...annotation.data, - type: replacements?.[0]?.type, - }, messageId: 'replaceWithText', suggest: replacements?.slice(0, maxSuggestions).map( (replacement): LintAnnotationSuggestion => ({ @@ -40,16 +36,15 @@ const replaceMatchRule: LintConfigPluginRule = { }, }) ), + type: replacements?.[0]?.type, }; }, }; }, meta: { - defaultOptions: [ - { - maxSuggestions: 8, - }, - ], + defaultOptions: { + maxSuggestions: 8, + }, hasSuggestions: true, type: 'suggestion', }, @@ -68,22 +63,22 @@ export const replaceLintPlugin = { ...plugin, configs: { all: { - languageOptions: { - parserOptions: ({ options }) => { - const replaceMap = options[0].replaceMap; - - return { - match: ({ text }) => { - return !!replaceMap?.has(text.toLowerCase()); - }, - splitPattern: /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, - }; - }, - }, name: 'replace/all', plugins: { replace: plugin }, rules: { - 'replace/text': ['error'], + 'replace/text': ['error', {}], + }, + settings: { + replace: { + parserOptions: ({ options }) => { + return { + match: ({ text }) => { + return !!options.replaceMap?.has(text.toLowerCase()); + }, + splitPattern: /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, + }; + }, + }, }, }, }, diff --git a/packages/lint/src/react/runLint.spec.ts b/packages/lint/src/react/runLint.spec.ts index d035e0eb1c..868c5355cc 100644 --- a/packages/lint/src/react/runLint.spec.ts +++ b/packages/lint/src/react/runLint.spec.ts @@ -37,7 +37,9 @@ describe('runLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap, + replace: { + replaceMap, + }, }, }, ]); @@ -45,9 +47,6 @@ describe('runLint', () => { const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations'); expect(annotations).toEqual([ { - data: { - type: 'emoji', - }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), @@ -61,11 +60,9 @@ describe('runLint', () => { }, ], text: 'hello', + type: 'emoji', }, { - data: { - type: 'emoji', - }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), @@ -86,6 +83,7 @@ describe('runLint', () => { }, ], text: 'world', + type: 'emoji', }, ]); }); @@ -116,7 +114,9 @@ describe('runLint', () => { }, { settings: { - replaceMap, + replace: { + replaceMap, + }, }, }, ]); @@ -124,9 +124,6 @@ describe('runLint', () => { const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations'); expect(annotations).toEqual([ { - data: { - type: 'emoji', - }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), @@ -140,6 +137,7 @@ describe('runLint', () => { }, ], text: 'hello', + type: 'emoji', }, ]); }); @@ -154,7 +152,9 @@ describe('runLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap, + replace: { + replaceMap, + }, }, }, ]); @@ -191,7 +191,9 @@ describe('runLint', () => { plugin.api.lint.run([ replaceLintPlugin.configs.all, { - settings: { replaceMap: new Map([['hello', [{ text: '👋' }]]]) }, + settings: { + replace: { replaceMap: new Map([['hello', [{ text: '👋' }]]]) }, + }, }, ]); @@ -223,10 +225,9 @@ describe('runLint', () => { caseLintPlugin.configs.all, { settings: { - replaceMap: new Map([ - ['hello', [{ text: '👋', type: 'emoji' }]], - ['world', [{ text: '🌍', type: 'emoji' }]], - ]), + replace: { + replaceMap: new Map([['world', [{ text: '🌍', type: 'emoji' }]]]), + }, }, }, ]); @@ -237,26 +238,17 @@ describe('runLint', () => { expect(annotations).toEqual([ // Replace annotations { - data: { type: 'emoji' }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), suggest: [ { - data: { text: '👋', type: 'emoji' }, + data: { text: '🌍', type: 'emoji' }, fix: expect.any(Function), }, ], - text: 'hello', - }, - // Case annotation for 'this' - { - data: { type: undefined }, - messageId: 'replaceWithText', - range: expect.any(Object), - rangeRef: expect.any(Object), - suggest: undefined, - text: 'this', + text: 'world', + type: 'emoji', }, // Case annotation for 'hello' { @@ -288,16 +280,53 @@ describe('runLint', () => { 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' }]], + ]), + }, + }, + }, + ]); - // Verify both types of fixes work - annotations[0].suggest?.[0].fix(); // Replace 'hello' with emoji - expect(editor.children[0].children[0].text).toBe( - '👋 world. this is a test.' - ); + const annotations = editor.getOption(ExperimentalLintPlugin, 'annotations'); - annotations[2].suggest?.[0].fix(); // Capitalize 'this' - expect(editor.children[0].children[0].text).toBe( - '👋 world. This is a test.' - ); + // 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 index 981109a89e..ff9106ab43 100644 --- a/packages/lint/src/react/runLint.ts +++ b/packages/lint/src/react/runLint.ts @@ -25,9 +25,6 @@ export const runLint = (editor: PlateEditor, configs: LintConfigArray) => { const { setOption, tf } = ctx; setOption('configs', configs); - const resolvedRules = resolveLintConfigs(configs); - - console.log('Resolved rules:', Object.keys(resolvedRules)); // Create fixer actions once const fixerActions: LintFixer = { @@ -50,14 +47,13 @@ export const runLint = (editor: PlateEditor, configs: LintConfigArray) => { }, }; - console.log(resolvedRules); - - // Process each rule - const annotations = Object.entries(resolvedRules).flatMap( - ([ruleId, rule]) => { - console.log('Processing rule:', ruleId); - console.log('Rule settings:', rule.settings); + 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, @@ -71,7 +67,7 @@ export const runLint = (editor: PlateEditor, configs: LintConfigArray) => { } else { collapseSelection(editor); setTimeout(() => { - setOption('activeAnnotation', null); + setOption('activeAnnotations', null); }, 0); } }, @@ -82,23 +78,14 @@ export const runLint = (editor: PlateEditor, configs: LintConfigArray) => { ...(ctx as unknown as PlatePluginContext), id: ruleId, fixer, - languageOptions: rule.languageOptions ?? {}, options: rule.options ?? [], - settings: rule.settings ?? {}, }; - let parserOptions = rule.languageOptions?.parserOptions; - - if (typeof parserOptions === 'function') { - parserOptions = parserOptions(context); - } - - console.log('Parser options:', parserOptions); + const parserOptions = rule.options.parserOptions(context); const { Annotation } = rule.create(context); - - // Parse with transform const { annotations } = experimental_parseNode(editor, { + at: [index], match: parserOptions?.match ?? (() => false), maxLength: parserOptions?.maxLength, minLength: parserOptions?.minLength, @@ -106,13 +93,10 @@ export const runLint = (editor: PlateEditor, configs: LintConfigArray) => { transform: Annotation, }); - console.log(`Annotations for ${ruleId}:`, annotations.length); - return annotations; - } - ); + }); + }); - console.log('Total annotations:', annotations.length); setOption('annotations', annotations); editor.api.redecorate(); }; diff --git a/packages/lint/src/react/types.ts b/packages/lint/src/react/types.ts index 2cae6372be..ee314f3016 100644 --- a/packages/lint/src/react/types.ts +++ b/packages/lint/src/react/types.ts @@ -1,26 +1,27 @@ -import type { AnyObject, TText, UnknownObject } from '@udecode/plate-common'; +import type { + Annotation, + AnyObject, + TText, + UnknownObject, +} from '@udecode/plate-common'; import type { PlatePluginContext } from '@udecode/plate-common/react'; -import type { Range, RangeRef } from 'slate'; +import type { Range } from 'slate'; // ─── Plate ────────────────────────────────────────────────────────────────── export type LintDecoration = TText & { - annotation: LintAnnotation; + annotations: LintAnnotation[]; lint: boolean; }; -export type LintAnnotation = { - range: Range; - rangeRef: RangeRef; - text: string; - data?: UnknownObject; +export type LintAnnotation = Annotation<{ messageId?: string; suggest?: LintAnnotationSuggestion[]; -}; +}>; export type LintAnnotationSuggestion = { fix: (options?: { goNext?: boolean }) => void; - data?: Record; + data?: UnknownObject; messageId?: string; }; @@ -28,8 +29,6 @@ export type LintAnnotationSuggestion = { export type LintConfigArray = LintConfigObject[]; export type LintConfigObject = { - /** Language-specific options for parsing and processing */ - languageOptions?: LintLanguageOptions; /** An object containing settings related to the linting process. */ linterOptions?: AnyObject; /** @@ -52,17 +51,22 @@ export type LintConfigObject = { * An object containing name-value pairs of information that should be * available to all rules. */ - settings?: LintConfigRuleOptions>; + settings?: LintConfigSettings>; /** The targets to match. */ targets?: { id?: string }[]; }; -export type LintConfigRuleOptions = T & UnknownObject; +export type LintConfigRuleOptions = { + parserOptions?: + | ((context: LintConfigPluginRuleContext) => LintParserOptions) + | LintParserOptions; +} & T & + UnknownObject; -export type LintConfigRuleOptionsArray = [ - LintConfigRuleOptions, - ...LintConfigRuleOptions[], -]; +export type LintConfigSettings = Record< + string, + LintConfigRuleOptions +>; export type LintAnnotationOptions = { annotations?: { @@ -75,13 +79,17 @@ export type LintAnnotationOptions = { export type LintConfigRule = | LintConfigRuleLevel - | LintConfigRuleLevelAndOptions; + | LintConfigRuleLevelAndOptions + | [LintConfigRuleLevel]; export type LintConfigRuleLevel = | LintConfigRuleSeverity | LintConfigRuleSeverityString; -export type LintConfigRuleLevelAndOptions = [LintConfigRuleLevel, ...unknown[]]; +export type LintConfigRuleLevelAndOptions = [ + LintConfigRuleLevel, + LintConfigRuleOptions, +]; export type LintConfigRules = Partial>; @@ -124,10 +132,8 @@ export type LintConfigPluginRule = { * Specifies default options for the rule. If present, any user-provided * options in their config will be merged on top of them recursively. */ - defaultOptions?: LintConfigRuleOptionsArray>; + defaultOptions?: LintConfigRuleOptions>; hasSuggestions?: boolean; - /** Overrides the language options for the rule. */ - languageOptions?: LintLanguageOptions; messages?: Record; type?: 'problem' | 'suggestion'; }; @@ -135,7 +141,7 @@ export type LintConfigPluginRule = { export type LintConfigPluginRuleContext = Pick< ResolvedLintRule, - 'languageOptions' | 'options' | 'settings' + 'options' > & { /** The id of the rule. */ id: string; @@ -173,26 +179,13 @@ export type LintParserOptions = { splitPattern?: RegExp; }; -// Add new language options types -export type LintLanguageOptions = T & { - /** Custom parser implementation */ - // parser?: typeof findRanges; - /** Parser-specific options */ - parserOptions?: - | ((context: LintConfigPluginRuleContext) => LintParserOptions) - | LintParserOptions; -}; - // ─── Resolved Rules ──────────────────────────────────────────────────────────── export type ResolvedLintRule = Pick< LintConfigPluginRule, 'create' | 'meta' > & - Pick, 'languageOptions' | 'settings' | 'targets'> & { - languageOptions: { - parserOptions?: LintParserOptions; - }; + Pick, 'targets'> & { linterOptions: { severity: LintConfigRuleSeverityString; }; @@ -202,9 +195,18 @@ export type ResolvedLintRule = Pick< * An array of the configured options for this rule. This array does not * include the rule severity. */ - options: LintConfigRuleOptionsArray; + options: ResolvedLintRuleOptions; }; +export type ResolvedParserOptions = ( + context: LintConfigPluginRuleContext +) => LintParserOptions; + +export type ResolvedLintRuleOptions = { + parserOptions: ResolvedParserOptions; +} & T & + UnknownObject; + export type ResolvedLintRules = Record; // export type LintAnalysisType = diff --git a/packages/lint/src/react/utils/resolveLintConfigs.spec.ts b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts index 976e49261b..37f444b096 100644 --- a/packages/lint/src/react/utils/resolveLintConfigs.spec.ts +++ b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts @@ -1,6 +1,7 @@ /* 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'; @@ -27,7 +28,9 @@ describe('resolveLintConfigs', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: replaceMap, + replace: { + replaceMap: replaceMap, + }, }, }, ]; @@ -38,22 +41,22 @@ describe('resolveLintConfigs', () => { expect(result[ruleKey]).toBeDefined(); expect(result[ruleKey].name).toBe(ruleKey); expect(result[ruleKey].linterOptions.severity).toBe('error'); - expect(result[ruleKey].options[0].replaceMap).toBe(replaceMap); - expect(result[ruleKey].options[0].maxSuggestions).toBe(8); + 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, { - languageOptions: { - parserOptions: { - match: (params) => true, - minLength: 4, - }, - }, settings: { - replaceMap: replaceMap, + replace: { + parserOptions: { + match: (params) => true, + minLength: 4, + }, + replaceMap: replaceMap, + }, }, }, ]; @@ -61,25 +64,27 @@ describe('resolveLintConfigs', () => { const result = resolveLintConfigs(configs); const ruleKey = 'replace/text'; - expect(result[ruleKey].languageOptions.parserOptions).toBeDefined(); - - const parserOptionsFn = result[ruleKey].languageOptions.parserOptions; - - if (typeof parserOptionsFn !== 'function') { - throw new TypeError('Expected parserOptions to be a function'); - } + expect(result[ruleKey].options.parserOptions).toBeDefined(); - const parserOptions = parserOptionsFn({ - id: 'test', - fixer: {}, - languageOptions: {}, - options: [{ replaceMap: replaceMap }], - settings: {}, + const parserOptions = result[ruleKey].options.parserOptions({ + options: result[ruleKey].options, } as any); - expect(parserOptions.minLength).toBe(4); - expect(parserOptions.splitPattern).toBeDefined(); + // 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', () => { @@ -92,7 +97,9 @@ describe('resolveLintConfigs', () => { }, { settings: { - replaceMap: replaceMap, + replace: { + replaceMap: replaceMap, + }, }, }, ]; @@ -111,7 +118,9 @@ describe('resolveLintConfigs', () => { }, { settings: { - replaceMap: replaceMap, + replace: { + replaceMap: replaceMap, + }, }, }, ]; @@ -130,7 +139,9 @@ describe('resolveLintConfigs', () => { }, { settings: { - replaceMap: replaceMap, + replace: { + replaceMap: replaceMap, + }, }, }, ]; @@ -156,15 +167,15 @@ describe('resolveLintConfigs', () => { const configs: LintConfigArray = [ replaceLintPlugin.configs.all, { - languageOptions: { - parserOptions: { - match: (params) => true, - maxLength: 4, - }, - }, settings: { - maxSuggestions: 5, - replaceMap: replaceMap, + replace: { + maxSuggestions: 5, + parserOptions: { + match: (params) => true, + maxLength: 4, + }, + replaceMap: replaceMap, + }, }, }, ]; @@ -172,9 +183,9 @@ describe('resolveLintConfigs', () => { const result = resolveLintConfigs(configs); const ruleKey = 'replace/text'; - expect(result[ruleKey].options[0].maxSuggestions).toBe(5); - expect(result[ruleKey].options[0].replaceMap).toBe(replaceMap); - expect(result[ruleKey].languageOptions.parserOptions).toBeDefined(); + 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', () => { @@ -182,24 +193,39 @@ describe('resolveLintConfigs', () => { { plugins: { replace: replaceLintPlugin }, rules: { - 'replace/text': ['error'], + 'replace/text': ['error', {}], }, settings: { - setting1: 'value1', + replace: { + parserOptions: { + match: ({ text }) => text.length > 2, + }, + setting1: 'value1', + }, }, }, { settings: { - setting2: 'value2', + 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].settings).toEqual({ + expect(result[ruleKey].options).toMatchObject({ + maxSuggestions: 8, setting1: 'value1', setting2: 'value2', }); @@ -213,7 +239,9 @@ describe('resolveLintConfigs', () => { }, { settings: { - replaceMap: replaceMap, + replace: { + replaceMap: replaceMap, + }, }, }, ]; @@ -222,28 +250,32 @@ describe('resolveLintConfigs', () => { const ruleKey = 'replace/text'; expect(result[ruleKey]).toBeDefined(); - expect(result[ruleKey].options[0].replaceMap).toBe(replaceMap); + expect(result[ruleKey].options.replaceMap).toBe(replaceMap); }); it('should merge parser options correctly when both are objects', () => { const configs: LintConfigArray = [ { - languageOptions: { - parserOptions: { - match: (params) => params.text.length > 2, - splitPattern: /\w+/g, - }, - }, plugins: { replace: replaceLintPlugin }, rules: { 'replace/text': ['error'], }, + settings: { + replace: { + parserOptions: { + match: (params) => params.text.length > 2, + splitPattern: /\w+/g, + }, + }, + }, }, { - languageOptions: { - parserOptions: { - match: (params) => params.text.length > 4, - minLength: 3, + settings: { + replace: { + parserOptions: { + match: (params) => params.text.length > 4, + minLength: 3, + }, }, }, }, @@ -253,7 +285,7 @@ describe('resolveLintConfigs', () => { const ruleKey = 'replace/text'; expect(result[ruleKey]).toBeDefined(); - const parserOptions = result[ruleKey].languageOptions.parserOptions; + const parserOptions = result[ruleKey].options.parserOptions({} as any); expect(parserOptions).toBeDefined(); expect(parserOptions).toHaveProperty('match'); @@ -262,12 +294,7 @@ describe('resolveLintConfigs', () => { }); it('should handle empty or undefined configs gracefully', () => { - const configs: LintConfigArray = [ - undefined as any, - null as any, - {}, - replaceLintPlugin.configs.all, - ]; + const configs: LintConfigArray = [{}, replaceLintPlugin.configs.all, {}]; const result = resolveLintConfigs(configs); expect(result).toBeDefined(); @@ -277,23 +304,27 @@ describe('resolveLintConfigs', () => { it('should handle function merging in parser options', () => { const configs: LintConfigArray = [ { - languageOptions: { - parserOptions: (context) => ({ - match: (params) => true, - splitPattern: /\w+/g, - }), - }, plugins: { replace: replaceLintPlugin }, rules: { 'replace/text': ['error'], }, + settings: { + replace: { + parserOptions: (context) => ({ + match: (params) => true, + splitPattern: /\w+/g, + }), + }, + }, }, { - languageOptions: { - parserOptions: (context) => ({ - match: (params) => true, - splitPattern: /\w+/g, - }), + settings: { + replace: { + parserOptions: (context) => ({ + match: (params) => true, + splitPattern: /\w+/g, + }), + }, }, }, ]; @@ -302,7 +333,163 @@ describe('resolveLintConfigs', () => { const ruleKey = 'replace/text'; expect(result[ruleKey]).toBeDefined(); - const parserOptions = result[ruleKey].languageOptions.parserOptions; + 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 index 634c813a08..d7aa5b5372 100644 --- a/packages/lint/src/react/utils/resolveLintConfigs.ts +++ b/packages/lint/src/react/utils/resolveLintConfigs.ts @@ -1,136 +1,126 @@ -import mergeWith from 'lodash/mergeWith.js'; - import type { LintConfigArray, LintConfigRule, LintConfigRuleLevel, - LintConfigRuleSeverity, - LintConfigRuleSeverityString, ResolvedLintRules, } from '../types'; -/** https://eslint.org/docs/latest/use/configure/configuration-files#cascading-configuration-objects */ -export function resolveLintConfigs( - configs: LintConfigArray -): ResolvedLintRules { - // Helper function for merging language options - const mergeLanguageOptions = (objValue: any, srcValue: any) => { - if (!objValue || !srcValue) return; +/** Convert parserOptions to a function */ +const toParserFunction = (parserOptions: any) => { + if (typeof parserOptions === 'function') { + return parserOptions; + } + if (parserOptions) { + return () => parserOptions; + } - return { - ...objValue, - parserOptions: objValue.parserOptions - ? typeof objValue.parserOptions === 'function' - ? (context: any) => ({ - ...objValue.parserOptions(context), - ...(typeof srcValue.parserOptions === 'function' - ? srcValue.parserOptions(context) - : srcValue.parserOptions), - }) - : { - ...objValue.parserOptions, - ...srcValue.parserOptions, - } - : srcValue.parserOptions, - }; - }; - - const mergedConfig = configs.reduce((acc, config) => { - return mergeWith({}, acc, config, (objValue, srcValue, key) => { - if (Array.isArray(objValue)) { - return srcValue; - } - // Special handling for rules to merge their options - if (objValue && typeof objValue === 'object' && 'rules' in objValue) { - return { - ...objValue, - rules: { - ...objValue.rules, - ...srcValue.rules, - }, - }; - } - // Special handling for languageOptions - if (key === 'languageOptions') { - return mergeLanguageOptions(objValue, srcValue); - } - }); - }, {}); - - if (!mergedConfig.plugins || !mergedConfig.rules) return {}; - - const defaultLanguageOptions = mergedConfig.languageOptions ?? {}; - - return Object.entries(mergedConfig.rules).reduce( - (rulesAcc, [ruleId, entry]) => { - const [pluginName, ruleName] = ruleId.split('/'); - const plugin = mergedConfig.plugins?.[pluginName]; - const rule = plugin?.rules?.[ruleName]; - - 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.slice(1) - : []; - const defaultOptions = rule.meta.defaultOptions || []; - - const options = [ - { - ...defaultOptions[0], - ...userOptions[0], - ...mergedConfig.settings, - }, - ...defaultOptions.slice(1), - ...userOptions.slice(1), - ]; - - const languageOptions = mergeWith( - {}, - defaultLanguageOptions, - rule.meta.languageOptions ?? {}, - (objValue, srcValue) => { - if (Array.isArray(objValue)) { - return srcValue; - } - } - ); + 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 { - ...rulesAcc, - [ruleId]: { - create: rule.create, - languageOptions, - linterOptions: { severity }, - meta: rule.meta, - name: ruleId, - options, - settings: mergedConfig.settings ?? {}, - }, + ...acc, + ...pluginSettings, + parserOptions: (ctx: any) => ({ + ...accParserFn(ctx), + ...newParserFn(ctx), + }), }; - }, - {} as any + } + + // 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 -): LintConfigRuleSeverityString { +function normalizeSeverity(level: LintConfigRuleLevel) { if (typeof level === 'number') { - const numericLevel = level as LintConfigRuleSeverity; - - return numericLevel === 0 ? 'off' : numericLevel === 1 ? 'warn' : 'error'; + return level === 0 ? 'off' : level === 1 ? 'warn' : 'error'; } - return level as LintConfigRuleSeverityString; + return level; } diff --git a/packages/lint/src/react/utils/useAnnotationSelected.ts b/packages/lint/src/react/utils/useAnnotationSelected.ts index 78e29c36dd..fbd9a1934f 100644 --- a/packages/lint/src/react/utils/useAnnotationSelected.ts +++ b/packages/lint/src/react/utils/useAnnotationSelected.ts @@ -8,18 +8,25 @@ import { ExperimentalLintPlugin } from '../lint-plugin'; export const useAnnotationSelected = () => { const { useOption } = useEditorPlugin(ExperimentalLintPlugin); - const activeAnnotation = useOption('activeAnnotation'); + const activeAnnotations = useOption('activeAnnotations'); return useEditorSelector( (editor) => { - if (!editor.selection || !activeAnnotation) return false; + if (!editor.selection || !activeAnnotations?.length) return false; + + const range = activeAnnotations[0].rangeRef.current; + if ( - isSelectionInRange(editor, { at: activeAnnotation.rangeRef.current! }) - ) + range && + isSelectionInRange(editor, { + at: range, + }) + ) { return true; + } return false; }, - [activeAnnotation] + [activeAnnotations] ); }; 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 index d3e09e21db..820d816430 100644 --- a/packages/slate-utils/src/queries/annotationToDecorations.spec.ts +++ b/packages/slate-utils/src/queries/annotationToDecorations.spec.ts @@ -1,10 +1,7 @@ import { createPlateEditor } from '@udecode/plate-core/react'; import { createRangeRef } from '@udecode/slate'; -import { - annotationToDecorations, - annotationsToDecorations, -} from './annotationToDecorations'; +import { annotationToDecorations } from './annotationToDecorations'; describe('annotationToDecorations', () => { const editor = createPlateEditor(); @@ -33,11 +30,9 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, + anchor: { offset: 0, path: [0, 0] }, + annotations: [annotation], + focus: { offset: 5, path: [0, 0] }, }, ]); }); @@ -66,25 +61,19 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 2, path: [0, 0] }, - }, + anchor: { offset: 0, path: [0, 0] }, + annotations: [annotation], + focus: { offset: 2, path: [0, 0] }, }, { - annotation, - range: { - anchor: { offset: 0, path: [0, 1] }, - focus: { offset: 2, path: [0, 1] }, - }, + anchor: { offset: 0, path: [0, 1] }, + annotations: [annotation], + focus: { offset: 2, path: [0, 1] }, }, { - annotation, - range: { - anchor: { offset: 0, path: [0, 2] }, - focus: { offset: 1, path: [0, 2] }, - }, + anchor: { offset: 0, path: [0, 2] }, + annotations: [annotation], + focus: { offset: 1, path: [0, 2] }, }, ]); }); @@ -117,11 +106,9 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 0, path: [0, 1] }, - focus: { offset: 5, path: [0, 1] }, - }, + anchor: { offset: 0, path: [0, 1] }, + annotations: [annotation], + focus: { offset: 5, path: [0, 1] }, }, ]); }); @@ -150,11 +137,9 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 0, path: [0, 1] }, - focus: { offset: 5, path: [0, 1] }, - }, + anchor: { offset: 0, path: [0, 1] }, + annotations: [annotation], + focus: { offset: 5, path: [0, 1] }, }, ]); }); @@ -207,18 +192,14 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 3, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, + anchor: { offset: 3, path: [0, 0] }, + annotations: [annotation], + focus: { offset: 5, path: [0, 0] }, }, { - annotation, - range: { - anchor: { offset: 0, path: [1, 0] }, - focus: { offset: 3, path: [1, 0] }, - }, + anchor: { offset: 0, path: [1, 0] }, + annotations: [annotation], + focus: { offset: 3, path: [1, 0] }, }, ]); }); @@ -251,188 +232,14 @@ describe('annotationToDecorations', () => { expect(decorations).toEqual([ { - annotation, - range: { - anchor: { offset: 3, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, - }, - { - annotation, - range: { - anchor: { offset: 0, path: [1, 0] }, - focus: { offset: 1, path: [1, 0] }, - }, - }, - ]); - }); -}); - -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: createRangeRef(editor, { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }), - text: 'hello', - }; - - const annotation2 = { - range: { - anchor: { offset: 6, path: [0, 0] }, - focus: { offset: 11, path: [0, 0] }, // "world" - }, - rangeRef: createRangeRef(editor, { - anchor: { offset: 6, path: [0, 0] }, - focus: { offset: 11, path: [0, 0] }, - }), - text: 'world', - }; - - const decorations = annotationsToDecorations(editor, { - annotations: [annotation1, annotation2], - }); - - expect(decorations).toEqual([ - { - annotation: annotation1, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, - }, - { - annotation: annotation2, - range: { - anchor: { offset: 6, path: [0, 0] }, - 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: createRangeRef(editor, { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 7, path: [0, 0] }, - }), - text: 'hello w', - }; - - const annotation2 = { - range: { - anchor: { offset: 6, path: [0, 0] }, - focus: { offset: 11, path: [0, 0] }, // "world" - }, - rangeRef: createRangeRef(editor, { - anchor: { offset: 6, path: [0, 0] }, - focus: { offset: 11, path: [0, 0] }, - }), - text: 'world', - }; - - const decorations = annotationsToDecorations(editor, { - annotations: [annotation1, annotation2], - }); - - // Should include both annotations for the overlapping region - expect(decorations).toEqual([ - { - annotation: annotation1, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 7, path: [0, 0] }, - }, - }, - { - annotation: annotation2, - range: { - anchor: { offset: 6, path: [0, 0] }, - 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: createRangeRef(editor, { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }), - text: 'hello', - }; - - const annotation2 = { - data: { type: 'grammar' }, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, - rangeRef: createRangeRef(editor, { - anchor: { offset: 0, path: [0, 0] }, + anchor: { offset: 3, path: [0, 0] }, + annotations: [annotation], focus: { offset: 5, path: [0, 0] }, - }), - text: 'hello', - }; - - const decorations = annotationsToDecorations(editor, { - annotations: [annotation1, annotation2], - }); - - // Should include both annotations for the same range - expect(decorations).toEqual([ - { - annotation: annotation1, - range: { - anchor: { offset: 0, path: [0, 0] }, - focus: { offset: 5, path: [0, 0] }, - }, }, { - annotation: annotation2, - range: { - anchor: { offset: 0, path: [0, 0] }, - 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 index 63739b4fe8..956e4a8d44 100644 --- a/packages/slate-utils/src/queries/annotationToDecorations.ts +++ b/packages/slate-utils/src/queries/annotationToDecorations.ts @@ -4,89 +4,62 @@ import { getNodeEntries, isText, } from '@udecode/slate'; -import { type Range, type RangeRef, Point } from 'slate'; +import { Point } from 'slate'; -export type Annotation = { - range: Range; - rangeRef: RangeRef; - text: string; - data?: Record; -}; - -export type DecorationWithAnnotation = Range & { - annotation: Annotation; -}; +import type { Annotation, DecorationWithAnnotations } from './annotation'; export const annotationToDecorations = ( editor: TEditor, options: { annotation: Annotation; + decorations?: Map; } -): DecorationWithAnnotation[] => { - const { annotation } = options; +): DecorationWithAnnotations[] => { + const { annotation, decorations = new Map() } = options; const { range } = annotation; - const decorations: DecorationWithAnnotation[] = []; - - // Get annotation boundaries - const annotationStart = range.anchor; - const annotationEnd = range.focus; + const results: DecorationWithAnnotations[] = []; const textEntries = getNodeEntries(editor, { at: range, match: (n) => isText(n), }); - // Create decorations for each text leaf that overlaps with the annotation for (const [node, path] of textEntries) { - const textStart = { offset: 0, path: path }; - const textEnd = { offset: node.text.length, path: path }; + const textStart = { offset: 0, path }; + const textEnd = { offset: node.text.length, path }; - // Skip if text is before annotation start - if (Point.isAfter(annotationStart, textEnd)) { + if ( + Point.isAfter(range.anchor, textEnd) || + Point.isBefore(range.focus, textStart) + ) { continue; } - // Break if text is after annotation end - if (Point.isBefore(textEnd, annotationStart)) { - break; - } - // Calculate overlap between annotation and text leaf - const overlapStart = Point.isAfter(annotationStart, textStart) - ? annotationStart + const overlapStart = Point.isAfter(range.anchor, textStart) + ? range.anchor : textStart; - const overlapEnd = Point.isBefore(annotationEnd, textEnd) - ? annotationEnd + const overlapEnd = Point.isBefore(range.focus, textEnd) + ? range.focus : textEnd; if (Point.isBefore(overlapStart, overlapEnd)) { - const decoration = { - anchor: { - offset: overlapStart.offset, - path: path, - }, - annotation, - focus: { - offset: overlapEnd.offset, - path: path, - }, - }; - decorations.push(decoration); - } - } + const key = `${path.join(',')}-${overlapStart.offset}-${overlapEnd.offset}`; - return decorations; -}; + let decoration = decorations.get(key); -export const annotationsToDecorations = ( - editor: TEditor, - options: { - annotations: Annotation[]; + if (!decoration) { + decoration = { + anchor: overlapStart, + annotations: [], + focus: overlapEnd, + }; + decorations.set(key, decoration); + results.push(decoration); + } + + decoration.annotations.push(annotation); + } } -): DecorationWithAnnotation[] => { - const { annotations } = options; - // Get all decorations for each annotation - return annotations.flatMap((annotation) => - annotationToDecorations(editor, { 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/index.ts b/packages/slate-utils/src/queries/index.ts index 059b982203..e2964e1502 100644 --- a/packages/slate-utils/src/queries/index.ts +++ b/packages/slate-utils/src/queries/index.ts @@ -2,7 +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'; diff --git a/packages/slate-utils/src/queries/parseNode.ts b/packages/slate-utils/src/queries/parseNode.ts index 9855ef2efb..6f5354c844 100644 --- a/packages/slate-utils/src/queries/parseNode.ts +++ b/packages/slate-utils/src/queries/parseNode.ts @@ -12,7 +12,7 @@ import { isEditor, } from '@udecode/slate'; -import type { Annotation } from './annotationToDecorations'; +import type { Annotation } from './annotation'; export type ParseNodeOptions = { /** Function to match annotations and return match result */