From 0ca7cb57ce978027bf668a87ee9fe454522aaf30 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Thu, 5 Dec 2024 20:06:08 +0100 Subject: [PATCH 1/7] feat --- apps/www/package.json | 1 + packages/floating/src/hooks/index.ts | 1 + .../floating/src/hooks/useVirtualRefState.ts | 22 ++ packages/lint/.npmignore | 3 + packages/lint/README.md | 11 + packages/lint/package.json | 71 ++++ packages/lint/src/react/decorateLint.spec.ts | 271 ++++++++++++++++ packages/lint/src/react/decorateLint.ts | 123 +++++++ packages/lint/src/react/index.ts | 9 + packages/lint/src/react/lint-plugin.spec.ts | 75 +++++ packages/lint/src/react/lint-plugin.tsx | 102 ++++++ packages/lint/src/react/plugins/index.ts | 5 + .../react/plugins/lint-plugin-case.spec.ts | 150 +++++++++ .../src/react/plugins/lint-plugin-case.ts | 160 +++++++++ .../react/plugins/lint-plugin-replace.spec.ts | 60 ++++ .../src/react/plugins/lint-plugin-replace.ts | 83 +++++ packages/lint/src/react/types.ts | 232 +++++++++++++ packages/lint/src/react/utils/index.ts | 6 + .../react/utils/resolveLintConfigs.spec.ts | 304 ++++++++++++++++++ .../src/react/utils/resolveLintConfigs.ts | 136 ++++++++ .../lint/src/react/utils/useTokenSelected.ts | 23 ++ packages/lint/tsconfig.build.json | 8 + packages/lint/tsconfig.json | 5 + .../slate-utils/src/queries/getNextRange.ts | 61 ++++ packages/slate-utils/src/queries/index.ts | 4 + .../src/queries/isSelectionInRange.ts | 35 ++ packages/slate-utils/src/queries/parseNode.ts | 188 +++++++++++ .../slate-utils/src/queries/replaceText.ts | 29 ++ yarn.lock | 193 ++++++----- 29 files changed, 2284 insertions(+), 87 deletions(-) create mode 100644 packages/floating/src/hooks/useVirtualRefState.ts create mode 100644 packages/lint/.npmignore create mode 100644 packages/lint/README.md create mode 100644 packages/lint/package.json create mode 100644 packages/lint/src/react/decorateLint.spec.ts create mode 100644 packages/lint/src/react/decorateLint.ts create mode 100644 packages/lint/src/react/index.ts create mode 100644 packages/lint/src/react/lint-plugin.spec.ts create mode 100644 packages/lint/src/react/lint-plugin.tsx create mode 100644 packages/lint/src/react/plugins/index.ts create mode 100644 packages/lint/src/react/plugins/lint-plugin-case.spec.ts create mode 100644 packages/lint/src/react/plugins/lint-plugin-case.ts create mode 100644 packages/lint/src/react/plugins/lint-plugin-replace.spec.ts create mode 100644 packages/lint/src/react/plugins/lint-plugin-replace.ts create mode 100644 packages/lint/src/react/types.ts create mode 100644 packages/lint/src/react/utils/index.ts create mode 100644 packages/lint/src/react/utils/resolveLintConfigs.spec.ts create mode 100644 packages/lint/src/react/utils/resolveLintConfigs.ts create mode 100644 packages/lint/src/react/utils/useTokenSelected.ts create mode 100644 packages/lint/tsconfig.build.json create mode 100644 packages/lint/tsconfig.json create mode 100644 packages/slate-utils/src/queries/getNextRange.ts create mode 100644 packages/slate-utils/src/queries/isSelectionInRange.ts create mode 100644 packages/slate-utils/src/queries/parseNode.ts create mode 100644 packages/slate-utils/src/queries/replaceText.ts 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/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..c29f5d08e6 --- /dev/null +++ b/packages/lint/package.json @@ -0,0 +1,71 @@ +{ + "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": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "module": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./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/react/decorateLint.spec.ts b/packages/lint/src/react/decorateLint.spec.ts new file mode 100644 index 0000000000..1643f60406 --- /dev/null +++ b/packages/lint/src/react/decorateLint.spec.ts @@ -0,0 +1,271 @@ +import type { TokenDecoration } from '@udecode/slate-utils'; + +import { getEditorPlugin } from '@udecode/plate-common/react'; +import { createPlateEditor } from '@udecode/plate-common/react'; + +import type { LintToken } from './types'; + +import { ExperimentalLintPlugin } from './lint-plugin'; +import { replaceLintPlugin } from './plugins/lint-plugin-replace'; + +describe('decorateLint', () => { + const replaceMap = new Map([ + ['hello', [{ text: '👋' }]], + ['world', [{ text: '🌍' }, { text: '🌎' }]], + ]); + + it('should decorate matching text', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + editor.children = [ + { + children: [ + { + text: 'hello world', + }, + ], + type: 'p', + }, + ]; + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + replaceLintPlugin.configs.all, + { + settings: { + replaceMap: replaceMap, + }, + }, + ]); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) + ).toEqual([ + { + anchor: { + offset: 0, + path: [0, 0], + }, + focus: { + offset: 5, + path: [0, 0], + }, + lint: true, + token: { + messageId: 'replaceWithText', + range: expect.any(Object), + rangeRef: expect.any(Object), + suggest: [ + { + data: { text: '👋' }, + fix: expect.any(Function), + }, + ], + text: 'hello', + }, + }, + { + anchor: { + offset: 6, + path: [0, 0], + }, + focus: { + offset: 11, + path: [0, 0], + }, + lint: true, + token: { + messageId: 'replaceWithText', + range: expect.any(Object), + rangeRef: expect.any(Object), + suggest: [ + { + data: { text: '🌍' }, + fix: expect.any(Function), + }, + { + data: { text: '🌎' }, + fix: expect.any(Function), + }, + ], + text: 'world', + }, + }, + ]); + }); + + 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); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + { + ...replaceLintPlugin.configs.all, + targets: [{ id: nodeId }], + }, + { + settings: { + replaceMap: replaceMap, + }, + }, + ]); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [{ id: nodeId, children: [{ text: 'hello' }], type: 'p' }, [0]], + }) + ).toEqual([ + { + anchor: { + offset: 0, + path: [0, 0], + }, + focus: { + offset: 5, + path: [0, 0], + }, + lint: true, + token: { + messageId: 'replaceWithText', + range: expect.any(Object), + rangeRef: expect.any(Object), + suggest: [ + { + data: { text: '👋' }, + fix: expect.any(Function), + }, + ], + text: 'hello', + }, + }, + ]); + }); + + it('should skip non-block nodes', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + replaceLintPlugin.configs.all, + { + settings: { + replaceMap: replaceMap, + }, + }, + ]); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [{ text: 'hello' }, [0]], // Non-block node + }) + ).toEqual([]); + }); + + it('should handle empty configs', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + + editor.setOption(ExperimentalLintPlugin, 'configs', []); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [{ children: [{ text: 'hello' }], type: 'p' }, [0]], + }) + ).toEqual([]); + }); + + it('should handle non-matching targets', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + { + ...replaceLintPlugin.configs.all, + targets: [{ id: 'non-existent' }], + }, + { + settings: { + replaceMap: replaceMap, + }, + }, + ]); + + expect( + plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [ + { id: 'different-id', children: [{ text: 'hello' }], type: 'p' }, + [0], + ], + }) + ).toEqual([]); + }); + + it('should handle fixer actions', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + editor.children = [ + { + children: [{ text: 'hello' }], + type: 'p', + }, + ]; + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + replaceLintPlugin.configs.all, + { + settings: { replaceMap: new Map([['hello', [{ text: '👋' }]]]) }, + }, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + // Test fixer actions + const token = decorations[0].token as LintToken; + expect(token?.suggest?.[0].fix).toBeDefined(); + + // Call fix function + token?.suggest?.[0].fix(); + expect(editor.children[0].children[0].text).toBe('👋'); + }); +}); diff --git a/packages/lint/src/react/decorateLint.ts b/packages/lint/src/react/decorateLint.ts new file mode 100644 index 0000000000..1534be2a7e --- /dev/null +++ b/packages/lint/src/react/decorateLint.ts @@ -0,0 +1,123 @@ +import type { Decorate, PlatePluginContext } from '@udecode/plate-common/react'; + +import { + deleteText, + experimental_parseNode, + isBlock, + replaceText, +} from '@udecode/plate-common'; +import { Range } from 'slate'; + +import type { LintConfig } from './lint-plugin'; +import type { LintConfigPluginRuleContext, LintFixer } from './types'; + +import { resolveLintConfigs } from './utils/resolveLintConfigs'; + +export const decorateLint: Decorate = (ctx) => { + const { + editor, + entry: [node, path], + getOptions, + setOption, + tf, + } = ctx; + const { configs } = getOptions(); + + // Support only blocks for now + if (!isBlock(editor, node)) { + return []; + } + + // First, filter configs that apply to this node + const applicableConfigs = configs.filter( + (config) => + !config.targets || // applies to all + config.targets.some((target) => target.id === node.id) // specifically targets this node + ); + + if (applicableConfigs.length === 0) return []; + + // Resolve rules for this node's configs only + const resolvedRules = resolveLintConfigs(applicableConfigs); + + const decorations = Object.entries(resolvedRules).flatMap( + ([ruleId, rule]) => { + 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: text, + }); + }, + }; + + const fixer = Object.fromEntries( + Object.entries(fixerActions).map(([key, fn]) => [ + key, + (options: any) => { + fn(options); + + if (options.goNext && tokens.length > 2) { + setTimeout(() => { + tf.lint.focusNextMatch(); + }, 0); + } else { + setTimeout(() => { + setOption('activeToken', null); + }, 0); + } + }, + ]) + ) as LintFixer; + + const context: LintConfigPluginRuleContext = { + ...(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); + } + + const { Token } = rule.create(context); + + const { decorations, tokens } = experimental_parseNode(editor, { + at: path, + match: parserOptions?.match ?? (() => false), + maxLength: parserOptions?.maxLength, + minLength: parserOptions?.minLength, + splitPattern: parserOptions?.splitPattern, + transform: Token, + }); + + setTimeout(() => { + setOption('tokens', tokens); + }, 0); + + return decorations.map(({ range, token }) => ({ + ...range, + lint: true, + token, + })); + } + ); + + return decorations; +}; 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..4a37b0a9a1 --- /dev/null +++ b/packages/lint/src/react/lint-plugin.spec.ts @@ -0,0 +1,75 @@ +import { createPlateEditor } from '@udecode/plate-common/react'; + +import { ExperimentalLintPlugin } from './lint-plugin'; + +describe('LintPlugin', () => { + it('should set selected active token', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + editor.children = [ + { + children: [{ text: 'hello' }], + type: 'p', + }, + ]; + + const activeToken: any = { + rangeRef: { + current: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 5, path: [0, 0] }, + }, + }, + text: 'hello', + }; + editor.setOption(ExperimentalLintPlugin, 'tokens', [activeToken]); + editor.setOption(ExperimentalLintPlugin, 'activeToken', activeToken); + + editor.selection = { + anchor: { offset: 2, path: [0, 0] }, + focus: { offset: 2, path: [0, 0] }, + }; + const result = editor.api.lint.setSelectedActiveToken(); + expect(result).toBe(true); + expect(editor.getOption(ExperimentalLintPlugin, 'activeToken')?.text).toBe( + 'hello' + ); + }); + + it('should focus next match', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + const activeToken = { + rangeRef: { + current: { + anchor: { offset: 0, path: [0, 0] }, + focus: { offset: 5, path: [0, 0] }, + }, + }, + text: 'hello', + } as any; + editor.setOption(ExperimentalLintPlugin, 'tokens', [ + activeToken, + { + rangeRef: { + current: { + anchor: { offset: 6, path: [0, 0] }, + focus: { offset: 11, path: [0, 0] }, + }, + }, + text: 'world', + } as any, + ]); + editor.setOption(ExperimentalLintPlugin, 'activeToken', activeToken); + + const match = editor.tf.lint.focusNextMatch(); + expect(match?.text).toBe('world'); + expect(editor.getOption(ExperimentalLintPlugin, 'activeToken')?.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..576b8df88a --- /dev/null +++ b/packages/lint/src/react/lint-plugin.tsx @@ -0,0 +1,102 @@ +import { + type PluginConfig, + getNextRange, + isSelectionInRange, +} from '@udecode/plate-common'; +import { createTPlatePlugin, focusEditor } from '@udecode/plate-common/react'; + +import type { LintConfigArray, LintToken } from './types'; + +import { decorateLint } from './decorateLint'; + +export type LintConfig = PluginConfig< + 'lint', + { + activeToken: LintToken | null; + configs: LintConfigArray; + tokens: LintToken[]; + }, + { + lint: { + getNextMatch: (options?: { reverse: boolean }) => LintToken | undefined; + reset: () => void; + run: (configs: LintConfigArray) => void; + setSelectedActiveToken: () => boolean | undefined; + }; + }, + { + lint: { + focusNextMatch: (options?: { reverse: boolean }) => LintToken | undefined; + }; + } +>; + +export const ExperimentalLintPlugin = createTPlatePlugin({ + key: 'lint', + decorate: decorateLint, + node: { + isLeaf: true, + }, + options: { + activeToken: null, + configs: [], + tokens: [], + }, +}) + .extendApi((ctx) => { + const { editor, getOptions, setOption } = ctx; + + return { + getNextMatch: (options) => { + const { activeToken, tokens } = getOptions(); + + const ranges = tokens.map((token) => token.rangeRef.current!); + const nextRange = getNextRange(editor, { + from: activeToken?.rangeRef.current, + ranges, + reverse: options?.reverse, + }); + + if (!nextRange) return; + + return tokens[ranges.indexOf(nextRange)]; + }, + reset: () => { + setOption('configs', []); + editor.api.redecorate(); + }, + run: (configs) => { + setOption('configs', configs); + editor.api.redecorate(); + }, + setSelectedActiveToken: () => { + if (!editor.selection) return false; + + const activeToken = getOptions().tokens.find((match) => + isSelectionInRange(editor, { at: match.rangeRef.current! }) + ); + + if (activeToken) { + setOption('activeToken', activeToken); + + return true; + } + + return false; + }, + }; + }) + .extendTransforms( + ({ api, editor, setOption }) => ({ + focusNextMatch: (options) => { + const match = api.lint.getNextMatch(options); + setOption('activeToken', match ?? null); + + if (match) { + focusEditor(editor, match!.rangeRef.current!); + } + + 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..a0e930729a --- /dev/null +++ b/packages/lint/src/react/plugins/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './lint-plugin-emoji'; 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..ce6a11e042 --- /dev/null +++ b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts @@ -0,0 +1,150 @@ +import type { TokenDecoration } from '@udecode/slate-utils'; + +import { getEditorPlugin } from '@udecode/plate-common/react'; +import { createPlateEditor } from '@udecode/plate-common/react'; + +import type { LintToken } from '../types'; + +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); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(3); + expect((decorations[0].token as LintToken).suggest?.[0].data?.text).toBe( + 'Hello' + ); + expect((decorations[1].token as LintToken).suggest?.[0].data?.text).toBe( + 'This' + ); + expect((decorations[2].token as LintToken).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); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + { + settings: { + ignoredWords: ['iPhone', 'ios'], + }, + }, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(1); // Only "app" should be flagged + expect((decorations[0].token as LintToken).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); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + const token = decorations[0].token as LintToken; + token.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); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + // Only the second "cat" (after period) should be flagged + expect(decorations).toHaveLength(1); + expect((decorations[0].token as LintToken).text).toBe('cat'); + expect((decorations[0].token as LintToken).suggest?.[0].data?.text).toBe( + 'Cat' + ); + }); +}); 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..10a98afb87 --- /dev/null +++ b/packages/lint/src/react/plugins/lint-plugin-case.ts @@ -0,0 +1,160 @@ +import { Node } from 'slate'; + +import type { LintConfigPlugin, LintConfigPluginRule } from '../types'; + +export type CaseLintPluginOptions = { + ignoredWords?: string[]; + maxSuggestions?: number; +}; + +const caseMatchRule: LintConfigPluginRule = { + create: ({ fixer, options }) => { + const ignoredWords = options[0].ignoredWords ?? []; + + return { + Token: (token) => { + const text = token.text; + + // Skip if word is in ignored list + if (ignoredWords.includes(text)) return token; + // Check if first letter is lowercase using startsWith + if (text && /^[a-z]/.test(text)) { + const suggestion = text.charAt(0).toUpperCase() + text.slice(1); + + return { + ...token, + messageId: 'capitalizeFirstLetter', + suggest: [ + { + data: { text: suggestion }, + fix: (options) => { + fixer.replaceText({ + range: token.rangeRef.current!, + text: suggestion, + ...options, + }); + }, + }, + ], + }; + } + + return token; + }, + }; + }, + meta: { + defaultOptions: [ + { + ignoredWords: [], + }, + ], + hasSuggestions: true, + type: 'suggestion', + }, +}; + +const plugin = { + meta: { + name: 'case', + }, + rules: { + sentence: caseMatchRule, + }, +} satisfies LintConfigPlugin; + +export const caseLintPlugin = { + ...plugin, + configs: { + all: { + languageOptions: { + parserOptions: (context) => { + const { editor, options: contextOptions } = context; + const text = Node.string(editor.children[0]); + const ignoredWords = contextOptions[0]?.ignoredWords ?? []; + + console.log('Parsing text:', text); + console.log('Ignored words:', ignoredWords); + + return { + context: { + getTokenPosition: (token: string, text: string) => { + const match = new RegExp(`\\b${token}\\b`).exec(text); + const position = match?.index ?? 0; + console.log( + 'Getting position for token:', + token, + 'position:', + position + ); + + return position; + }, + isValidTokenContext: (position: number, text: string) => { + if (position === 0) { + console.log('Position 0, valid context'); + + return true; + } + + const prevChar = text[position - 2]; + const isValid = + prevChar === '.' || prevChar === '!' || prevChar === '?'; + console.log( + 'Checking context at position:', + position, + 'prevChar:', + prevChar, + 'isValid:', + isValid + ); + + return isValid; + }, + text, + }, + match: (token: string) => { + console.log('\nMatching token:', token); + + if (ignoredWords.includes(token)) { + console.log('Token is ignored'); + + return false; + } + if (!/^[a-z]/.test(token)) { + console.log('Token starts with uppercase'); + + return false; + } + + // Get position + const match = new RegExp(`\\b${token}\\b`).exec(text); + const position = match?.index ?? 0; + console.log('Token position:', position); + + // Check position + if (position === 0) { + console.log('Token at start of text'); + + return true; + } + + const prevChar = text[position - 2]; + const isValid = + prevChar === '.' || prevChar === '!' || prevChar === '?'; + console.log('Previous char:', prevChar, 'isValid:', isValid); + + return isValid; + }, + splitPattern: /\b[\dA-Za-z]+\b/g, + }; + }, + }, + name: 'case/all', + plugins: { case: plugin }, + rules: { + 'case/sentence': ['error'], + }, + }, + }, +} 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..d5089c3c53 --- /dev/null +++ b/packages/lint/src/react/plugins/lint-plugin-replace.spec.ts @@ -0,0 +1,60 @@ +import type { TokenDecoration } from '@udecode/slate-utils'; + +import { getEditorPlugin } from '@udecode/plate-common/react'; +import { createPlateEditor } from '@udecode/plate-common/react'; + +import type { LintToken } from '../types'; + +import { ExperimentalLintPlugin } from '../lint-plugin'; +import { replaceLintPlugin } from './lint-plugin-replace'; + +describe('replaceLintPlugin', () => { + const replaceMap = new Map([ + ['hello', [{ text: '👋' }]], + ['world', [{ text: '🌍' }, { text: '🌎' }]], + ]); + + it('should suggest replacements', () => { + const editor = createPlateEditor({ + plugins: [ExperimentalLintPlugin], + }); + + editor.children = [ + { + children: [ + { + text: 'hello world', + }, + ], + type: 'p', + }, + ]; + + const plugin = editor.getPlugin(ExperimentalLintPlugin); + + editor.setOption(ExperimentalLintPlugin, 'configs', [ + replaceLintPlugin.configs.all, + { + settings: { + replaceMap, + }, + }, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(2); + expect((decorations[0].token as LintToken).suggest?.[0].data?.text).toBe( + '👋' + ); + expect((decorations[1].token as LintToken).suggest?.[0].data?.text).toBe( + '🌍' + ); + expect((decorations[1].token as LintToken).suggest?.[1].data?.text).toBe( + '🌎' + ); + }); +}); 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..9b823682cb --- /dev/null +++ b/packages/lint/src/react/plugins/lint-plugin-replace.ts @@ -0,0 +1,83 @@ +import type { + LintConfigPlugin, + LintConfigPluginRule, + LintTokenSuggestion, +} from '../types'; + +export type ReplaceLintPluginOptions = { + maxSuggestions?: number; + replaceMap?: Map; +}; + +const replaceMatchRule: LintConfigPluginRule = { + create: ({ fixer, options }) => { + const replaceMap = options[0].replaceMap; + const maxSuggestions = options[0].maxSuggestions; + + return { + Token: (token) => { + const replacements = replaceMap?.get(token.text.toLowerCase()); + + return { + ...token, + messageId: 'replaceWithText', + suggest: replacements?.slice(0, maxSuggestions).map( + (replacement): LintTokenSuggestion => ({ + data: { text: replacement.text }, + fix: (options) => { + fixer.replaceText({ + range: token.rangeRef.current!, + text: replacement.text, + ...options, + }); + }, + }) + ), + }; + }, + }; + }, + meta: { + defaultOptions: [ + { + maxSuggestions: 8, + }, + ], + hasSuggestions: true, + type: 'suggestion', + }, +}; + +const plugin = { + meta: { + name: 'replace', + }, + rules: { + text: replaceMatchRule, + }, +} satisfies LintConfigPlugin; + +export const replaceLintPlugin = { + ...plugin, + configs: { + all: { + languageOptions: { + parserOptions: ({ options }) => { + const replaceMap = options[0].replaceMap; + + return { + match: (token: string) => { + return !!replaceMap?.has(token.toLowerCase()); + }, + splitPattern: /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, + }; + }, + }, + name: 'replace/all', + plugins: { replace: plugin }, + rules: { + 'replace/text': ['error'], + }, + }, + }, +} satisfies LintConfigPlugin; diff --git a/packages/lint/src/react/types.ts b/packages/lint/src/react/types.ts new file mode 100644 index 0000000000..f55fc4ce10 --- /dev/null +++ b/packages/lint/src/react/types.ts @@ -0,0 +1,232 @@ +import type { AnyObject, TText, UnknownObject } from '@udecode/plate-common'; +import type { PlatePluginContext } from '@udecode/plate-common/react'; +import type { Range, RangeRef } from 'slate'; + +// ─── Plate ────────────────────────────────────────────────────────────────── + +export type LintDecoration = TText & { + lint: boolean; + token: LintToken; +}; + +export type LintToken = { + range: Range; + rangeRef: RangeRef; + text: string; + data?: UnknownObject; + messageId?: string; + suggest?: LintTokenSuggestion[]; +}; + +export type LintTokenSuggestion = { + fix: (options?: { goNext?: boolean }) => void; + data?: Record; + messageId?: string; +}; + +// ─── Config ────────────────────────────────────────────────────────────────── +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; + /** + * 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?: LintConfigRuleOptions>; + /** The targets to match. */ + targets?: { id?: string }[]; +}; + +export type LintConfigRuleOptions = T & UnknownObject; + +export type LintConfigRuleOptionsArray = [ + LintConfigRuleOptions, + ...LintConfigRuleOptions[], +]; + +export type LintTokenOptions = { + tokens?: { + match?: (token: string) => boolean; + splitPattern?: RegExp; + }; +}; + +// ─── Config Rules ───────────────────────────────────────────────────────────── + +export type LintConfigRule = + | LintConfigRuleLevel + | LintConfigRuleLevelAndOptions; + +export type LintConfigRuleLevel = + | LintConfigRuleSeverity + | LintConfigRuleSeverityString; + +export type LintConfigRuleLevelAndOptions = [LintConfigRuleLevel, ...unknown[]]; + +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 tokens + * while traversing the document during decoration. + */ + create: (context: LintConfigPluginRuleContext) => { + /** A function that transforms a token. */ + Token: (token: LintToken) => LintToken; + }; + 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?: LintConfigRuleOptionsArray>; + hasSuggestions?: boolean; + /** Overrides the language options for the rule. */ + languageOptions?: LintLanguageOptions; + messages?: Record; + type?: 'problem' | 'suggestion'; + }; +}; + +export type LintConfigPluginRuleContext = Pick< + ResolvedLintRule, + 'languageOptions' | 'options' | 'settings' +> & { + /** 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 LintParserContext = { + /** Custom token position calculator */ + getTokenPosition: (token: string, text: string) => number; + /** Custom token context checker */ + isValidTokenContext: (position: number, text: string) => boolean; + /** Full text content for context-aware matching */ + text: string; +}; + +export type LintParserOptions = { + context?: LintParserContext; + /** Function to determine if a token should be processed */ + match?: (token: string) => boolean; + /** Maximum length of tokens to process */ + maxLength?: number; + /** Minimum length of tokens to process */ + minLength?: number; + /** Pattern for splitting text into tokens */ + 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; + }; + 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: LintConfigRuleOptionsArray; + }; + +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 token extraction */ +// pattern?: RegExp; +// /** Additional conditions for matching */ +// match?: (token: string) => boolean; +// /** Minimum token length */ +// minLength?: number; +// /** Maximum token 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..5e059fb2d7 --- /dev/null +++ b/packages/lint/src/react/utils/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './resolveLintConfigs'; +export * from './useTokenSelected'; 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..9a422b6f7a --- /dev/null +++ b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts @@ -0,0 +1,304 @@ +/* eslint-disable jest/no-conditional-expect */ +import type { LintConfigArray } from '../types'; + +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: { + 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[0].replaceMap).toBe(replaceMap); + expect(result[ruleKey].options[0].maxSuggestions).toBe(8); + }); + + it('should handle language options merging', () => { + const configs: LintConfigArray = [ + replaceLintPlugin.configs.all, + { + languageOptions: { + parserOptions: { + minLength: 4, + }, + }, + settings: { + replaceMap: replaceMap, + }, + }, + ]; + + 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'); + } + + const parserOptions = parserOptionsFn({ + id: 'test', + fixer: {}, + languageOptions: {}, + options: [{ replaceMap: replaceMap }], + settings: {}, + } as any); + + expect(parserOptions.minLength).toBe(4); + expect(parserOptions.splitPattern).toBeDefined(); + expect(typeof parserOptions.match).toBe('function'); + }); + + it('should handle rule severity levels', () => { + const configs: LintConfigArray = [ + { + ...replaceLintPlugin.configs.all, + rules: { + 'replace/text': ['warn'], + }, + }, + { + settings: { + 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: { + 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: { + 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, + { + languageOptions: { + parserOptions: { + maxLength: 4, + }, + }, + settings: { + maxSuggestions: 5, + replaceMap: replaceMap, + }, + }, + ]; + + 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(); + }); + + it('should merge settings from multiple configs', () => { + const configs: LintConfigArray = [ + { + plugins: { replace: replaceLintPlugin }, + rules: { + 'replace/text': ['error'], + }, + settings: { + setting1: 'value1', + }, + }, + { + settings: { + setting2: 'value2', + }, + }, + ]; + + const result = resolveLintConfigs(configs); + const ruleKey = 'replace/text'; + + expect(result[ruleKey]).toBeDefined(); + expect(result[ruleKey].settings).toEqual({ + setting1: 'value1', + setting2: 'value2', + }); + }); + + it('should handle targets in config objects', () => { + const configs: LintConfigArray = [ + { + ...replaceLintPlugin.configs.all, + targets: [{ id: 'target1' }, { id: 'target2' }], + }, + { + settings: { + replaceMap: replaceMap, + }, + }, + ]; + + const result = resolveLintConfigs(configs); + const ruleKey = 'replace/text'; + + expect(result[ruleKey]).toBeDefined(); + expect(result[ruleKey].options[0].replaceMap).toBe(replaceMap); + }); + + it('should merge parser options correctly when both are objects', () => { + const configs: LintConfigArray = [ + { + languageOptions: { + parserOptions: { + match: (token: string) => token.length > 2, + splitPattern: /\w+/g, + }, + }, + plugins: { replace: replaceLintPlugin }, + rules: { + 'replace/text': ['error'], + }, + }, + { + languageOptions: { + parserOptions: { + match: (token: string) => token.length > 4, + minLength: 3, + }, + }, + }, + ]; + + const result = resolveLintConfigs(configs); + const ruleKey = 'replace/text'; + + expect(result[ruleKey]).toBeDefined(); + const parserOptions = result[ruleKey].languageOptions.parserOptions; + + 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 = [ + undefined as any, + null as any, + {}, + 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 = [ + { + languageOptions: { + parserOptions: (context) => ({ + match: () => true, + }), + }, + plugins: { replace: replaceLintPlugin }, + rules: { + 'replace/text': ['error'], + }, + }, + { + languageOptions: { + parserOptions: (context) => ({ + splitPattern: /\w+/g, + }), + }, + }, + ]; + + const result = resolveLintConfigs(configs); + const ruleKey = 'replace/text'; + + expect(result[ruleKey]).toBeDefined(); + const parserOptions = result[ruleKey].languageOptions.parserOptions; + expect(typeof parserOptions).toBe('function'); + }); +}); diff --git a/packages/lint/src/react/utils/resolveLintConfigs.ts b/packages/lint/src/react/utils/resolveLintConfigs.ts new file mode 100644 index 0000000000..634c813a08 --- /dev/null +++ b/packages/lint/src/react/utils/resolveLintConfigs.ts @@ -0,0 +1,136 @@ +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; + + 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 { + ...rulesAcc, + [ruleId]: { + create: rule.create, + languageOptions, + linterOptions: { severity }, + meta: rule.meta, + name: ruleId, + options, + settings: mergedConfig.settings ?? {}, + }, + }; + }, + {} as any + ); +} + +function normalizeSeverity( + level: LintConfigRuleLevel +): LintConfigRuleSeverityString { + if (typeof level === 'number') { + const numericLevel = level as LintConfigRuleSeverity; + + return numericLevel === 0 ? 'off' : numericLevel === 1 ? 'warn' : 'error'; + } + + return level as LintConfigRuleSeverityString; +} diff --git a/packages/lint/src/react/utils/useTokenSelected.ts b/packages/lint/src/react/utils/useTokenSelected.ts new file mode 100644 index 0000000000..8ffd48cb6e --- /dev/null +++ b/packages/lint/src/react/utils/useTokenSelected.ts @@ -0,0 +1,23 @@ +import { isSelectionInRange } from '@udecode/plate-common'; +import { + useEditorPlugin, + useEditorSelector, +} from '@udecode/plate-common/react'; + +import { ExperimentalLintPlugin } from '../lint-plugin'; + +export const useTokenSelected = () => { + const { useOption } = useEditorPlugin(ExperimentalLintPlugin); + const activeToken = useOption('activeToken'); + + return useEditorSelector( + (editor) => { + if (!editor.selection || !activeToken) return false; + if (isSelectionInRange(editor, { at: activeToken.rangeRef.current! })) + return true; + + return false; + }, + [activeToken] + ); +}; 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/getNextRange.ts b/packages/slate-utils/src/queries/getNextRange.ts new file mode 100644 index 0000000000..c9151142fe --- /dev/null +++ b/packages/slate-utils/src/queries/getNextRange.ts @@ -0,0 +1,61 @@ +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; + if (!from) { + if (!editor.selection) { + // If no selection and no range, select first/last depending on direction + return reverse ? ranges.at(-1) : ranges[0]; + } + + // Find the first range after the current selection + const selectionEnd = Range.end(editor.selection); + const nextRange = ranges.find((range) => { + const rangeStart = Range.start(range); + + return reverse + ? Point.isBefore(rangeStart, selectionEnd) + : Point.isAfter(rangeStart, selectionEnd); + }); + + // If no range found after selection, wrap around to first/last + return nextRange ?? (reverse ? ranges.at(-1) : ranges[0]); + } + + // Find current index + const currentIndex = ranges.findIndex((range) => Range.equals(range, from)); + + if (currentIndex === -1) return; + + // 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..194a288532 100644 --- a/packages/slate-utils/src/queries/index.ts +++ b/packages/slate-utils/src/queries/index.ts @@ -13,6 +13,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 +43,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.ts b/packages/slate-utils/src/queries/parseNode.ts new file mode 100644 index 0000000000..5ed7a3d9c2 --- /dev/null +++ b/packages/slate-utils/src/queries/parseNode.ts @@ -0,0 +1,188 @@ +import type { AnyObject } from '@udecode/utils'; +import type { Path, Range, RangeRef } from 'slate'; + +import { + type TEditor, + createRangeRef, + getNode, + getNodeTexts, +} from '@udecode/slate'; + +export type ParseNodeOptions = { + /** Base path to the current node */ + at: Path; + /** Function to match tokens and return match result */ + match: (token: string) => AnyObject | boolean; + /** Maximum length of tokens to process */ + maxLength?: number; + /** Minimum length of tokens to process */ + minLength?: number; + /** Pattern for matching tokens in text */ + splitPattern?: RegExp; + /** Function to transform matched tokens */ + transform?: (token: TokenMatch) => TokenMatch; +}; + +export type ParseNodeResult = { + // All decorations for rendering + decorations: TokenDecoration[]; + tokens: TokenMatch[]; +}; + +export type TokenDecoration = { + // The token that was matched + token: { + // The full range of the token + range: Range; + // The full range reference of the token + rangeRef: RangeRef; + // The text of the token + text: string; + }; + // The range of the token that was matched. There can be multiple leaves that make up the token. + range: Range; +}; + +export type TokenMatch = { + range: Range; + rangeRef: RangeRef; + text: string; +}; + +export const experimental_parseNode = ( + editor: TEditor, + { + at, + match: matchToken, + maxLength = Infinity, + minLength = 0, + splitPattern = /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, + transform, + }: ParseNodeOptions +): ParseNodeResult => { + const node = getNode(editor, at); + + if (!node) return { decorations: [], tokens: [] }; + + const texts = [...getNodeTexts(node)]; + const str = texts.map((text) => text[0].text).join(''); + const tokenDecorations: TokenDecoration[] = []; + const uniqueTokens = new Map(); + + let matchResult: RegExpExecArray | null = null; + + while ((matchResult = splitPattern.exec(str)) !== null) { + const tokenText = matchResult[0]; + + // Skip tokens that don't meet length requirements + if (tokenText.length < minLength || tokenText.length > maxLength) { + continue; + } + + const tokenStart = matchResult.index; + const tokenEnd = tokenStart + tokenText.length; + const tokenData = matchToken(tokenText); + + if (tokenData) { + 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 && tokenStart < textEnd) { + startPath = [...at, ...path]; + startOffset = tokenStart - cumulativeLength; + } + // Find end position + if (endPath === null && tokenEnd <= textEnd) { + endPath = [...at, ...path]; + endOffset = tokenEnd - cumulativeLength; + } + if (startPath !== null && endPath !== null) break; + + cumulativeLength = textEnd; + } + + if (startPath && endPath) { + const tokenRange = { + anchor: { offset: startOffset, path: startPath }, + focus: { offset: endOffset, path: endPath }, + }; + const tokenRangeRef = createRangeRef(editor, tokenRange); + + let token = { + range: tokenRange, + rangeRef: tokenRangeRef, + text: tokenText, + }; + + if (transform) { + token = transform(token); + } + + // Store unique token + const tokenKey = `${tokenRange.anchor.path.join('-')}-${tokenRange.anchor.offset}-${tokenRange.focus.offset}`; + + if (!uniqueTokens.has(tokenKey)) { + uniqueTokens.set(tokenKey, token); + } + + // Create decorations + cumulativeLength = 0; + + for (const [text, path] of texts) { + const textPath = [...at, ...path]; + const textStart = cumulativeLength; + const textEnd = textStart + text.text.length; + + if (tokenStart >= textEnd) { + cumulativeLength = textEnd; + + continue; + } + if (tokenEnd <= textStart) break; + + const overlapStart = Math.max(tokenStart, textStart); + const overlapEnd = Math.min(tokenEnd, textEnd); + + if (overlapStart < overlapEnd) { + tokenDecorations.push({ + range: { + anchor: { + offset: overlapStart - textStart, + path: textPath, + }, + focus: { + offset: overlapEnd - textStart, + path: textPath, + }, + }, + token, + }); + } + + cumulativeLength = textEnd; + } + } + } + } + + return { + decorations: tokenDecorations, + tokens: Array.from(uniqueTokens.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..8c16f48ee8 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: @@ -22514,6 +22532,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:^" From 0be8fecbec35c30c5b9592694d30f21575872e16 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Thu, 5 Dec 2024 20:49:52 +0100 Subject: [PATCH 2/7] feat --- .../react/plugins/lint-plugin-case.spec.ts | 123 +++++++++++++++++ .../src/react/plugins/lint-plugin-case.ts | 129 ++++++++---------- .../src/react/plugins/lint-plugin-replace.ts | 4 +- packages/lint/src/react/types.ts | 22 ++- .../react/utils/resolveLintConfigs.spec.ts | 10 +- packages/slate-utils/src/queries/parseNode.ts | 95 ++++++++----- 6 files changed, 258 insertions(+), 125 deletions(-) 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 ce6a11e042..bcf29b79a8 100644 --- a/packages/lint/src/react/plugins/lint-plugin-case.spec.ts +++ b/packages/lint/src/react/plugins/lint-plugin-case.spec.ts @@ -147,4 +147,127 @@ describe('caseLintPlugin', () => { '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); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(3); // second, third, fourth should be flagged + expect(decorations[0].token.text).toBe('second'); + expect(decorations[1].token.text).toBe('third'); + expect(decorations[2].token.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); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(2); + expect(decorations[0].token.text).toBe('two'); + expect(decorations[1].token.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); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(2); + expect(decorations[0].token.text).toBe('this'); + expect(decorations[1].token.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); + editor.setOption(ExperimentalLintPlugin, 'configs', [ + caseLintPlugin.configs.all, + ]); + + const decorations = plugin.decorate?.({ + ...getEditorPlugin(editor, plugin), + entry: [editor.children[0], [0]], + }) as unknown as TokenDecoration[]; + + expect(decorations).toHaveLength(0); + }); }); diff --git a/packages/lint/src/react/plugins/lint-plugin-case.ts b/packages/lint/src/react/plugins/lint-plugin-case.ts index 10a98afb87..8838882b74 100644 --- a/packages/lint/src/react/plugins/lint-plugin-case.ts +++ b/packages/lint/src/react/plugins/lint-plugin-case.ts @@ -1,5 +1,3 @@ -import { Node } from 'slate'; - import type { LintConfigPlugin, LintConfigPluginRule } from '../types'; export type CaseLintPluginOptions = { @@ -15,9 +13,15 @@ const caseMatchRule: LintConfigPluginRule = { Token: (token) => { const text = token.text; - // Skip if word is in ignored list - if (ignoredWords.includes(text)) return token; - // Check if first letter is lowercase using startsWith + // Skip if word is in ignored list or is part of URL/email + if (ignoredWords.includes(text) || /\.|@/.test(text)) { + return token; + } + // Skip if not a regular word or already capitalized + if (!/^[a-z][\da-z]*$/i.test(text) || /^[A-Z]/.test(text)) { + return token; + } + // Check if first letter is lowercase if (text && /^[a-z]/.test(text)) { const suggestion = text.charAt(0).toUpperCase() + text.slice(1); @@ -69,84 +73,67 @@ export const caseLintPlugin = { all: { languageOptions: { parserOptions: (context) => { - const { editor, options: contextOptions } = context; - const text = Node.string(editor.children[0]); + const { options: contextOptions } = context; const ignoredWords = contextOptions[0]?.ignoredWords ?? []; - console.log('Parsing text:', text); - console.log('Ignored words:', 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 { - context: { - getTokenPosition: (token: string, text: string) => { - const match = new RegExp(`\\b${token}\\b`).exec(text); - const position = match?.index ?? 0; - console.log( - 'Getting position for token:', - token, - 'position:', - position - ); - - return position; - }, - isValidTokenContext: (position: number, text: string) => { - if (position === 0) { - console.log('Position 0, valid context'); - - return true; - } - - const prevChar = text[position - 2]; - const isValid = - prevChar === '.' || prevChar === '!' || prevChar === '?'; - console.log( - 'Checking context at position:', - position, - 'prevChar:', - prevChar, - 'isValid:', - isValid - ); - - return isValid; - }, - text, - }, - match: (token: string) => { - console.log('\nMatching token:', token); - - if (ignoredWords.includes(token)) { - console.log('Token is ignored'); - + match: (params) => { + const { fullText, getContext, start, text: token } = params; + + // Skip ignored words and parts of URLs/emails + if ( + ignoredWords.includes(token) || + isUrlOrEmail(token, fullText, start) + ) { return false; } - if (!/^[a-z]/.test(token)) { - console.log('Token starts with uppercase'); - + // Skip if already capitalized + if (/^[A-Z]/.test(token)) { return false; } - - // Get position - const match = new RegExp(`\\b${token}\\b`).exec(text); - const position = match?.index ?? 0; - console.log('Token position:', position); - - // Check position - if (position === 0) { - console.log('Token at start of text'); - - return true; + // Skip if not a regular word (contains special characters or mixed case) + if ( + !/^[a-z][\da-z]*$/i.test(token) || + /[A-Z]/.test(token.slice(1)) + ) { + return false; } - const prevChar = text[position - 2]; - const isValid = - prevChar === '.' || prevChar === '!' || prevChar === '?'; - console.log('Previous char:', prevChar, 'isValid:', isValid); + // 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 isValid; + return isStartOfSentence; }, - splitPattern: /\b[\dA-Za-z]+\b/g, + // 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.ts b/packages/lint/src/react/plugins/lint-plugin-replace.ts index 9b823682cb..c0e25a430a 100644 --- a/packages/lint/src/react/plugins/lint-plugin-replace.ts +++ b/packages/lint/src/react/plugins/lint-plugin-replace.ts @@ -66,8 +66,8 @@ export const replaceLintPlugin = { const replaceMap = options[0].replaceMap; return { - match: (token: string) => { - return !!replaceMap?.has(token.toLowerCase()); + match: ({ text }) => { + return !!replaceMap?.has(text.toLowerCase()); }, splitPattern: /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, }; diff --git a/packages/lint/src/react/types.ts b/packages/lint/src/react/types.ts index f55fc4ce10..99372652ef 100644 --- a/packages/lint/src/react/types.ts +++ b/packages/lint/src/react/types.ts @@ -156,24 +156,20 @@ export type LintFixer = { // ─── Parser ────────────────────────────────────────────────────────────────── -export type LintParserContext = { - /** Custom token position calculator */ - getTokenPosition: (token: string, text: string) => number; - /** Custom token context checker */ - isValidTokenContext: (position: number, text: string) => boolean; - /** Full text content for context-aware matching */ - text: string; -}; - export type LintParserOptions = { - context?: LintParserContext; - /** Function to determine if a token should be processed */ - match?: (token: string) => boolean; + /** Function to match tokens 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 tokens to process */ maxLength?: number; /** Minimum length of tokens to process */ minLength?: number; - /** Pattern for splitting text into tokens */ + /** Pattern for matching tokens in text */ splitPattern?: RegExp; }; diff --git a/packages/lint/src/react/utils/resolveLintConfigs.spec.ts b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts index 9a422b6f7a..976e49261b 100644 --- a/packages/lint/src/react/utils/resolveLintConfigs.spec.ts +++ b/packages/lint/src/react/utils/resolveLintConfigs.spec.ts @@ -48,6 +48,7 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: { + match: (params) => true, minLength: 4, }, }, @@ -157,6 +158,7 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: { + match: (params) => true, maxLength: 4, }, }, @@ -228,7 +230,7 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: { - match: (token: string) => token.length > 2, + match: (params) => params.text.length > 2, splitPattern: /\w+/g, }, }, @@ -240,7 +242,7 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: { - match: (token: string) => token.length > 4, + match: (params) => params.text.length > 4, minLength: 3, }, }, @@ -277,7 +279,8 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: (context) => ({ - match: () => true, + match: (params) => true, + splitPattern: /\w+/g, }), }, plugins: { replace: replaceLintPlugin }, @@ -288,6 +291,7 @@ describe('resolveLintConfigs', () => { { languageOptions: { parserOptions: (context) => ({ + match: (params) => true, splitPattern: /\w+/g, }), }, diff --git a/packages/slate-utils/src/queries/parseNode.ts b/packages/slate-utils/src/queries/parseNode.ts index 5ed7a3d9c2..402e850bb5 100644 --- a/packages/slate-utils/src/queries/parseNode.ts +++ b/packages/slate-utils/src/queries/parseNode.ts @@ -9,10 +9,16 @@ import { } from '@udecode/slate'; export type ParseNodeOptions = { + /** Function to match tokens and return match result */ + match: (params: { + end: number; + fullText: string; + getContext: (options: { after?: number; before?: number }) => string; + start: number; + text: string; + }) => AnyObject | boolean; /** Base path to the current node */ at: Path; - /** Function to match tokens and return match result */ - match: (token: string) => AnyObject | boolean; /** Maximum length of tokens to process */ maxLength?: number; /** Minimum length of tokens to process */ @@ -51,39 +57,56 @@ export type TokenMatch = { export const experimental_parseNode = ( editor: TEditor, - { - at, - match: matchToken, - maxLength = Infinity, - minLength = 0, - splitPattern = /\b[\dA-Za-z]+(?:['-]\w+)*\b/g, - transform, - }: ParseNodeOptions + options: ParseNodeOptions ): ParseNodeResult => { - const node = getNode(editor, at); + const node = getNode(editor, options.at); if (!node) return { decorations: [], tokens: [] }; const texts = [...getNodeTexts(node)]; - const str = texts.map((text) => text[0].text).join(''); + 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 tokenDecorations: TokenDecoration[] = []; const uniqueTokens = new Map(); - let matchResult: RegExpExecArray | null = null; - - while ((matchResult = splitPattern.exec(str)) !== null) { - const tokenText = matchResult[0]; + matches.forEach((match) => { + const tokenText = match[0]; + const start = match.index!; + const end = start + tokenText.length; // Skip tokens that don't meet length requirements - if (tokenText.length < minLength || tokenText.length > maxLength) { - continue; + if ( + tokenText.length < (options.minLength ?? 0) || + tokenText.length > (options.maxLength ?? Infinity) + ) { + return; } - const tokenStart = matchResult.index; - const tokenEnd = tokenStart + tokenText.length; - const tokenData = matchToken(tokenText); + const matchResult = options.match({ + end, + fullText, + getContext: createContextGetter(start, end), + start, + text: tokenText, + }); - if (tokenData) { + if (matchResult) { let startPath: Path | null = null; let endPath: Path | null = null; let startOffset = 0; @@ -96,14 +119,14 @@ export const experimental_parseNode = ( const textEnd = cumulativeLength + textLength; // Find start position - if (startPath === null && tokenStart < textEnd) { - startPath = [...at, ...path]; - startOffset = tokenStart - cumulativeLength; + if (startPath === null && start < textEnd) { + startPath = [...options.at, ...path]; + startOffset = start - cumulativeLength; } // Find end position - if (endPath === null && tokenEnd <= textEnd) { - endPath = [...at, ...path]; - endOffset = tokenEnd - cumulativeLength; + if (endPath === null && end <= textEnd) { + endPath = [...options.at, ...path]; + endOffset = end - cumulativeLength; } if (startPath !== null && endPath !== null) break; @@ -123,8 +146,8 @@ export const experimental_parseNode = ( text: tokenText, }; - if (transform) { - token = transform(token); + if (options.transform) { + token = options.transform(token); } // Store unique token @@ -138,19 +161,19 @@ export const experimental_parseNode = ( cumulativeLength = 0; for (const [text, path] of texts) { - const textPath = [...at, ...path]; + const textPath = [...options.at, ...path]; const textStart = cumulativeLength; const textEnd = textStart + text.text.length; - if (tokenStart >= textEnd) { + if (start >= textEnd) { cumulativeLength = textEnd; continue; } - if (tokenEnd <= textStart) break; + if (end <= textStart) break; - const overlapStart = Math.max(tokenStart, textStart); - const overlapEnd = Math.min(tokenEnd, textEnd); + const overlapStart = Math.max(start, textStart); + const overlapEnd = Math.min(end, textEnd); if (overlapStart < overlapEnd) { tokenDecorations.push({ @@ -172,7 +195,7 @@ export const experimental_parseNode = ( } } } - } + }); return { decorations: tokenDecorations, From 1cb3128be8165f2f61305ebfe0cceca9162fe811 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Thu, 5 Dec 2024 20:52:41 +0100 Subject: [PATCH 3/7] feat --- packages/lint/src/react/plugins/lint-plugin-case.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/react/plugins/lint-plugin-case.ts b/packages/lint/src/react/plugins/lint-plugin-case.ts index 8838882b74..8db7646ce7 100644 --- a/packages/lint/src/react/plugins/lint-plugin-case.ts +++ b/packages/lint/src/react/plugins/lint-plugin-case.ts @@ -63,7 +63,7 @@ const plugin = { name: 'case', }, rules: { - sentence: caseMatchRule, + 'capitalize-sentence': caseMatchRule, }, } satisfies LintConfigPlugin; @@ -73,8 +73,8 @@ export const caseLintPlugin = { all: { languageOptions: { parserOptions: (context) => { - const { options: contextOptions } = context; - const ignoredWords = contextOptions[0]?.ignoredWords ?? []; + const { options } = context; + const ignoredWords = options[0]?.ignoredWords ?? []; // Helper to check if a word is part of URL/email const isUrlOrEmail = ( @@ -140,7 +140,7 @@ export const caseLintPlugin = { name: 'case/all', plugins: { case: plugin }, rules: { - 'case/sentence': ['error'], + 'case/capitalize-sentence': ['error'], }, }, }, From 9cd9c729939c910ac87596876614973dbb00e70e Mon Sep 17 00:00:00 2001 From: zbeyens Date: Fri, 6 Dec 2024 10:46:41 +0100 Subject: [PATCH 4/7] feat --- .../default/example/emoji-plate-editor.tsx | 140 ++++++++++++++++++ .../registry/default/plate-ui/lint-leaf.tsx | 32 ++++ .../default/plate-ui/lint-popover.tsx | 137 +++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 apps/www/src/registry/default/example/emoji-plate-editor.tsx create mode 100644 apps/www/src/registry/default/plate-ui/lint-leaf.tsx create mode 100644 apps/www/src/registry/default/plate-ui/lint-popover.tsx diff --git a/apps/www/src/registry/default/example/emoji-plate-editor.tsx b/apps/www/src/registry/default/example/emoji-plate-editor.tsx new file mode 100644 index 0000000000..00b7e43679 --- /dev/null +++ b/apps/www/src/registry/default/example/emoji-plate-editor.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { BasicMarksPlugin } from '@udecode/plate-basic-marks/react'; +import { Plate, useEditorPlugin } from '@udecode/plate-common/react'; +import { NodeIdPlugin } from '@udecode/plate-node-id'; + +import { wordToEmojisMap } from '@/components/editor/lint/emoji-utils'; +import { LintLeaf } from '@/components/editor/lint/lint-leaf'; +import { LintPlugin } from '@/components/editor/lint/lint-plugin'; +import { emojiLintPlugin } from '@/components/editor/lint/lint-plugin-emoji'; +import { LintPopover } from '@/components/editor/lint/lint-popover'; +import { + useCreateEditor, + viewComponents, +} from '@/components/editor/use-create-editor'; +import { Button } from '@/registry/default/potion-ui/button'; +import { Editor, EditorContainer } from '@/registry/default/potion-ui/editor'; + +export function EmojiPlateEditor() { + const editor = useCreateEditor({ + override: { + components: viewComponents, + }, + plugins: [ + LintPlugin.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', + }, + ], + }); + + return ( +
+ + + +
+ ); +} + +function EmojiPlateEditorContent() { + const { api, editor } = useEditorPlugin(LintPlugin); + + const runFirst = () => { + api.lint.run([ + { + ...emojiLintPlugin.configs.all, + targets: [ + { id: editor.children[0].id as string }, + { id: editor.children[1].id as string }, + ], + }, + { + languageOptions: { + parserOptions: { + minLength: 4, + }, + }, + targets: [{ id: editor.children[0].id as string }], + }, + { + settings: { + emojiMap: wordToEmojisMap, + maxSuggestions: 5, + }, + }, + ]); + }; + + const runMax = () => { + api.lint.run([ + emojiLintPlugin.configs.all, + { + languageOptions: { + parserOptions: { + maxLength: 4, + }, + }, + settings: { + emojiMap: wordToEmojisMap, + }, + }, + ]); + }; + + const runAll = () => { + api.lint.run([ + emojiLintPlugin.configs.all, + { + settings: { + emojiMap: wordToEmojisMap, + }, + }, + ]); + }; + + return ( + <> +
+ + + + +
+ + + + + ); +} 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..51a5821e46 --- /dev/null +++ b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx @@ -0,0 +1,32 @@ +'use client'; + +import React from 'react'; + +import type { LintDecoration } from '@/components/editor/lint/types'; + +import { cn, withRef } from '@udecode/cn'; +import { PlateLeaf, useEditorPlugin } from '@udecode/plate-common/react'; + +import { LintPlugin } from '@/components/editor/lint/lint-plugin'; + +export const LintLeaf = withRef( + ({ children, className, ...props }, ref) => { + const { setOption } = useEditorPlugin(LintPlugin); + const leaf = props.leaf as LintDecoration; + + return ( + { + e.preventDefault(); + setOption('activeToken', leaf.token); + }} + {...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..f1dbfc4c6c --- /dev/null +++ b/apps/www/src/registry/default/plate-ui/lint-popover.tsx @@ -0,0 +1,137 @@ +'use client'; + +import React, { useEffect } from 'react'; + +import { + focusEditor, + useEditorPlugin, + useHotkeys, +} from '@udecode/plate-common/react'; + +import { LintPlugin } from '@/components/editor/lint/lint-plugin'; +import { useVirtualRefState } from '@/components/editor/lint/next/useVirtualRefState'; +import { useTokenSelected } from '@/components/editor/lint/useTokenSelected'; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from '@/registry/default/potion-ui/popover'; +import { Toolbar, ToolbarButton } from '@/registry/default/potion-ui/toolbar'; + +export function LintPopover() { + const { api, editor, setOption, tf, useOption } = useEditorPlugin(LintPlugin); + const activeToken = useOption('activeToken'); + const selected = useTokenSelected(); + const toolbarRef = React.useRef(null); + const firstButtonRef = React.useRef(null); + const [virtualRef] = useVirtualRefState({ + at: activeToken?.range, + }); + const suggestions = activeToken?.suggest ?? []; + const open = selected && !!virtualRef?.current && suggestions.length > 0; + + useEffect(() => { + if (!selected) { + setOption('activeToken', null); + } + }, [selected, setOption]); + + useHotkeys( + 'ctrl+space', + (e) => { + if (api.lint.setSelectedActiveToken()) { + e.preventDefault(); + } + }, + { enabled: !open, enableOnContentEditable: true } + ); + + useHotkeys( + 'enter', + (e) => { + const suggestion = activeToken?.suggest?.[0]; + + if (suggestion) { + e.preventDefault(); + + suggestion.fix({ goNext: true }); + } + }, + { enabled: open, enableOnContentEditable: true } + ); + + useHotkeys( + 'down', + (e) => { + e.preventDefault(); + firstButtonRef.current?.focus(); + }, + { enabled: open, enableOnContentEditable: true } + ); + useHotkeys( + 'up', + (e) => { + if (toolbarRef.current?.contains(document.activeElement)) { + e.preventDefault(); + focusEditor(editor); + } + }, + { enabled: open, enableOnContentEditable: true } + ); + + useHotkeys( + 'tab', + (e) => { + if (tf.lint.focusNextMatch()) { + e.preventDefault(); + } + }, + { enabled: open, enableOnContentEditable: true } + ); + + useHotkeys( + 'shift+tab', + (e) => { + if (tf.lint.focusNextMatch({ reverse: true })) { + e.preventDefault(); + } + }, + { enabled: open, enableOnContentEditable: true } + ); + + return ( + + + { + e.preventDefault(); + focusEditor(editor); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + setOption('activeToken', null); + }} + onOpenAutoFocus={(e) => { + e.preventDefault(); + }} + > + + {suggestions.map((suggestion, index) => ( + { + suggestion.fix(); + }} + > + {suggestion.data?.emoji} + + ))} + + + + ); +} From 00732cbc12a7cfc5dcf802d0fdecdc2d38b2241a Mon Sep 17 00:00:00 2001 From: zbeyens Date: Fri, 6 Dec 2024 16:52:53 +0100 Subject: [PATCH 5/7] feat --- apps/www/content/docs/lint.mdx | 97 ++++++ .../r/styles/default/lint-emoji-demo.json | 38 +++ apps/www/src/__registry__/index.tsx | 20 ++ apps/www/src/config/docs-plugins.ts | 6 + .../default/example/emoji-plate-editor.tsx | 140 -------- .../default/example/lint-emoji-demo.tsx | 303 ++++++++++++++++++ .../registry/default/plate-ui/lint-leaf.tsx | 7 +- .../default/plate-ui/lint-popover.tsx | 60 ++-- apps/www/src/registry/registry-examples.ts | 26 ++ package.json | 3 + packages/lint/package.json | 6 - packages/lint/src/index.ts | 5 + packages/lint/src/lib/__empty.ts | 1 + packages/lint/src/lib/index.ts | 5 + packages/lint/src/react/decorateLint.spec.ts | 47 ++- packages/lint/src/react/decorateLint.ts | 2 + packages/lint/src/react/lint-plugin.spec.ts | 4 + packages/lint/src/react/lint-plugin.tsx | 13 +- packages/lint/src/react/plugins/index.ts | 3 +- .../src/react/plugins/lint-plugin-replace.ts | 13 +- packages/lint/src/react/types.ts | 2 +- .../getNextSiblingNodes/two-siblings.spec.tsx | 3 +- .../src/queries/getNextRange.spec.tsx | 156 +++++++++ .../slate-utils/src/queries/getNextRange.ts | 37 ++- .../slate-utils/src/queries/parseNode.spec.ts | 209 ++++++++++++ packages/slate-utils/src/queries/parseNode.ts | 197 +++++++----- yarn.lock | 8 + 27 files changed, 1135 insertions(+), 276 deletions(-) create mode 100644 apps/www/content/docs/lint.mdx create mode 100644 apps/www/public/r/styles/default/lint-emoji-demo.json delete mode 100644 apps/www/src/registry/default/example/emoji-plate-editor.tsx create mode 100644 apps/www/src/registry/default/example/lint-emoji-demo.tsx create mode 100644 packages/lint/src/index.ts create mode 100644 packages/lint/src/lib/__empty.ts create mode 100644 packages/lint/src/lib/index.ts create mode 100644 packages/slate-utils/src/queries/getNextRange.spec.tsx create mode 100644 packages/slate-utils/src/queries/parseNode.spec.ts diff --git a/apps/www/content/docs/lint.mdx b/apps/www/content/docs/lint.mdx new file mode 100644 index 0000000000..4089970b86 --- /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/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 \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 \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\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/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index 911bd6352e..fcad75476d 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-emoji-demo": { + name: "lint-emoji-demo", + description: "", + type: "registry:example", + registryDependencies: ["editor","button","lint-leaf","lint-popover"], + files: [{ + path: "src/registry/default/example/lint-emoji-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-emoji-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/emoji-plate-editor.tsx b/apps/www/src/registry/default/example/emoji-plate-editor.tsx deleted file mode 100644 index 00b7e43679..0000000000 --- a/apps/www/src/registry/default/example/emoji-plate-editor.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client'; - -import { BasicMarksPlugin } from '@udecode/plate-basic-marks/react'; -import { Plate, useEditorPlugin } from '@udecode/plate-common/react'; -import { NodeIdPlugin } from '@udecode/plate-node-id'; - -import { wordToEmojisMap } from '@/components/editor/lint/emoji-utils'; -import { LintLeaf } from '@/components/editor/lint/lint-leaf'; -import { LintPlugin } from '@/components/editor/lint/lint-plugin'; -import { emojiLintPlugin } from '@/components/editor/lint/lint-plugin-emoji'; -import { LintPopover } from '@/components/editor/lint/lint-popover'; -import { - useCreateEditor, - viewComponents, -} from '@/components/editor/use-create-editor'; -import { Button } from '@/registry/default/potion-ui/button'; -import { Editor, EditorContainer } from '@/registry/default/potion-ui/editor'; - -export function EmojiPlateEditor() { - const editor = useCreateEditor({ - override: { - components: viewComponents, - }, - plugins: [ - LintPlugin.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', - }, - ], - }); - - return ( -
- - - -
- ); -} - -function EmojiPlateEditorContent() { - const { api, editor } = useEditorPlugin(LintPlugin); - - const runFirst = () => { - api.lint.run([ - { - ...emojiLintPlugin.configs.all, - targets: [ - { id: editor.children[0].id as string }, - { id: editor.children[1].id as string }, - ], - }, - { - languageOptions: { - parserOptions: { - minLength: 4, - }, - }, - targets: [{ id: editor.children[0].id as string }], - }, - { - settings: { - emojiMap: wordToEmojisMap, - maxSuggestions: 5, - }, - }, - ]); - }; - - const runMax = () => { - api.lint.run([ - emojiLintPlugin.configs.all, - { - languageOptions: { - parserOptions: { - maxLength: 4, - }, - }, - settings: { - emojiMap: wordToEmojisMap, - }, - }, - ]); - }; - - const runAll = () => { - api.lint.run([ - emojiLintPlugin.configs.all, - { - settings: { - emojiMap: wordToEmojisMap, - }, - }, - ]); - }; - - return ( - <> -
- - - - -
- - - - - ); -} diff --git a/apps/www/src/registry/default/example/lint-emoji-demo.tsx b/apps/www/src/registry/default/example/lint-emoji-demo.tsx new file mode 100644 index 0000000000..81ca43bf5f --- /dev/null +++ b/apps/www/src/registry/default/example/lint-emoji-demo.tsx @@ -0,0 +1,303 @@ +'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 }, + { id: editor.children[1].id as string }, + ], + }, + { + languageOptions: { + parserOptions: { + minLength: 4, + }, + }, + targets: [{ id: editor.children[0].id as string }], + }, + { + settings: { + maxSuggestions: 5, + replaceMap: emojiMap, + }, + }, + ]); + }; + + const runMax = () => { + api.lint.run([ + replaceLintPlugin.configs.all, + { + languageOptions: { + parserOptions: { + maxLength: 4, + }, + }, + settings: { + replaceMap: emojiMap, + }, + }, + ]); + }; + + const runAll = () => { + api.lint.run([ + replaceLintPlugin.configs.all, + { + settings: { + replaceMap: emojiMap, + }, + }, + ]); + }; + + const runCase = () => { + api.lint.run([ + caseLintPlugin.configs.all, + { + settings: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + }, + }, + ]); + }; + + const runBoth = () => { + api.lint.run([ + replaceLintPlugin.configs.all, + caseLintPlugin.configs.all, + { + settings: { + ignoredWords: ['iPhone', 'iOS', 'iPad'], + replaceMap: emojiMap, + }, + }, + ]); + }; + + return ( + <> +
+ + + + + + +
+ + + + + ); +} + +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/plate-ui/lint-leaf.tsx b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx index 51a5821e46..ce10cc30dc 100644 --- a/apps/www/src/registry/default/plate-ui/lint-leaf.tsx +++ b/apps/www/src/registry/default/plate-ui/lint-leaf.tsx @@ -2,16 +2,15 @@ import React from 'react'; -import type { LintDecoration } from '@/components/editor/lint/types'; +import type { LintDecoration } from '@udecode/plate-lint/react'; import { cn, withRef } from '@udecode/cn'; import { PlateLeaf, useEditorPlugin } from '@udecode/plate-common/react'; - -import { LintPlugin } from '@/components/editor/lint/lint-plugin'; +import { ExperimentalLintPlugin } from '@udecode/plate-lint/react'; export const LintLeaf = withRef( ({ children, className, ...props }, ref) => { - const { setOption } = useEditorPlugin(LintPlugin); + const { setOption } = useEditorPlugin(ExperimentalLintPlugin); const leaf = props.leaf as LintDecoration; return ( 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 f1dbfc4c6c..2979fe87cd 100644 --- a/apps/www/src/registry/default/plate-ui/lint-popover.tsx +++ b/apps/www/src/registry/default/plate-ui/lint-popover.tsx @@ -2,24 +2,29 @@ import React, { useEffect } from 'react'; +import { cn } from '@udecode/cn'; import { focusEditor, useEditorPlugin, useHotkeys, } from '@udecode/plate-common/react'; +import { useVirtualRefState } from '@udecode/plate-floating'; +import { + ExperimentalLintPlugin, + useTokenSelected, +} from '@udecode/plate-lint/react'; -import { LintPlugin } from '@/components/editor/lint/lint-plugin'; -import { useVirtualRefState } from '@/components/editor/lint/next/useVirtualRefState'; -import { useTokenSelected } from '@/components/editor/lint/useTokenSelected'; import { Popover, PopoverAnchor, PopoverContent, -} from '@/registry/default/potion-ui/popover'; -import { Toolbar, ToolbarButton } from '@/registry/default/potion-ui/toolbar'; +} from '@/registry/default/plate-ui/popover'; +import { Toolbar, ToolbarButton } from '@/registry/default/plate-ui/toolbar'; export function LintPopover() { - const { api, editor, setOption, tf, useOption } = useEditorPlugin(LintPlugin); + const { api, editor, setOption, tf, useOption } = useEditorPlugin( + ExperimentalLintPlugin + ); const activeToken = useOption('activeToken'); const selected = useTokenSelected(); const toolbarRef = React.useRef(null); @@ -30,6 +35,8 @@ export function LintPopover() { const suggestions = activeToken?.suggest ?? []; const open = selected && !!virtualRef?.current && suggestions.length > 0; + console.log(suggestions); + useEffect(() => { if (!selected) { setOption('activeToken', null); @@ -43,7 +50,7 @@ export function LintPopover() { e.preventDefault(); } }, - { enabled: !open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: !open } ); useHotkeys( @@ -55,9 +62,11 @@ export function LintPopover() { e.preventDefault(); suggestion.fix({ goNext: true }); + + console.log(editor.selection); } }, - { enabled: open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: open } ); useHotkeys( @@ -66,7 +75,7 @@ export function LintPopover() { e.preventDefault(); firstButtonRef.current?.focus(); }, - { enabled: open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: open } ); useHotkeys( 'up', @@ -76,17 +85,19 @@ export function LintPopover() { focusEditor(editor); } }, - { enabled: open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: open } ); useHotkeys( 'tab', (e) => { + console.log(1111); + if (tf.lint.focusNextMatch()) { e.preventDefault(); } }, - { enabled: open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: open } ); useHotkeys( @@ -96,17 +107,22 @@ export function LintPopover() { e.preventDefault(); } }, - { enabled: open, enableOnContentEditable: true } + { enableOnContentEditable: true, enabled: open } ); + console.log(activeToken?.data); + return ( { e.preventDefault(); - focusEditor(editor); + // focusEditor(editor); }} onEscapeKeyDown={(e) => { e.preventDefault(); @@ -116,18 +132,26 @@ export function LintPopover() { e.preventDefault(); }} > - + {suggestions.map((suggestion, index) => ( { suggestion.fix(); }} > - {suggestion.data?.emoji} + {suggestion.data?.text} ))} diff --git a/apps/www/src/registry/registry-examples.ts b/apps/www/src/registry/registry-examples.ts index 69d005facc..02ddc2cff6 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-emoji-demo.tsx', type: 'registry:example' }, + { + path: 'components/editor/use-create-editor.ts', + type: 'registry:example', + }, + ], + name: 'lint-emoji-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/lint/package.json b/packages/lint/package.json index c29f5d08e6..cbb561c142 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -19,12 +19,6 @@ "license": "MIT", "sideEffects": false, "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "module": "./dist/index.mjs", - "require": "./dist/index.js" - }, "./react": { "types": "./dist/react/index.d.ts", "import": "./dist/react/index.mjs", 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 index 1643f60406..0454f9a3fb 100644 --- a/packages/lint/src/react/decorateLint.spec.ts +++ b/packages/lint/src/react/decorateLint.spec.ts @@ -10,8 +10,14 @@ import { replaceLintPlugin } from './plugins/lint-plugin-replace'; describe('decorateLint', () => { const replaceMap = new Map([ - ['hello', [{ text: '👋' }]], - ['world', [{ text: '🌍' }, { text: '🌎' }]], + ['hello', [{ text: '👋', type: 'emoji' }]], + [ + 'world', + [ + { text: '🌍', type: 'emoji' }, + { text: '🌎', type: 'emoji' }, + ], + ], ]); it('should decorate matching text', () => { @@ -36,7 +42,7 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: replaceMap, + replaceMap, }, }, ]); @@ -58,12 +64,18 @@ describe('decorateLint', () => { }, lint: true, token: { + data: { + type: 'emoji', + }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), suggest: [ { - data: { text: '👋' }, + data: { + text: '👋', + type: 'emoji', + }, fix: expect.any(Function), }, ], @@ -81,16 +93,25 @@ describe('decorateLint', () => { }, lint: true, token: { + data: { + type: 'emoji', + }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), suggest: [ { - data: { text: '🌍' }, + data: { + text: '🌍', + type: 'emoji', + }, fix: expect.any(Function), }, { - data: { text: '🌎' }, + data: { + text: '🌎', + type: 'emoji', + }, fix: expect.any(Function), }, ], @@ -127,7 +148,7 @@ describe('decorateLint', () => { }, { settings: { - replaceMap: replaceMap, + replaceMap, }, }, ]); @@ -149,12 +170,18 @@ describe('decorateLint', () => { }, lint: true, token: { + data: { + type: 'emoji', + }, messageId: 'replaceWithText', range: expect.any(Object), rangeRef: expect.any(Object), suggest: [ { - data: { text: '👋' }, + data: { + text: '👋', + type: 'emoji', + }, fix: expect.any(Function), }, ], @@ -175,7 +202,7 @@ describe('decorateLint', () => { replaceLintPlugin.configs.all, { settings: { - replaceMap: replaceMap, + replaceMap, }, }, ]); @@ -219,7 +246,7 @@ describe('decorateLint', () => { }, { settings: { - replaceMap: replaceMap, + replaceMap, }, }, ]); diff --git a/packages/lint/src/react/decorateLint.ts b/packages/lint/src/react/decorateLint.ts index 1534be2a7e..9e9c664a0e 100644 --- a/packages/lint/src/react/decorateLint.ts +++ b/packages/lint/src/react/decorateLint.ts @@ -1,6 +1,7 @@ import type { Decorate, PlatePluginContext } from '@udecode/plate-common/react'; import { + collapseSelection, deleteText, experimental_parseNode, isBlock, @@ -73,6 +74,7 @@ export const decorateLint: Decorate = (ctx) => { tf.lint.focusNextMatch(); }, 0); } else { + collapseSelection(editor); setTimeout(() => { setOption('activeToken', null); }, 0); diff --git a/packages/lint/src/react/lint-plugin.spec.ts b/packages/lint/src/react/lint-plugin.spec.ts index 4a37b0a9a1..68e6b932c8 100644 --- a/packages/lint/src/react/lint-plugin.spec.ts +++ b/packages/lint/src/react/lint-plugin.spec.ts @@ -2,6 +2,10 @@ 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 token', () => { const editor = createPlateEditor({ diff --git a/packages/lint/src/react/lint-plugin.tsx b/packages/lint/src/react/lint-plugin.tsx index 576b8df88a..d66782da3c 100644 --- a/packages/lint/src/react/lint-plugin.tsx +++ b/packages/lint/src/react/lint-plugin.tsx @@ -1,7 +1,9 @@ import { type PluginConfig, + collapseSelection, getNextRange, isSelectionInRange, + setSelection, } from '@udecode/plate-common'; import { createTPlatePlugin, focusEditor } from '@udecode/plate-common/react'; @@ -57,6 +59,8 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ reverse: options?.reverse, }); + console.log(33, ranges, activeToken?.rangeRef.current, nextRange); + if (!nextRange) return; return tokens[ranges.indexOf(nextRange)]; @@ -90,10 +94,17 @@ export const ExperimentalLintPlugin = createTPlatePlugin({ ({ api, editor, setOption }) => ({ focusNextMatch: (options) => { const match = api.lint.getNextMatch(options); + console.log(222, match); setOption('activeToken', match ?? null); if (match) { - focusEditor(editor, match!.rangeRef.current!); + console.log('focusNextMatch', editor.selection); + collapseSelection(editor); + setSelection(editor, match!.rangeRef.current!); + focusEditor(editor); + // setTimeout(() => { + // focusEditor(editor); + // }, 0); } return match; diff --git a/packages/lint/src/react/plugins/index.ts b/packages/lint/src/react/plugins/index.ts index a0e930729a..178218e7af 100644 --- a/packages/lint/src/react/plugins/index.ts +++ b/packages/lint/src/react/plugins/index.ts @@ -2,4 +2,5 @@ * @file Automatically generated by barrelsby. */ -export * from './lint-plugin-emoji'; +export * from './lint-plugin-case'; +export * from './lint-plugin-replace'; diff --git a/packages/lint/src/react/plugins/lint-plugin-replace.ts b/packages/lint/src/react/plugins/lint-plugin-replace.ts index c0e25a430a..4000ae091a 100644 --- a/packages/lint/src/react/plugins/lint-plugin-replace.ts +++ b/packages/lint/src/react/plugins/lint-plugin-replace.ts @@ -6,7 +6,7 @@ import type { export type ReplaceLintPluginOptions = { maxSuggestions?: number; - replaceMap?: Map; + replaceMap?: Map; }; const replaceMatchRule: LintConfigPluginRule = { @@ -20,16 +20,25 @@ const replaceMatchRule: LintConfigPluginRule = { return { ...token, + data: { + ...token.data, + type: replacements?.[0]?.type, + }, messageId: 'replaceWithText', suggest: replacements?.slice(0, maxSuggestions).map( (replacement): LintTokenSuggestion => ({ - data: { text: replacement.text }, + data: { + text: replacement.text, + type: replacement.type, + }, fix: (options) => { + console.log(token.rangeRef.current); fixer.replaceText({ range: token.rangeRef.current!, text: replacement.text, ...options, }); + console.log(token.rangeRef.current); }, }) ), diff --git a/packages/lint/src/react/types.ts b/packages/lint/src/react/types.ts index 99372652ef..3b4e662922 100644 --- a/packages/lint/src/react/types.ts +++ b/packages/lint/src/react/types.ts @@ -158,7 +158,7 @@ export type LintFixer = { export type LintParserOptions = { /** Function to match tokens and return match result */ - match: (params: { + match?: (params: { end: number; fullText: string; getContext: (options: { after?: number; before?: number }) => string; 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/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 index c9151142fe..1c276fb908 100644 --- a/packages/slate-utils/src/queries/getNextRange.ts +++ b/packages/slate-utils/src/queries/getNextRange.ts @@ -23,30 +23,43 @@ export const getNextRange = ( } ) => { if (ranges.length === 0) return; + // Handle the case when there's no 'from' range if (!from) { if (!editor.selection) { - // If no selection and no range, select first/last depending on direction return reverse ? ranges.at(-1) : ranges[0]; } - // Find the first range after the current selection - const selectionEnd = Range.end(editor.selection); - const nextRange = ranges.find((range) => { - const rangeStart = Range.start(range); + const selectionPoint = Range.start(editor.selection); - return reverse - ? Point.isBefore(rangeStart, selectionEnd) - : Point.isAfter(rangeStart, selectionEnd); - }); + // 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) + ); + } - // If no range found after selection, wrap around to first/last return nextRange ?? (reverse ? ranges.at(-1) : ranges[0]); } - // Find current index + // When there is a 'from' range, find the next/previous range const currentIndex = ranges.findIndex((range) => Range.equals(range, from)); - if (currentIndex === -1) return; + if (currentIndex === -1) return ranges[0]; // Return first range if current not found // Calculate next index let nextIndex: number; 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..fd15d3ce7d --- /dev/null +++ b/packages/slate-utils/src/queries/parseNode.spec.ts @@ -0,0 +1,209 @@ +/* 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 { decorations, tokens } = experimental_parseNode(editor, { + at: [0], + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + }); + + expect(tokens).toHaveLength(6); // hello, world, this, is, a, test + expect(decorations).toHaveLength(6); + + // Check token positions + expect(tokens[0].text).toBe('hello'); + expect(tokens[0].range.anchor.offset).toBe(0); + expect(tokens[0].range.focus.offset).toBe(5); + + expect(tokens[1].text).toBe('world'); + expect(tokens[1].range.anchor.offset).toBe(6); + expect(tokens[1].range.focus.offset).toBe(11); + }); + + it('should respect minLength and maxLength', () => { + const { tokens } = 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(tokens).toHaveLength(4); + expect(tokens.map((t) => t.text)).toEqual([ + 'hello', + 'world', + 'this', + 'test', + ]); + }); + + it('should apply match function correctly', () => { + const { tokens } = 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(tokens).toHaveLength(2); + expect(tokens.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 { tokens } = experimental_parseNode(editor, { + at: [0], + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + transform: (token) => ({ + ...token, + data: { transformed: true }, + }), + }); + + tokens.forEach((token) => { + expect(token.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 { tokens } = experimental_parseNode(editor, { + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + }); + + expect(tokens).toHaveLength(4); // hello, world, another, block + expect(tokens.map((t) => t.text)).toEqual([ + 'hello', + 'world', + 'another', + 'block', + ]); + + // Check paths are correct - now they should be nested under root [] + expect(tokens[0].range.anchor.path).toEqual([0, 0]); + expect(tokens[2].range.anchor.path).toEqual([1, 0]); + }); + + it('should handle empty editor with at=[]', () => { + editor.children = []; + + const { decorations, tokens } = experimental_parseNode(editor, { + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + }); + + expect(tokens).toHaveLength(0); + expect(decorations).toHaveLength(0); + }); + + it('should handle root block with at=[]', () => { + editor.children = [ + { + children: [{ text: 'hello' }], + type: 'p', + }, + { + children: [{ text: 'world' }], + type: 'p', + }, + ]; + + const { tokens } = experimental_parseNode(editor, { + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + }); + + expect(tokens.map((t) => t.text)).toEqual(['hello', 'world']); + expect(tokens[0].range.anchor.path).toEqual([0, 0]); + expect(tokens[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 { tokens } = experimental_parseNode(editor, { + match: () => true, + splitPattern: /\b[A-Za-z]+\b/g, + }); + + expect(tokens.map((t) => t.text)).toEqual(['nested']); + expect(tokens[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 index 402e850bb5..4fd1f7260c 100644 --- a/packages/slate-utils/src/queries/parseNode.ts +++ b/packages/slate-utils/src/queries/parseNode.ts @@ -1,11 +1,15 @@ -import type { AnyObject } from '@udecode/utils'; +import type { AnyObject, UnknownObject } from '@udecode/utils'; import type { Path, Range, RangeRef } from 'slate'; import { + type TDescendant, type TEditor, + type TElement, createRangeRef, getNode, getNodeTexts, + isBlock, + isEditor, } from '@udecode/slate'; export type ParseNodeOptions = { @@ -17,8 +21,8 @@ export type ParseNodeOptions = { start: number; text: string; }) => AnyObject | boolean; - /** Base path to the current node */ - at: Path; + /** Target path or range. If undefined, parses entire editor */ + at?: Path | Range; /** Maximum length of tokens to process */ maxLength?: number; /** Minimum length of tokens to process */ @@ -53,16 +57,45 @@ export type TokenMatch = { range: Range; rangeRef: RangeRef; text: string; + data?: UnknownObject; }; export const experimental_parseNode = ( editor: TEditor, options: ParseNodeOptions ): ParseNodeResult => { - const node = getNode(editor, options.at); + 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 { decorations: [], tokens: [] }; + // 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 { + decorations: results.flatMap((r: ParseNodeResult) => r.decorations), + tokens: results.flatMap((r: ParseNodeResult) => r.tokens), + }; + } + // Parse single block const texts = [...getNodeTexts(node)]; const fullText = texts.map((text) => text[0].text).join(''); @@ -98,6 +131,7 @@ export const experimental_parseNode = ( return; } + // Apply match function const matchResult = options.match({ end, fullText, @@ -106,93 +140,96 @@ export const experimental_parseNode = ( text: tokenText, }); - if (matchResult) { - let startPath: Path | null = null; - let endPath: Path | null = null; - let startOffset = 0; - let endOffset = 0; - let cumulativeLength = 0; + // Skip if match function returns false + if (!matchResult) { + return; + } - // Find the correct start and end positions across leaves - for (const [text, path] of texts) { - const textLength = text.text.length; - const textEnd = cumulativeLength + textLength; + 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; - // Find start position - if (startPath === null && start < textEnd) { - startPath = [...options.at, ...path]; - startOffset = start - cumulativeLength; - } - // Find end position - if (endPath === null && end <= textEnd) { - endPath = [...options.at, ...path]; - endOffset = end - cumulativeLength; - } - if (startPath !== null && endPath !== null) break; + cumulativeLength = textEnd; + } - cumulativeLength = textEnd; + if (startPath && endPath) { + const tokenRange = { + anchor: { offset: startOffset, path: startPath }, + focus: { offset: endOffset, path: endPath }, + }; + const tokenRangeRef = createRangeRef(editor, tokenRange); + + let token = { + range: tokenRange, + rangeRef: tokenRangeRef, + text: tokenText, + }; + + if (options.transform) { + token = options.transform(token); } - if (startPath && endPath) { - const tokenRange = { - anchor: { offset: startOffset, path: startPath }, - focus: { offset: endOffset, path: endPath }, - }; - const tokenRangeRef = createRangeRef(editor, tokenRange); - - let token = { - range: tokenRange, - rangeRef: tokenRangeRef, - text: tokenText, - }; - - if (options.transform) { - token = options.transform(token); - } + // Store unique token + const tokenKey = `${tokenRange.anchor.path.join('-')}-${tokenRange.anchor.offset}-${tokenRange.focus.offset}`; - // Store unique token - const tokenKey = `${tokenRange.anchor.path.join('-')}-${tokenRange.anchor.offset}-${tokenRange.focus.offset}`; + if (!uniqueTokens.has(tokenKey)) { + uniqueTokens.set(tokenKey, token); + } - if (!uniqueTokens.has(tokenKey)) { - uniqueTokens.set(tokenKey, token); - } + // Create decorations + cumulativeLength = 0; - // Create decorations - cumulativeLength = 0; - - for (const [text, path] of texts) { - const textPath = [...options.at, ...path]; - const textStart = cumulativeLength; - const textEnd = textStart + text.text.length; - - if (start >= textEnd) { - cumulativeLength = textEnd; - - continue; - } - if (end <= textStart) break; - - const overlapStart = Math.max(start, textStart); - const overlapEnd = Math.min(end, textEnd); - - if (overlapStart < overlapEnd) { - tokenDecorations.push({ - range: { - anchor: { - offset: overlapStart - textStart, - path: textPath, - }, - focus: { - offset: overlapEnd - textStart, - path: textPath, - }, - }, - token, - }); - } + for (const [text, path] of texts) { + const textPath = [...at, ...path]; + const textStart = cumulativeLength; + const textEnd = textStart + text.text.length; + if (start >= textEnd) { cumulativeLength = textEnd; + + continue; } + if (end <= textStart) break; + + const overlapStart = Math.max(start, textStart); + const overlapEnd = Math.min(end, textEnd); + + if (overlapStart < overlapEnd) { + tokenDecorations.push({ + range: { + anchor: { + offset: overlapStart - textStart, + path: textPath, + }, + focus: { + offset: overlapEnd - textStart, + path: textPath, + }, + }, + token, + }); + } + + cumulativeLength = textEnd; } } }); diff --git a/yarn.lock b/yarn.lock index 8c16f48ee8..31240cca88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11957,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" @@ -17432,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" From ae20b36daa813296a45a3dfe37b16003df797c26 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Fri, 6 Dec 2024 20:51:32 +0100 Subject: [PATCH 6/7] feat --- .../default/example/lint-emoji-demo.tsx | 32 +- .../registry/default/example/mode-toggle.tsx | 5 +- .../registry/default/plate-ui/lint-leaf.tsx | 3 +- .../default/plate-ui/lint-popover.tsx | 31 +- packages/lint/src/react/decorateLint.spec.ts | 343 ++++++-------- packages/lint/src/react/decorateLint.ts | 119 +---- packages/lint/src/react/lint-plugin.spec.ts | 38 +- packages/lint/src/react/lint-plugin.tsx | 61 +-- .../react/plugins/lint-plugin-case.spec.ts | 140 ++---- .../src/react/plugins/lint-plugin-case.ts | 26 +- .../react/plugins/lint-plugin-replace.spec.ts | 81 +++- .../src/react/plugins/lint-plugin-replace.ts | 16 +- packages/lint/src/react/runLint.spec.ts | 303 ++++++++++++ packages/lint/src/react/runLint.ts | 118 +++++ packages/lint/src/react/types.ts | 38 +- packages/lint/src/react/utils/index.ts | 2 +- ...enSelected.ts => useAnnotationSelected.ts} | 12 +- .../queries/annotationToDecorations.spec.ts | 439 ++++++++++++++++++ .../src/queries/annotationToDecorations.ts | 92 ++++ packages/slate-utils/src/queries/index.ts | 1 + .../slate-utils/src/queries/parseNode.spec.ts | 72 ++- packages/slate-utils/src/queries/parseNode.ts | 125 ++--- 22 files changed, 1388 insertions(+), 709 deletions(-) create mode 100644 packages/lint/src/react/runLint.spec.ts create mode 100644 packages/lint/src/react/runLint.ts rename packages/lint/src/react/utils/{useTokenSelected.ts => useAnnotationSelected.ts} (56%) create mode 100644 packages/slate-utils/src/queries/annotationToDecorations.spec.ts create mode 100644 packages/slate-utils/src/queries/annotationToDecorations.ts diff --git a/apps/www/src/registry/default/example/lint-emoji-demo.tsx b/apps/www/src/registry/default/example/lint-emoji-demo.tsx index 81ca43bf5f..069d72ed85 100644 --- a/apps/www/src/registry/default/example/lint-emoji-demo.tsx +++ b/apps/www/src/registry/default/example/lint-emoji-demo.tsx @@ -78,24 +78,10 @@ function EmojiPlateEditorContent() { api.lint.run([ { ...replaceLintPlugin.configs.all, - targets: [ - { id: editor.children[0].id as string }, - { id: editor.children[1].id as string }, - ], - }, - { - languageOptions: { - parserOptions: { - minLength: 4, - }, - }, - targets: [{ id: editor.children[0].id as string }], - }, - { settings: { - maxSuggestions: 5, replaceMap: emojiMap, }, + targets: [{ id: editor.children[0].id as string }], }, ]); }; @@ -116,17 +102,6 @@ function EmojiPlateEditorContent() { ]); }; - const runAll = () => { - api.lint.run([ - replaceLintPlugin.configs.all, - { - settings: { - replaceMap: emojiMap, - }, - }, - ]); - }; - const runCase = () => { api.lint.run([ caseLintPlugin.configs.all, @@ -155,14 +130,11 @@ function EmojiPlateEditorContent() { <>
- 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 ( \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 */