├── .changeset ├── README.md └── config.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── renovate.json └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── inactive-issues.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── plugin ├── CHANGELOG.md ├── copy-files.js ├── eslint.config.mjs ├── package.json ├── publish-lib.js ├── src │ ├── BeautifulMentionsPlugin.tsx │ ├── BeautifulMentionsPluginProps.ts │ ├── ComboboxPlugin.tsx │ ├── MentionComponent.tsx │ ├── MentionNode.spec.ts │ ├── MentionNode.tsx │ ├── Menu.tsx │ ├── PlaceholderNode.ts │ ├── PlaceholderPlugin.tsx │ ├── ZeroWidthNode.ts │ ├── ZeroWidthPlugin.tsx │ ├── createMentionNode.ts │ ├── environment.ts │ ├── index.ts │ ├── mention-commands.ts │ ├── mention-converter.spec.ts │ ├── mention-converter.ts │ ├── mention-utils.ts │ ├── theme.ts │ ├── useBeautifulMentions.ts │ ├── useDebounce.ts │ ├── useIsFocused.ts │ ├── useMentionLookupService.spec.tsx │ └── useMentionLookupService.ts ├── tsconfig.build.json ├── tsconfig.json └── vitest.config.ts ├── resources ├── screenshot1.png └── screenshot2.png ├── turbo.json └── www ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile.e2e ├── app ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── Combobox.tsx ├── ConfigurationProvider.tsx ├── CustomMentionComponent.tsx ├── Editor.css ├── Editor.tsx ├── Empty.tsx ├── GithubButton.tsx ├── MentionsToolbarPlugin.tsx ├── Menu.tsx ├── ModeToggle.tsx ├── Navbar.tsx ├── Placeholder.tsx ├── ThemeProvider.tsx └── ui │ ├── Button.tsx │ ├── Card.tsx │ ├── Checkbox.tsx │ ├── DropdownMenu.tsx │ ├── Separator.tsx │ └── Tooltip.tsx ├── eslint.config.mjs ├── lib ├── editor-config.ts ├── editor-theme.ts ├── editor-utils.ts ├── useQueryParams.ts └── utils.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── tests ├── combobox.spec.ts ├── commands.spec.ts ├── mention.spec.ts ├── menu.spec.ts ├── menu.spec.ts-snapshots │ ├── Mention-Menu-should-dynamically-position-the-menu-1-Mobile-Safari-linux.png │ └── Mention-Menu-should-dynamically-position-the-menu-1-chromium-linux.png ├── space.spec.ts └── test-utils.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "privatePackages": { "version": false, "tag": false } 12 | } 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchPackagePatterns": [ 9 | "*" 10 | ], 11 | "rangeStrategy": "pin" 12 | }, 13 | { 14 | "matchDepTypes": [ 15 | "peerDependencies" 16 | ], 17 | "rangeStrategy": "auto" 18 | }, 19 | { 20 | "matchDepTypes": ["peerDependencies"], 21 | "excludePackagePatterns": ["^react", "^lexical$", "^@lexical\\/"], 22 | "matchUpdateTypes": ["patch"], 23 | "groupName": "peerDependencies (patch)" 24 | }, 25 | { 26 | "matchDepTypes": ["devDependencies"], 27 | "excludePackagePatterns": ["^react", "^lexical$", "^@lexical\\/"], 28 | "matchUpdateTypes": ["patch", "minor"], 29 | "groupName": "devDependencies (non-major)" 30 | }, 31 | { 32 | "matchPackagePatterns": [ 33 | "^react" 34 | ], 35 | "groupName": "React dependencies" 36 | }, 37 | { 38 | "matchPackagePatterns": [ 39 | "^lexical$", 40 | "^@lexical\\/" 41 | ], 42 | "groupName": "lexical dependencies" 43 | }, 44 | { 45 | "matchPackagePatterns": [ 46 | "^playwright$", 47 | "^@playwright\\/test$", 48 | "^mcr.microsoft.com\\/playwright$" 49 | ], 50 | "groupName": "playwright dependencies" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | cache: 'npm' 16 | - run: npm ci 17 | - run: npm run test 18 | e2e: 19 | name: 'e2e Tests - ${{ matrix.project }}' 20 | timeout-minutes: 60 21 | runs-on: ubuntu-latest 22 | container: 23 | image: mcr.microsoft.com/playwright:v1.52.0-jammy 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | shardIndex: [1, 2, 3, 4] 28 | shardTotal: [4] 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | cache: 'npm' 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Install Playwright browsers 38 | run: npx playwright install --with-deps 39 | - name: Run Playwright tests 40 | run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} 41 | working-directory: www 42 | - name: Upload blob report to GitHub Actions Artifacts 43 | if: always() 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: blob-report-${{ matrix.shardIndex }} 47 | path: www/blob-report 48 | retention-days: 1 49 | merge-reports: 50 | if: always() 51 | needs: [ e2e ] 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 20 58 | cache: 'npm' 59 | - name: Install dependencies 60 | run: npm ci 61 | - name: Download blob reports from GitHub Actions Artifacts 62 | uses: actions/download-artifact@v4 63 | with: 64 | path: all-blob-reports 65 | pattern: blob-report-* 66 | merge-multiple: true 67 | - name: Merge into HTML Report 68 | run: npx playwright merge-reports --reporter html ./all-blob-reports 69 | - name: Upload HTML report 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: html-report--attempt-${{ github.run_attempt }} 73 | path: playwright-report 74 | retention-days: 14 75 | hygiene: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: actions/setup-node@v4 80 | with: 81 | node-version: 20 82 | cache: 'npm' 83 | - run: npm ci 84 | - run: npm run hygiene 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '45 22 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/inactive-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | close-issues: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - uses: actions/stale@v9 15 | with: 16 | stale-issue-label: "stale" 17 | stale-issue-message: "This issue is stale because it has been open for 60 days with no activity." 18 | close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." 19 | days-before-pr-stale: -1 20 | days-before-pr-close: -1 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: [ main ] 5 | concurrency: ${{ github.workflow }}-${{ github.ref }} 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 11 | - name: Git fetch tags 12 | run: git fetch --tags origin 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'npm' 17 | - run: npm ci 18 | - name: Create Release Pull Request or Publish to npm 19 | id: changesets 20 | uses: changesets/action@v1 21 | with: 22 | publish: npm run release 23 | commit: 'chore(): version packages' 24 | createGithubReleases: true 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # misc 7 | .DS_Store 8 | *.pem 9 | 10 | # debug 11 | npm-debug.log* 12 | 13 | # local env files 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | # turbo 20 | .turbo 21 | 22 | # build 23 | plugin/lib 24 | 25 | # IDE 26 | .idea 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tailwindConfig": "./www/tailwind.config.js", 3 | "plugins": [ 4 | "prettier-plugin-organize-imports", 5 | "prettier-plugin-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vitest.commandLine": "npx vitest", 3 | "vitest.include": [ 4 | "plugin/**/*.spec.{ts,tsx}" 5 | ], 6 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to lexical-beautiful-mentions 2 | Here is a quick guide to doing code contributions to lexical-beautiful-mentions. 3 | 4 | ## Pull Requests 5 | 6 | 1. Fork this repository. 7 | 2. Create a new branch following the convention `[type/scope]`. Type can be either `fix`, `feat`, or any other [commit convention](https://www.conventionalcommits.org/en/v1.0.0/) type. Scope is a short describes of the work. 8 | 3. Install dependencies: 9 | ```sh 10 | npm install 11 | ``` 12 | 4. Start the app: 13 | ```sh 14 | npm run dev 15 | ``` 16 | 5. Make and commit your changes following the commit convention. 17 | 6. Ensure tests and build passes: 18 | ```sh 19 | npm run hygiene 20 | npm run test 21 | npm run e2e 22 | npm run build 23 | ``` 24 | 7. If you've changed APIs, update the documentation. 25 | 8. Push your branch. 26 | 9. Submit a pull request to the upstream [lexical-beautiful-mentions repository](https://github.com/sodenn/lexical-beautiful-mentions/pulls).
27 | > Maintainers will merge the pull request by squashing all commits and editing the commit message if necessary. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dennis Soehnen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-beautiful-mentions", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "plugin", 7 | "www" 8 | ], 9 | "scripts": { 10 | "build": "turbo run build", 11 | "dev": "turbo run dev", 12 | "fmt": "prettier --check \"**/*.{ts,tsx}\"", 13 | "lint": "turbo run lint", 14 | "typecheck": "turbo run typecheck", 15 | "hygiene": "turbo run typecheck lint fmt", 16 | "test": "turbo run test", 17 | "e2e": "turbo run e2e", 18 | "release": "turbo run release && changeset tag" 19 | }, 20 | "devDependencies": { 21 | "@changesets/cli": "2.29.4", 22 | "@eslint/compat": "1.2.9", 23 | "@eslint/eslintrc": "3.3.1", 24 | "@eslint/js": "9.27.0", 25 | "@types/react": "19.1.6", 26 | "@types/react-dom": "19.1.5", 27 | "eslint": "9.27.0", 28 | "eslint-config-prettier": "10.1.5", 29 | "eslint-config-turbo": "2.5.4", 30 | "eslint-plugin-react": "7.37.5", 31 | "eslint-plugin-react-hooks": "5.2.0", 32 | "globals": "16.2.0", 33 | "prettier": "3.5.3", 34 | "prettier-plugin-organize-imports": "4.1.0", 35 | "prettier-plugin-tailwindcss": "0.6.12", 36 | "react": "19.1.0", 37 | "react-dom": "19.1.0", 38 | "turbo": "2.5.4", 39 | "typescript": "5.8.3", 40 | "typescript-eslint": "8.33.0" 41 | }, 42 | "overrides": { 43 | "typescript-eslint": { 44 | "typescript": "$typescript" 45 | } 46 | }, 47 | "packageManager": "npm@11.4.1" 48 | } 49 | -------------------------------------------------------------------------------- /plugin/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # lexical-beautiful-mentions 2 | 3 | ## 0.1.47 4 | 5 | ### Patch Changes 6 | 7 | - 6c47650: chore: mark PlaceholderPlugin as deprecated, no longer needed 8 | - 5644f09: fix(): insert mentions via the keyboard only by enter key 9 | 10 | ## 0.1.46 11 | 12 | ### Patch Changes 13 | 14 | - 7e5c094: fix: use condition exports based on import type 15 | 16 | ## 0.1.45 17 | 18 | ### Patch Changes 19 | 20 | - 4346b4d: fix(mentions): avoid opening the mention menu if no matching results are found #681 21 | 22 | ## 0.1.44 23 | 24 | ### Patch Changes 25 | 26 | - 68c908e: feat(): insertMention function use autoSpace of config 27 | 28 | ## 0.1.43 29 | 30 | ### Patch Changes 31 | 32 | - df15d2f: fix(): prevent unintended deletion of text after inserting a mention 33 | - f3153a0: fix(): ensure space is added after multi-character trigger mention 34 | 35 | ## 0.1.42 36 | 37 | ### Patch Changes 38 | 39 | - bd1b143: feat: make mention auto-spacing configurable 40 | - 7e53fbd: refactor(): deprecate ZeroWidthPlugin and introduce PlaceholderPlugin 41 | - Easier Maintenance: Without the need to handle ZeroWidthNode, the beautiful-mentions codebase becomes less complex, which can lead to fewer bugs and easier maintenance. 42 | - Simplified Export: zero-width characters no longer need to be removed before exporting the content. 43 | 44 | ## 0.1.41 45 | 46 | ### Patch Changes 47 | 48 | - 9e795c1: feat(): show suggestions even if the cursor is in the middle of the query 49 | - fbf63cc: feat(): insert mention when pressing a non-word character 50 | 51 | ## 0.1.40 52 | 53 | ### Patch Changes 54 | 55 | - e71a286: feat($transformTextToMentionNodes): add utility function to transform text nodes to mention nodes 56 | 57 | ## 0.1.39 58 | 59 | ### Patch Changes 60 | 61 | - 9ef8442: fix(): prevent error with apple pencil when key is undefined #575 [thanks, @circlingthesun] 62 | 63 | ## 0.1.38 64 | 65 | ### Patch Changes 66 | 67 | - f481f85: fix($findBeautifulMentionNodes): add null check for CustomBeautifulMentionNode 68 | 69 | ## 0.1.37 70 | 71 | ### Patch Changes 72 | 73 | - f041caa: fix(): add space between text and mention when cursor is at line start and in front of mention 74 | - 2eddba3: fix(useBeautifulMentions): fix broken functions for custom mention node (removeMentions/renameMentions) 75 | 76 | ## 0.1.36 77 | 78 | ### Patch Changes 79 | 80 | - eb6b1a3: fix(): mention selection not being cleared when clicking outside the editor and focus the editor again 81 | - 7e4b35e: feat(): allow to define a set of characters that can appear directly before the trigger character 82 | 83 | ## 0.1.35 84 | 85 | ### Patch Changes 86 | 87 | - 9588d0a: fix(): undo not working after insert mention on focus lost 88 | 89 | ## 0.1.34 90 | 91 | ### Patch Changes 92 | 93 | - 3fc023a: fix(utils): regex always failed to match if triggers are empty (#440) [thanks, @reekystive] 94 | - 8f83624: docs(): add links to sections in README.md 95 | - 8f83624: refactor(): rename `BeautifulMentionsThemeValues` to `BeautifulMentionsCssClassNames` for more understandable naming 96 | - 8f83624: feat(): throw error with explanation when BeautifulMentionNode is not registered on editor 97 | 98 | ## 0.1.33 99 | 100 | ### Patch Changes 101 | 102 | - 63de856: fix(BeautifulMentionComponent): don't reset the editor selection when a blur event occurs #410 103 | 104 | ## 0.1.32 105 | 106 | ### Patch Changes 107 | 108 | - b609f78: fix(): allow to select the text when it ends with a mention 109 | 110 | ## 0.1.31 111 | 112 | ### Patch Changes 113 | 114 | - bf6d8f0: feat(useBeautifulMentions): allow to insert mentions with data 115 | 116 | ## 0.1.30 117 | 118 | ### Patch Changes 119 | 120 | - 7e1a1ea: refactor(TypeaheadMenuPlugin): use TypeaheadMenuPlugin from `@lexical/react`. ⚠️ Initially, this project has used a copy of `LexicalTypeaheadMenuPlugin` from `@lexical/react` with a few adjustments regarding the positioning of the menu. This is no longer needed as the positioning issues has been fixed. Now, we can use the original `LexicalTypeaheadMenuPlugin` from `@lexical/react`. If the menu is too far below the caret, this is probably due to the absolute positioning (top: x). You can remove the "top" property, as the menu opens directly under the caret now. 121 | - e4f29c1: feat(EmptyComponent): ability to render a custom component if no results are found 122 | 123 | ## 0.1.29 124 | 125 | ### Patch Changes 126 | 127 | - b2e5c65: fix(createBeautifulMentionNode): add missing return type for custom mention node 128 | - d03f567: fix($convertToMentionNodes): triggers followed by text in the middle of a word should be recognized as mentions 129 | 130 | ## 0.1.28 131 | 132 | ### Patch Changes 133 | 134 | - 413fcbf: fix(): set missing return type of BeautifulMentionNode methods 135 | 136 | ## 0.1.27 137 | 138 | ### Patch Changes 139 | 140 | - cb6a655: fix(): onBlur behavior in MentionComponent 141 | 142 | ## 0.1.26 143 | 144 | ### Patch Changes 145 | 146 | - ab5971d: fix(): insert a space (if necessary) when pasting text 147 | - 18c2bb5: feat(): mentions adopt the selected state when the text section is selected 148 | 149 | ## 0.1.25 150 | 151 | ### Patch Changes 152 | 153 | - 3f97fa8: fix(): remove the copy-and-paste handling from the plugin and suggest to use the lexical RichTextPlugin instead 154 | 155 | ## 0.1.24 156 | 157 | ### Patch Changes 158 | 159 | - 2487452: fix(Menu): prevent other key enter commands from blocking the menu from closing 160 | 161 | ## 0.1.23 162 | 163 | ### Patch Changes 164 | 165 | - cc96095: fix(BeautifulMentionNode + ZeroWidthNode): fix HTML/JSON node serialization & deserialization 166 | 167 | ## 0.1.22 168 | 169 | ### Patch Changes 170 | 171 | - 17b8bd5: feat(): copy value of selected mention to clipboard 172 | - 2388ee5: fix(ZeroWidthPlugin): 🚨 to avoid that the trigger character is inserted at the wrong position when using `openMentionMenu` (lexical >0.12.4), you need to set the new `textContent` prop of the ZeroWidthPlugin to a non-empty string. Don`t forget to remove the non-empty string characters before saving the content to your database. 173 | - 17b8bd5: feat(): paste mentions from clipboard into editor 174 | 175 | ## 0.1.21 176 | 177 | ### Patch Changes 178 | 179 | - f60adca: fix(createBeautifulMentionNode): prevent re-creating the BeautifulMentionNode class on every call 180 | 181 | ## 0.1.20 182 | 183 | ### Patch Changes 184 | 185 | - 662e895: feat(): add null type to BeautifulMentionsItem data fields 186 | 187 | ## 0.1.19 188 | 189 | ### Patch Changes 190 | 191 | - 215e1ed: feat(Menu): add callback fired when the user selects a menu item 192 | - 7391fb0: fix(Menu): remove typeahead element from DOM after menu is closed 193 | 194 | ## 0.1.18 195 | 196 | ### Patch Changes 197 | 198 | - 50d0ae0: feat(BeautifulMentionComponentProps): data prop with generic type 199 | - 7e14758: feat(): drop check for slash 200 | 201 | ## 0.1.17 202 | 203 | ### Patch Changes 204 | 205 | - 31c36f2: fix(): adjust query value after selecting an option (#177) [thanks, matusca96] 206 | - 9bc9674: docs(): remove animation from Menu component 207 | 208 | ## 0.1.16 209 | 210 | ### Patch Changes 211 | 212 | - ea87bf0: fix(): close menu when editor focus is lost 213 | 214 | ## 0.1.15 215 | 216 | ### Patch Changes 217 | 218 | - 164beac: feat(): add support for custom mention nodes 219 | - a8da266: feat(): add option to enable/disable that the current mentions are displayed as suggestions 220 | - c473eab: feat(): add 'data' field to getMentions function 221 | - 1d2f712: fix(): allows inserting, deleting and renaming mentions when the editor was not previously focused 222 | 223 | ## 0.1.14 224 | 225 | ### Patch Changes 226 | 227 | - 2ff69af: fix(): do not close combobox when editor focus is lost 228 | - 2ff69af: feat(Combobox): allow to add additional combobox items 229 | - 4ce0123: - refactor(): remove `open` prop from Menu component 230 | 231 | - refactor(): called `onComboboxFocusChange` with a `BeautifulMentionsComboboxItem` instead of a string 232 | 233 | **BREAKING** 234 | 235 | - `open` prop has been removed from `BeautifulMentionsMenuProps` 236 | - `onComboboxFocusChange` now receives a `BeautifulMentionsComboboxItem` instead of a string 237 | 238 | - 2ff69af: feat(Combobox): control the combobox open state 239 | 240 | ## 0.1.13 241 | 242 | ### Patch Changes 243 | 244 | - 5345e5a: feat(): provide the value of the MenuItem/ComboboxItem as prop 245 | - 9874434: fix(): conditionally useEffect on the server and useLayoutEffect in the browser 246 | 247 | ## 0.1.12 248 | 249 | ### Patch Changes 250 | 251 | - 5760567: feat(): more reliable information about the state of the menu / combobox 252 | 253 | **BREAKING**: 254 | 255 | - Renamed `openMentionsMenu` to `openMentionMenu` 256 | - Removed the `isMentionsMenuOpen` and `isTriggersMenuOpen` functions from the `useBeautifulMentions` hook in favor of the new `onMenuOpen`, `onMenuClose`, `onComboboxOpen`, `onComboboxClose` and `onComboboxItemSelect` props of the `BeautifulMentionsPlugin` component. This leads to a more reliable information about the state of the menu / combobox, since it is no longer determined by DOM elements. 257 | 258 | - ae9dd61: feat(): improve mention selection for mobile usage 259 | 260 | ## 0.1.11 261 | 262 | ### Patch Changes 263 | 264 | - c5f0035: fix(): allows undo of mentions 265 | - 4f37da5: feat(Combobox): improve active selection handling 266 | - ac5679c: feat(): allow comboboxAnchor prop to be nullable 267 | 268 | ## 0.1.10 269 | 270 | ### Patch Changes 271 | 272 | - 63c72e5: feat(): add additional metadata to mentions 273 | - f425ce5: fix(Combobox): keep trigger selection when backspace key is pressed 274 | - 54287e9: refactor(): add prefix to convertToMentionNodes function to indicate the Lexical scope. **BREAKING**: rename `convertToMentionNodes` to `$convertToMentionNodes`. 275 | - 839502c: refactor(): combobox positioning 276 | 277 | ## 0.1.9 278 | 279 | ### Patch Changes 280 | 281 | - 7e1eb6d: feat(): add combobox as alternative to typeahead menu. **BREAKING**: the `showTriggers` prop has been removed as the combobox shows all available triggers by default. 282 | - 0f55446: feat(): allow mentions with spaces to enclose with custom-defined characters 283 | - 0f55446: feat(): allow to define custom punctuation when looking for mentions 284 | - 10f2c1e: fix(): trigger menu should not re-position when typing 285 | 286 | ## 0.1.8 287 | 288 | ### Patch Changes 289 | 290 | - c6072bf: fix(): trigger menu should not open before or after other nodes 291 | 292 | ## 0.1.7 293 | 294 | ### Patch Changes 295 | 296 | - 401421d: fix(): focused mentions should not use the CSS classes defined in "container" 297 | 298 | ## 0.1.6 299 | 300 | ### Patch Changes 301 | 302 | - fe4e9bd: feat(): show available triggers while typing 303 | - 49de188: style(): allow styling of mention container when trigger and value have separate styling 304 | 305 | ## 0.1.5 306 | 307 | ### Patch Changes 308 | 309 | - 6d5eb6e: feat(): add option to limit suggested mentions 310 | - 6d5eb6e: feat(): provide a function that tells if the triggers menu is currently open 311 | 312 | ## 0.1.4 313 | 314 | ### Patch Changes 315 | 316 | - d5e52fd: fix(): prevent the selection from being lost after a focused mention has been deleted 317 | - 6c4d983: feat(): show all available triggers via a configurable shortcut 318 | - 29b9749: style(): separate styles for trigger and value 319 | - 8a769aa: feat(): show mentions menu when mention is deleted 320 | 321 | ## 0.1.3 322 | 323 | ### Patch Changes 324 | 325 | - bb9d4c7: feat(): add label to BeautifulMentionsMenuItemProps 326 | 327 | ## 0.1.2 328 | 329 | ### Patch Changes 330 | 331 | - faae1b3: fix(): remove trailing spaces when removing a mention 332 | - b4b2a01: fix(): make sure that the focus is removed when clicking next to the mention 333 | 334 | ## 0.1.1 335 | 336 | ### Patch Changes 337 | 338 | - a9b1aef: fix(): handle missing focus when inserting, renaming or deleting mentions 339 | 340 | ## 0.1.0 341 | 342 | ### Minor Changes 343 | 344 | - 903be59: feat(): make it configurable if the editor should be focused after inserting, renaming or deleting mentions 345 | 346 | ## 0.0.5 347 | 348 | ### Patch Changes 349 | 350 | - 2400fc2: fix(): prevent flickering of the menu when a search function is provided 351 | - 840f7de: fix(): show existing mentions from the editor as suggestions 352 | 353 | ## 0.0.4 354 | 355 | ### Patch Changes 356 | 357 | - e13cd0a: fix(useBeautifulMentions): hook function should return `true` when the mention menu is open 358 | 359 | ## 0.0.3 360 | 361 | ### Patch Changes 362 | 363 | - 132fb5c: fix(Menu): use menu + menuitem role instead of list + listitem 364 | 365 | ## 0.0.2 366 | 367 | ### Patch Changes 368 | 369 | - f40bfe0: Initial version 370 | -------------------------------------------------------------------------------- /plugin/copy-files.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { 3 | promises: { access, readFile, writeFile }, 4 | } = require("fs"); 5 | const glob = require("fast-glob"); 6 | 7 | async function exists(path) { 8 | return access(path) 9 | .then(() => true) 10 | .catch(() => false); 11 | } 12 | 13 | const packagePath = process.cwd(); 14 | const rootPath = path.join(packagePath, ".."); 15 | const libPath = path.join(packagePath, "lib"); 16 | const srcPath = path.join(packagePath, "src"); 17 | 18 | /** 19 | * Puts a package.json into every immediate child directory of rootDir. 20 | * That package.json contains information about esm for bundlers. 21 | * 22 | * It also tests that this import can be used in TypeScript by checking 23 | * if an index.d.ts is present at that path. 24 | */ 25 | async function createModulePackages({ from, to }) { 26 | const directoryPackages = glob 27 | .sync("*/index.{js,ts,tsx}", { cwd: from }) 28 | .map(path.dirname); 29 | 30 | await Promise.all( 31 | directoryPackages.map(async (directoryPackage) => { 32 | const packageJsonPath = path.join(to, directoryPackage, "package.json"); 33 | const topLevelPathImportsAreEcmaScriptModules = await exists( 34 | path.resolve(path.dirname(packageJsonPath), "../cjs") 35 | ); 36 | 37 | const packageJsonExports = { 38 | types: "./index.d.ts", 39 | import: "./index.js", 40 | require: topLevelPathImportsAreEcmaScriptModules 41 | ? path.posix.join("../cjs", directoryPackage, "index.js") 42 | : "./index.js", 43 | }; 44 | 45 | const [typingsEntryExist, moduleEntryExists, mainEntryExists] = 46 | await Promise.all([ 47 | exists( 48 | path.resolve(path.dirname(packageJsonPath), packageJsonExports.types) 49 | ), 50 | exists( 51 | path.resolve(path.dirname(packageJsonPath), packageJsonExports.import) 52 | ), 53 | exists(path.resolve(path.dirname(packageJsonPath), packageJsonExports.require)), 54 | writeFile(packageJsonPath, JSON.stringify(packageJsonExports, null, 2)), 55 | ]); 56 | 57 | const manifestErrorMessages = []; 58 | if (!typingsEntryExist) { 59 | manifestErrorMessages.push( 60 | `'types' entry '${packageJsonExports.types}' does not exist` 61 | ); 62 | } 63 | if (!moduleEntryExists) { 64 | manifestErrorMessages.push( 65 | `'module' entry '${packageJsonExports.import}' does not exist` 66 | ); 67 | } 68 | if (!mainEntryExists) { 69 | manifestErrorMessages.push( 70 | `'main' entry '${packageJsonExports.require}' does not exist` 71 | ); 72 | } 73 | if (manifestErrorMessages.length > 0) { 74 | throw new Error( 75 | `${packageJsonPath}:\n${manifestErrorMessages.join("\n")}` 76 | ); 77 | } 78 | 79 | return packageJsonPath; 80 | }) 81 | ); 82 | } 83 | 84 | async function createPackageFile() { 85 | const packageData = await readFile( 86 | path.resolve(packagePath, "./package.json"), 87 | "utf8" 88 | ); 89 | const { scripts, devDependencies, ...packageDataOther } = 90 | JSON.parse(packageData); 91 | 92 | const dependencies = packageDataOther.dependencies || {}; 93 | Object.keys(dependencies).forEach((pkgName) => { 94 | const pkgVersion = dependencies[pkgName]; 95 | if (pkgVersion === "workspace:*") { 96 | dependencies[pkgName] = packageDataOther.version; 97 | } 98 | }); 99 | 100 | const newPackageData = { 101 | ...packageDataOther, 102 | exports: { 103 | types: "./index.d.ts", 104 | import: "./index.js", 105 | require: "./cjs/index.js", 106 | }, 107 | }; 108 | 109 | const targetPath = path.resolve(libPath, "./package.json"); 110 | 111 | await writeFile(targetPath, JSON.stringify(newPackageData, null, 2), "utf8"); 112 | } 113 | 114 | async function addFiles() { 115 | await Promise.all( 116 | ["./LICENSE", "./README.md"].map(async (file) => { 117 | const data = await readFile(path.resolve(rootPath, file), "utf8"); 118 | return writeFile(path.resolve(libPath, file), data, "utf8"); 119 | }) 120 | ); 121 | } 122 | 123 | async function run() { 124 | try { 125 | await createPackageFile(); 126 | await addFiles(); 127 | await createModulePackages({ from: srcPath, to: libPath }); 128 | } catch (err) { 129 | console.error(err); 130 | process.exit(1); 131 | } 132 | } 133 | 134 | run(); 135 | -------------------------------------------------------------------------------- /plugin/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import eslint from "@eslint/js"; 3 | import eslintConfigPrettier from "eslint-config-prettier"; 4 | import reactPlugin from "eslint-plugin-react"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import tseslint from "typescript-eslint"; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | }); 14 | 15 | export default [ 16 | eslint.configs.recommended, 17 | ...tseslint.configs.strictTypeChecked, 18 | ...tseslint.configs.stylisticTypeChecked, 19 | { 20 | languageOptions: { 21 | parserOptions: { 22 | projectService: true, 23 | tsconfigRootDir: import.meta.dirname, 24 | }, 25 | }, 26 | }, 27 | { 28 | ignores: ["*.mjs", "*.js", "lib", "vitest.config.ts"], 29 | }, 30 | eslintConfigPrettier, 31 | { 32 | ...reactPlugin.configs.flat.recommended, 33 | settings: { react: { version: "18.3" } }, 34 | }, 35 | reactPlugin.configs.flat["jsx-runtime"], 36 | ...compat.extends("plugin:react-hooks/recommended"), 37 | { 38 | rules: { 39 | "@typescript-eslint/no-floating-promises": "off", 40 | "@typescript-eslint/restrict-plus-operands": "off", 41 | "@typescript-eslint/restrict-template-expressions": "off", 42 | "@typescript-eslint/unbound-method": "off", 43 | "@typescript-eslint/no-empty-object-type": "off", 44 | "@typescript-eslint/ban-ts-comment": "off", 45 | }, 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lexical-beautiful-mentions", 3 | "version": "0.1.47", 4 | "license": "MIT", 5 | "description": "A mentions plugin for the lexical text editor.", 6 | "main": "src/index.ts", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/sodenn/lexical-beautiful-mentions.git", 13 | "directory": "packages/lexical-beautiful-mentions" 14 | }, 15 | "keywords": [ 16 | "mentions", 17 | "react", 18 | "wysiwyg", 19 | "editor", 20 | "lexical", 21 | "plugin" 22 | ], 23 | "homepage": "https://github.com/sodenn/lexical-beautiful-mentions", 24 | "author": { 25 | "name": "sodenn", 26 | "email": "mail@sodenn.dev" 27 | }, 28 | "scripts": { 29 | "build": "run-s clean build:all copy", 30 | "build:all": "run-p build:esm build:cjs", 31 | "build:cjs": "tsc -p tsconfig.build.json --m commonjs --outDir lib/cjs -t es2015", 32 | "build:esm": "tsc -p tsconfig.build.json --m esnext --outDir lib -t es6", 33 | "clean": "rimraf lib", 34 | "copy": "node copy-files.js", 35 | "publish:lib": "node publish-lib.js", 36 | "release": "run-s build publish:lib", 37 | "test": "vitest --run", 38 | "lint": "eslint .", 39 | "typecheck": "tsc --noEmit" 40 | }, 41 | "devDependencies": { 42 | "@testing-library/react": "16.3.0", 43 | "fast-glob": "3.3.3", 44 | "happy-dom": "17.5.6", 45 | "npm-run-all2": "8.0.4", 46 | "rimraf": "6.0.1", 47 | "vite": "6.3.5", 48 | "vitest": "3.1.4" 49 | }, 50 | "peerDependencies": { 51 | "@lexical/react": ">=0.11.0", 52 | "@lexical/utils": ">=0.11.0", 53 | "lexical": ">=0.11.0", 54 | "react": ">=17.x", 55 | "react-dom": ">=17.x" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /plugin/publish-lib.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { execSync } = require("child_process"); 3 | 4 | const libPath = path.join(__dirname, "./lib"); 5 | 6 | execSync("npm publish", { cwd: libPath, stdio: "inherit" }); 7 | -------------------------------------------------------------------------------- /plugin/src/MentionComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection"; 3 | import { mergeRegister } from "@lexical/utils"; 4 | import { 5 | $getNodeByKey, 6 | $getSelection, 7 | $isDecoratorNode, 8 | $isElementNode, 9 | $isNodeSelection, 10 | $isTextNode, 11 | $setSelection, 12 | BLUR_COMMAND, 13 | CLICK_COMMAND, 14 | COMMAND_PRIORITY_LOW, 15 | KEY_ARROW_LEFT_COMMAND, 16 | KEY_ARROW_RIGHT_COMMAND, 17 | KEY_BACKSPACE_COMMAND, 18 | KEY_DELETE_COMMAND, 19 | NodeKey, 20 | SELECTION_CHANGE_COMMAND, 21 | } from "lexical"; 22 | import { ElementType, useCallback, useEffect, useMemo, useRef } from "react"; 23 | import { 24 | BeautifulMentionsItemData, 25 | BeautifulMentionComponentProps as CustomBeautifulMentionComponentProps, 26 | } from "./BeautifulMentionsPluginProps"; 27 | import { $isBeautifulMentionNode } from "./MentionNode"; 28 | import { IS_IOS } from "./environment"; 29 | import { getNextSibling, getPreviousSibling } from "./mention-utils"; 30 | import { BeautifulMentionsCssClassNames } from "./theme"; 31 | import { useIsFocused } from "./useIsFocused"; 32 | 33 | interface BeautifulMentionComponentProps { 34 | nodeKey: NodeKey; 35 | trigger: string; 36 | value: string; 37 | data?: Record; 38 | component?: ElementType | null; 39 | className?: string; 40 | classNameFocused?: string; 41 | classNames?: BeautifulMentionsCssClassNames; 42 | } 43 | 44 | export default function BeautifulMentionComponent( 45 | props: BeautifulMentionComponentProps, 46 | ) { 47 | const { 48 | value, 49 | trigger, 50 | data, 51 | className, 52 | classNameFocused, 53 | classNames, 54 | nodeKey, 55 | component: Component, 56 | } = props; 57 | const [editor] = useLexicalComposerContext(); 58 | const isEditorFocused = useIsFocused(); 59 | const [isSelected, setSelected, clearSelection] = 60 | useLexicalNodeSelection(nodeKey); 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | const ref = useRef(null); 63 | const mention = trigger + value; 64 | 65 | const composedClassNames = useMemo(() => { 66 | if (className) { 67 | const classes = [className]; 68 | if (isSelected && isEditorFocused && classNameFocused) { 69 | classes.push(classNameFocused); 70 | } 71 | return classes.join(" ").trim() || undefined; 72 | } 73 | return ""; 74 | }, [isSelected, className, classNameFocused, isEditorFocused]); 75 | 76 | const onDelete = useCallback( 77 | (payload: KeyboardEvent) => { 78 | if (isSelected && $isNodeSelection($getSelection())) { 79 | payload.preventDefault(); 80 | const node = $getNodeByKey(nodeKey); 81 | if ($isBeautifulMentionNode(node)) { 82 | node.remove(); 83 | } 84 | } 85 | return false; 86 | }, 87 | [isSelected, nodeKey], 88 | ); 89 | 90 | const onArrowLeftPress = useCallback( 91 | (event: KeyboardEvent) => { 92 | const node = $getNodeByKey(nodeKey); 93 | if (!node?.isSelected()) { 94 | return false; 95 | } 96 | let handled = false; 97 | const nodeToSelect = getPreviousSibling(node); 98 | if ($isElementNode(nodeToSelect)) { 99 | nodeToSelect.selectEnd(); 100 | handled = true; 101 | } 102 | if ($isTextNode(nodeToSelect)) { 103 | nodeToSelect.select(); 104 | handled = true; 105 | } 106 | if ($isDecoratorNode(nodeToSelect)) { 107 | nodeToSelect.selectNext(); 108 | handled = true; 109 | } 110 | if (nodeToSelect === null) { 111 | node.selectPrevious(); 112 | handled = true; 113 | } 114 | if (handled) { 115 | event.preventDefault(); 116 | } 117 | return handled; 118 | }, 119 | [nodeKey], 120 | ); 121 | 122 | const onArrowRightPress = useCallback( 123 | (event: KeyboardEvent) => { 124 | const node = $getNodeByKey(nodeKey); 125 | if (!node?.isSelected()) { 126 | return false; 127 | } 128 | let handled = false; 129 | const nodeToSelect = getNextSibling(node); 130 | if ($isElementNode(nodeToSelect)) { 131 | nodeToSelect.selectStart(); 132 | handled = true; 133 | } 134 | if ($isTextNode(nodeToSelect)) { 135 | nodeToSelect.select(0, 0); 136 | handled = true; 137 | } 138 | if ($isDecoratorNode(nodeToSelect)) { 139 | nodeToSelect.selectPrevious(); 140 | handled = true; 141 | } 142 | if (nodeToSelect === null) { 143 | node.selectNext(); 144 | handled = true; 145 | } 146 | if (handled) { 147 | event.preventDefault(); 148 | } 149 | return handled; 150 | }, 151 | [nodeKey], 152 | ); 153 | 154 | const onClick = useCallback( 155 | (event: MouseEvent) => { 156 | if ( 157 | event.target === ref.current || 158 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call 159 | ref.current?.contains(event.target as Node) 160 | ) { 161 | if (!event.shiftKey) { 162 | clearSelection(); 163 | } 164 | setSelected(true); 165 | return true; 166 | } 167 | return false; 168 | }, 169 | [clearSelection, setSelected], 170 | ); 171 | 172 | const onBlur = useCallback(() => { 173 | const node = $getNodeByKey(nodeKey); 174 | if (!node?.isSelected()) { 175 | return false; 176 | } 177 | 178 | const selection = $getSelection(); 179 | if (!$isNodeSelection(selection)) { 180 | return false; 181 | } 182 | 183 | $setSelection(null); 184 | return false; 185 | }, [nodeKey]); 186 | 187 | const onSelectionChange = useCallback(() => { 188 | if (IS_IOS && isSelected) { 189 | // needed to keep the cursor in the editor when clicking next to a selected mention 190 | setSelected(false); 191 | return true; 192 | } 193 | return false; 194 | }, [isSelected, setSelected]); 195 | 196 | useEffect(() => { 197 | const unregister = mergeRegister( 198 | editor.registerCommand( 199 | CLICK_COMMAND, 200 | onClick, 201 | COMMAND_PRIORITY_LOW, 202 | ), 203 | editor.registerCommand( 204 | KEY_DELETE_COMMAND, 205 | onDelete, 206 | COMMAND_PRIORITY_LOW, 207 | ), 208 | editor.registerCommand( 209 | KEY_BACKSPACE_COMMAND, 210 | onDelete, 211 | COMMAND_PRIORITY_LOW, 212 | ), 213 | editor.registerCommand( 214 | KEY_ARROW_LEFT_COMMAND, 215 | onArrowLeftPress, 216 | COMMAND_PRIORITY_LOW, 217 | ), 218 | editor.registerCommand( 219 | KEY_ARROW_RIGHT_COMMAND, 220 | onArrowRightPress, 221 | COMMAND_PRIORITY_LOW, 222 | ), 223 | editor.registerCommand(BLUR_COMMAND, onBlur, COMMAND_PRIORITY_LOW), 224 | editor.registerCommand( 225 | SELECTION_CHANGE_COMMAND, 226 | onSelectionChange, 227 | COMMAND_PRIORITY_LOW, 228 | ), 229 | ); 230 | return () => { 231 | unregister(); 232 | }; 233 | }, [ 234 | editor, 235 | onArrowLeftPress, 236 | onArrowRightPress, 237 | onClick, 238 | onDelete, 239 | onBlur, 240 | onSelectionChange, 241 | ]); 242 | 243 | if (Component) { 244 | return ( 245 | 254 | {mention} 255 | 256 | ); 257 | } 258 | 259 | if (classNames) { 260 | return ( 261 | 270 | {trigger} 271 | {value} 272 | 273 | ); 274 | } 275 | 276 | return ( 277 | 282 | {mention} 283 | 284 | ); 285 | } 286 | -------------------------------------------------------------------------------- /plugin/src/MentionNode.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreateEditorArgs, createEditor } from "lexical"; 2 | import { describe, expect, test } from "vitest"; 3 | import { BeautifulMentionsItemData } from "./BeautifulMentionsPluginProps"; 4 | import { 5 | $createBeautifulMentionNode, 6 | BeautifulMentionNode, 7 | } from "./MentionNode"; 8 | 9 | const editorConfig: CreateEditorArgs = { 10 | nodes: [BeautifulMentionNode], 11 | }; 12 | 13 | export function exportJSON( 14 | trigger: string, 15 | value: string, 16 | data?: Record, 17 | ) { 18 | let node: BeautifulMentionNode | undefined = undefined; 19 | const editor = createEditor(editorConfig); 20 | editor.update(() => { 21 | node = $createBeautifulMentionNode(trigger, value, data); 22 | }); 23 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 24 | if (!node) { 25 | throw new Error("Node is undefined"); 26 | } 27 | return (node as BeautifulMentionNode).exportJSON(); 28 | } 29 | 30 | describe("BeautifulMentionNode", () => { 31 | test("should include a data prop when exporting to JSON and data is provided when creating the node", () => { 32 | const node = exportJSON("@", "Jane", { 33 | email: "jane@example.com", 34 | }); 35 | expect(node).toStrictEqual({ 36 | trigger: "@", 37 | type: "beautifulMention", 38 | value: "Jane", 39 | data: { 40 | email: "jane@example.com", 41 | }, 42 | version: 1, 43 | }); 44 | }); 45 | 46 | test("should not include a data prop when exporting to JSON if no data is provided when creating the node", () => { 47 | const node = exportJSON("@", "Jane"); 48 | expect(node).toStrictEqual({ 49 | trigger: "@", 50 | type: "beautifulMention", 51 | value: "Jane", 52 | version: 1, 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /plugin/src/MentionNode.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | $applyNodeReplacement, 3 | DOMConversionMap, 4 | DOMExportOutput, 5 | DecoratorNode, 6 | LexicalEditor, 7 | SerializedLexicalNode, 8 | Spread, 9 | type DOMConversionOutput, 10 | type EditorConfig, 11 | type LexicalNode, 12 | type NodeKey, 13 | } from "lexical"; 14 | import React, { ElementType } from "react"; 15 | import { 16 | BeautifulMentionComponentProps, 17 | BeautifulMentionsItemData, 18 | } from "./BeautifulMentionsPluginProps"; 19 | import MentionComponent from "./MentionComponent"; 20 | import { BeautifulMentionsTheme } from "./theme"; 21 | 22 | export type SerializedBeautifulMentionNode = Spread< 23 | { 24 | trigger: string; 25 | value: string; 26 | data?: Record; 27 | }, 28 | SerializedLexicalNode 29 | >; 30 | 31 | function convertElement(domNode: HTMLElement): DOMConversionOutput | null { 32 | const trigger = domNode.getAttribute( 33 | "data-lexical-beautiful-mention-trigger", 34 | ); 35 | const value = domNode.getAttribute("data-lexical-beautiful-mention-value"); 36 | let data: Record | undefined = undefined; 37 | const dataStr = domNode.getAttribute("data-lexical-beautiful-mention-data"); 38 | if (dataStr) { 39 | try { 40 | data = JSON.parse(dataStr) as Record; 41 | } catch (e) { 42 | console.warn( 43 | "Failed to parse data attribute of beautiful mention node", 44 | e, 45 | ); 46 | } 47 | } 48 | if (trigger != null && value !== null) { 49 | const node = $createBeautifulMentionNode(trigger, value, data); 50 | return { node }; 51 | } 52 | return null; 53 | } 54 | 55 | /** 56 | * This node is used to represent a mention used in the BeautifulMentionPlugin. 57 | */ 58 | export class BeautifulMentionNode extends DecoratorNode { 59 | __trigger: string; 60 | __value: string; 61 | __data?: Record; 62 | 63 | static getType(): string { 64 | return "beautifulMention"; 65 | } 66 | 67 | static clone(node: BeautifulMentionNode): BeautifulMentionNode { 68 | return new BeautifulMentionNode( 69 | node.__trigger, 70 | node.__value, 71 | node.__data, 72 | node.__key, 73 | ); 74 | } 75 | 76 | constructor( 77 | trigger: string, 78 | value: string, 79 | data?: Record, 80 | key?: NodeKey, 81 | ) { 82 | super(key); 83 | this.__trigger = trigger; 84 | this.__value = value; 85 | this.__data = data; 86 | } 87 | 88 | createDOM(): HTMLElement { 89 | return document.createElement("span"); 90 | } 91 | 92 | updateDOM(): boolean { 93 | return false; 94 | } 95 | 96 | exportDOM(): DOMExportOutput { 97 | const element = document.createElement("span"); 98 | element.setAttribute("data-lexical-beautiful-mention", "true"); 99 | element.setAttribute( 100 | "data-lexical-beautiful-mention-trigger", 101 | this.__trigger, 102 | ); 103 | element.setAttribute("data-lexical-beautiful-mention-value", this.__value); 104 | if (this.__data) { 105 | element.setAttribute( 106 | "data-lexical-beautiful-mention-data", 107 | JSON.stringify(this.__data), 108 | ); 109 | } 110 | element.textContent = this.getTextContent(); 111 | return { element }; 112 | } 113 | 114 | static importDOM(): DOMConversionMap | null { 115 | return { 116 | span: (domNode: HTMLElement) => { 117 | if (!domNode.hasAttribute("data-lexical-beautiful-mention")) { 118 | return null; 119 | } 120 | return { 121 | conversion: convertElement, 122 | priority: 1, 123 | }; 124 | }, 125 | }; 126 | } 127 | 128 | static importJSON( 129 | serializedNode: SerializedBeautifulMentionNode, 130 | ): BeautifulMentionNode { 131 | return $createBeautifulMentionNode( 132 | serializedNode.trigger, 133 | serializedNode.value, 134 | serializedNode.data, 135 | ); 136 | } 137 | 138 | exportJSON(): SerializedBeautifulMentionNode { 139 | const data = this.__data; 140 | return { 141 | trigger: this.__trigger, 142 | value: this.__value, 143 | ...(data ? { data } : {}), 144 | type: "beautifulMention", 145 | version: 1, 146 | }; 147 | } 148 | 149 | getTextContent(): string { 150 | const self = this.getLatest(); 151 | return self.__trigger + self.__value; 152 | } 153 | 154 | getTrigger(): string { 155 | const self = this.getLatest(); 156 | return self.__trigger; 157 | } 158 | 159 | getValue(): string { 160 | const self = this.getLatest(); 161 | return self.__value; 162 | } 163 | 164 | setValue(value: string) { 165 | const self = this.getWritable(); 166 | self.__value = value; 167 | } 168 | 169 | getData(): Record | undefined { 170 | const self = this.getLatest(); 171 | return self.__data; 172 | } 173 | 174 | setData(data?: Record) { 175 | const self = this.getWritable(); 176 | self.__data = data; 177 | } 178 | 179 | component(): ElementType | null { 180 | return null; 181 | } 182 | 183 | decorate(_editor: LexicalEditor, config: EditorConfig): React.JSX.Element { 184 | const { className, classNameFocused, classNames } = 185 | this.getCssClassesFromTheme(config); 186 | return ( 187 | 197 | ); 198 | } 199 | 200 | getCssClassesFromTheme(config: EditorConfig) { 201 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 202 | const theme: BeautifulMentionsTheme = config.theme.beautifulMentions ?? {}; 203 | const themeEntry = Object.entries(theme).find(([trigger]) => 204 | new RegExp(trigger).test(this.__trigger), 205 | ); 206 | const key = themeEntry?.[0] ?? ""; 207 | const value = themeEntry?.[1]; 208 | const className = typeof value === "string" ? value : undefined; 209 | const classNameFocused = 210 | className && typeof theme[key + "Focused"] === "string" 211 | ? (theme[key + "Focused"] as string) 212 | : undefined; 213 | const classNames = 214 | themeEntry && typeof value !== "string" ? value : undefined; 215 | return { 216 | className, 217 | classNameFocused, 218 | classNames, 219 | }; 220 | } 221 | } 222 | 223 | export function $createBeautifulMentionNode( 224 | trigger: string, 225 | value: string, 226 | data?: Record, 227 | ): BeautifulMentionNode { 228 | const mentionNode = new BeautifulMentionNode(trigger, value, data); 229 | return $applyNodeReplacement(mentionNode); 230 | } 231 | 232 | export function $isBeautifulMentionNode( 233 | node: LexicalNode | null | undefined, 234 | ): node is BeautifulMentionNode { 235 | return node instanceof BeautifulMentionNode; 236 | } 237 | -------------------------------------------------------------------------------- /plugin/src/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { MenuTextMatch } from "@lexical/react/LexicalTypeaheadMenuPlugin"; 2 | import { $getSelection, $isRangeSelection, TextNode } from "lexical"; 3 | import { RefObject } from "react"; 4 | import { BeautifulMentionsItemData } from "./BeautifulMentionsPluginProps"; 5 | import { getTextContent } from "./mention-utils"; 6 | 7 | export class MenuOption { 8 | /** 9 | * Unique key to iterate over options. Equals to `data` if provided, otherwise 10 | * `value` is used. 11 | */ 12 | readonly key: string; 13 | /** 14 | * Ref to the DOM element of the option. 15 | */ 16 | ref?: RefObject; 17 | 18 | constructor( 19 | /** 20 | * The menu item value. For example: "John". 21 | */ 22 | public readonly value: string, 23 | /** 24 | * The value to be displayed. Normally the same as `value` but can be 25 | * used to display a different value. For example: "Add 'John'". 26 | */ 27 | public readonly displayValue: string, 28 | /** 29 | * Additional data belonging to the option. For example: `{ id: 1 }`. 30 | */ 31 | public readonly data?: Record, 32 | ) { 33 | this.key = !data ? value : JSON.stringify({ ...data, value }); 34 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 35 | this.displayValue = displayValue ?? value; 36 | this.ref = { current: null }; 37 | this.setRefElement = this.setRefElement.bind(this); 38 | } 39 | 40 | setRefElement(element: HTMLElement | null) { 41 | this.ref = { current: element }; 42 | } 43 | } 44 | 45 | /** 46 | * Split Lexical TextNode and return a new TextNode only containing matched text. 47 | * Common use cases include: removing the node, replacing with a new node. 48 | */ 49 | export function $splitNodeContainingQuery( 50 | match: MenuTextMatch, 51 | ): TextNode | null { 52 | const selection = $getSelection(); 53 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { 54 | return null; 55 | } 56 | const anchor = selection.anchor; 57 | if (anchor.type !== "text") { 58 | return null; 59 | } 60 | const anchorNode = anchor.getNode(); 61 | if (!anchorNode.isSimpleText()) { 62 | return null; 63 | } 64 | const selectionOffset = anchor.offset; 65 | const textContent = getTextContent(anchorNode).slice(0, selectionOffset); 66 | const characterOffset = match.replaceableString.length; 67 | const queryOffset = getFullMatchOffset( 68 | textContent, 69 | match.matchingString, 70 | characterOffset, 71 | ); 72 | const startOffset = selectionOffset - queryOffset; 73 | if (startOffset < 0) { 74 | return null; 75 | } 76 | let newNode; 77 | if (startOffset === 0) { 78 | [newNode] = anchorNode.splitText(selectionOffset); 79 | } else { 80 | [, newNode] = anchorNode.splitText(startOffset, selectionOffset); 81 | } 82 | return newNode; 83 | } 84 | 85 | /** 86 | * Walk backwards along user input and forward through entity title to try 87 | * and replace more of the user's text with entity. 88 | */ 89 | function getFullMatchOffset( 90 | documentText: string, 91 | entryText: string, 92 | offset: number, 93 | ): number { 94 | let triggerOffset = offset; 95 | for (let i = triggerOffset; i <= entryText.length; i++) { 96 | if (documentText.substring(-i) === entryText.substring(0, i)) { 97 | triggerOffset = i; 98 | } 99 | } 100 | return triggerOffset; 101 | } 102 | -------------------------------------------------------------------------------- /plugin/src/PlaceholderNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $applyNodeReplacement, 3 | DOMConversionMap, 4 | EditorConfig, 5 | ElementNode, 6 | SerializedElementNode, 7 | type LexicalNode, 8 | type NodeKey, 9 | } from "lexical"; 10 | 11 | export type SerializedPlaceholderNode = SerializedElementNode; 12 | 13 | /* eslint @typescript-eslint/no-unused-vars: "off" */ 14 | export class PlaceholderNode extends ElementNode { 15 | static getType(): string { 16 | return "placeholder"; 17 | } 18 | 19 | static clone(node: PlaceholderNode): PlaceholderNode { 20 | return new PlaceholderNode(node.__textContent, node.__key); 21 | } 22 | 23 | constructor( 24 | private __textContent: string, 25 | key?: NodeKey, 26 | ) { 27 | super(key); 28 | } 29 | 30 | createDOM(_: EditorConfig): HTMLImageElement { 31 | const element = document.createElement("img"); 32 | element.style.display = "inline"; 33 | element.style.border = "none"; 34 | element.style.margin = "0"; 35 | element.style.height = "1px"; 36 | element.style.width = "1px"; 37 | return element; 38 | } 39 | 40 | updateDOM(): boolean { 41 | return false; 42 | } 43 | 44 | static importDOM(): DOMConversionMap | null { 45 | return null; 46 | } 47 | 48 | static importJSON(_: SerializedPlaceholderNode): PlaceholderNode { 49 | return $createPlaceholderNode(); 50 | } 51 | 52 | isInline(): boolean { 53 | return true; 54 | } 55 | 56 | exportJSON(): SerializedPlaceholderNode { 57 | return { 58 | ...super.exportJSON(), 59 | type: "placeholder", 60 | }; 61 | } 62 | 63 | getTextContent(): string { 64 | return ""; 65 | } 66 | } 67 | 68 | export function $createPlaceholderNode(textContent = ""): PlaceholderNode { 69 | const placeholderNode = new PlaceholderNode(textContent); 70 | return $applyNodeReplacement(placeholderNode); 71 | } 72 | 73 | export function $isPlaceholderNode( 74 | node: LexicalNode | null | undefined, 75 | ): node is PlaceholderNode { 76 | return node instanceof PlaceholderNode; 77 | } 78 | -------------------------------------------------------------------------------- /plugin/src/PlaceholderPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { mergeRegister } from "@lexical/utils"; 3 | import { 4 | $getSelection, 5 | $isDecoratorNode, 6 | $isRangeSelection, 7 | $nodesOfType, 8 | COMMAND_PRIORITY_HIGH, 9 | KEY_DOWN_COMMAND, 10 | ParagraphNode, 11 | SELECTION_CHANGE_COMMAND, 12 | } from "lexical"; 13 | import { useEffect } from "react"; 14 | import { 15 | $createPlaceholderNode, 16 | $isPlaceholderNode, 17 | PlaceholderNode, 18 | } from "./PlaceholderNode"; 19 | 20 | /** 21 | * This plugin serves as a patch to fix an incorrect cursor position in Safari. 22 | * {@link https://github.com/facebook/lexical/issues/4487}. 23 | * @deprecated This plugin is no longer needed and will be removed in a future release. 24 | */ 25 | export function PlaceholderPlugin() { 26 | const [editor] = useLexicalComposerContext(); 27 | 28 | useEffect(() => { 29 | if (!editor.hasNodes([PlaceholderNode])) { 30 | throw new Error( 31 | "BeautifulMentionsPlugin: PlaceholderNode not registered on editor", 32 | ); 33 | } 34 | return mergeRegister( 35 | editor.registerUpdateListener(() => { 36 | editor.update( 37 | () => { 38 | // insert a placeholder node at the end of each paragraph if the 39 | // last node is a decorator node. 40 | // eslint-disable-next-line @typescript-eslint/no-deprecated 41 | const placeholderNodes = $nodesOfType(PlaceholderNode); 42 | // eslint-disable-next-line @typescript-eslint/no-deprecated 43 | $nodesOfType(ParagraphNode).forEach((paragraph) => { 44 | const paragraphPlaceholders = placeholderNodes.filter((p) => 45 | paragraph.isParentOf(p), 46 | ); 47 | const lastNode = paragraph.getLastDescendant(); 48 | if ($isDecoratorNode(lastNode)) { 49 | paragraphPlaceholders.forEach((p) => { 50 | p.remove(); 51 | }); 52 | lastNode.insertAfter($createPlaceholderNode()); 53 | } else if ( 54 | $isPlaceholderNode(lastNode) && 55 | !$isDecoratorNode(lastNode.getPreviousSibling()) 56 | ) { 57 | paragraphPlaceholders.forEach((p) => { 58 | p.remove(); 59 | }); 60 | } 61 | }); 62 | }, 63 | // merge with previous history entry to allow undoing 64 | { tag: "history-merge" }, 65 | ); 66 | }), 67 | editor.registerCommand( 68 | KEY_DOWN_COMMAND, 69 | (event) => { 70 | // prevent unnecessary removal of the placeholder nodes, since this 71 | // would lead to insertion of another placeholder node and thus break 72 | // undo with Ctrl+z 73 | if ( 74 | event.ctrlKey || 75 | event.metaKey || 76 | event.altKey || 77 | event.key === "Shift" 78 | ) { 79 | return false; 80 | } 81 | // if the user starts typing at the placeholder's position, remove 82 | // the placeholder node. this makes the PlaceholderNode almost 83 | // "invisible" and prevents issues when, for example, when checking 84 | // for previous nodes in the code. 85 | const selection = $getSelection(); 86 | if ($isRangeSelection(selection)) { 87 | const [node] = selection.getNodes(); 88 | if ($isPlaceholderNode(node)) { 89 | node.remove(); 90 | } 91 | } 92 | return false; 93 | }, 94 | COMMAND_PRIORITY_HIGH, 95 | ), 96 | editor.registerCommand( 97 | SELECTION_CHANGE_COMMAND, 98 | () => { 99 | // select the previous node to avoid an error that occurs when the 100 | // user tries to insert a node directly after the PlaceholderNode 101 | const selection = $getSelection(); 102 | if ($isRangeSelection(selection) && selection.isCollapsed()) { 103 | const [node] = selection.getNodes(); 104 | if ($isPlaceholderNode(node)) { 105 | node.selectPrevious(); 106 | } 107 | } 108 | return false; 109 | }, 110 | COMMAND_PRIORITY_HIGH, 111 | ), 112 | ); 113 | }, [editor]); 114 | 115 | return null; 116 | } 117 | -------------------------------------------------------------------------------- /plugin/src/ZeroWidthNode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $applyNodeReplacement, 3 | DOMConversionMap, 4 | DOMConversionOutput, 5 | DOMExportOutput, 6 | LexicalEditor, 7 | TextNode, 8 | type LexicalNode, 9 | type NodeKey, 10 | type SerializedTextNode, 11 | } from "lexical"; 12 | import { ZERO_WIDTH_CHARACTER } from "./ZeroWidthPlugin"; 13 | 14 | export type SerializedZeroWidthNode = SerializedTextNode; 15 | 16 | function convertZeroWidthElement( 17 | domNode: HTMLElement, 18 | ): DOMConversionOutput | null { 19 | return null; 20 | } 21 | 22 | /** 23 | * @deprecated Use `PlaceholderNode` instead. This Node will be removed in a future version. 24 | */ 25 | /* eslint @typescript-eslint/no-unused-vars: "off" */ 26 | /* eslint @typescript-eslint/no-deprecated: "off" */ 27 | export class ZeroWidthNode extends TextNode { 28 | static getType(): string { 29 | return "zeroWidth"; 30 | } 31 | 32 | static clone(node: ZeroWidthNode): ZeroWidthNode { 33 | return new ZeroWidthNode(node.__textContent, node.__key); 34 | } 35 | 36 | static importJSON(_: SerializedZeroWidthNode): ZeroWidthNode { 37 | return $createZeroWidthNode(); 38 | } 39 | 40 | constructor( 41 | private __textContent: string, 42 | key?: NodeKey, 43 | ) { 44 | super(ZERO_WIDTH_CHARACTER, key); 45 | } 46 | 47 | exportJSON(): SerializedZeroWidthNode { 48 | return { 49 | ...super.exportJSON(), 50 | text: "", 51 | type: "zeroWidth", 52 | }; 53 | } 54 | 55 | updateDOM(): boolean { 56 | return false; 57 | } 58 | 59 | static importDOM(): DOMConversionMap | null { 60 | return null; 61 | } 62 | 63 | exportDOM(editor: LexicalEditor): DOMExportOutput { 64 | return { element: null }; 65 | } 66 | 67 | isTextEntity(): boolean { 68 | return true; 69 | } 70 | 71 | getTextContent(): string { 72 | return this.__textContent; 73 | } 74 | } 75 | 76 | export function $createZeroWidthNode(textContent = ""): ZeroWidthNode { 77 | const zeroWidthNode = new ZeroWidthNode(textContent); 78 | 79 | // Prevents that a space that is inserted by the user is deleted again 80 | // directly after the input. 81 | zeroWidthNode.setMode("segmented"); 82 | 83 | return $applyNodeReplacement(zeroWidthNode); 84 | } 85 | 86 | export function $isZeroWidthNode( 87 | node: LexicalNode | null | undefined, 88 | ): node is ZeroWidthNode { 89 | return node instanceof ZeroWidthNode; 90 | } 91 | -------------------------------------------------------------------------------- /plugin/src/ZeroWidthPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { mergeRegister } from "@lexical/utils"; 3 | import { 4 | $getRoot, 5 | $getSelection, 6 | $isDecoratorNode, 7 | $isRangeSelection, 8 | $nodesOfType, 9 | COMMAND_PRIORITY_HIGH, 10 | KEY_DOWN_COMMAND, 11 | LineBreakNode, 12 | SELECTION_CHANGE_COMMAND, 13 | } from "lexical"; 14 | import { useEffect } from "react"; 15 | import { 16 | $createZeroWidthNode, 17 | $isZeroWidthNode, 18 | ZeroWidthNode, 19 | } from "./ZeroWidthNode"; 20 | 21 | export const ZERO_WIDTH_CHARACTER = "​"; // 🚨 contains a zero-width space (U+200B) 22 | 23 | interface ZeroWidthPluginProps { 24 | /** 25 | * Defines the return value of `getTextContent()`. By default, an empty string to not corrupt 26 | * the text content of the editor. 27 | * 28 | * Note: If other nodes are not at the correct position when inserting via `$insertNodes`, 29 | * try to use a non-empty string like " " or a zero-width character. But don't forget 30 | * to remove these characters when exporting the editor content. 31 | * 32 | * @default empty string 33 | */ 34 | textContent?: string; 35 | } 36 | 37 | /** 38 | * This plugin serves as a patch to fix an incorrect cursor position in Safari. 39 | * It also ensures that the cursor is correctly aligned with the line height in 40 | * all browsers. 41 | * {@link https://github.com/facebook/lexical/issues/4487}. 42 | * 43 | * @deprecated Use `PlaceholderPlugin` instead. This Plugin will be removed in a future version. 44 | */ 45 | export function ZeroWidthPlugin({ textContent }: ZeroWidthPluginProps) { 46 | const [editor] = useLexicalComposerContext(); 47 | 48 | useEffect(() => { 49 | return mergeRegister( 50 | editor.registerUpdateListener(() => { 51 | // add a zero-width space node at the end if the last node is a decorator node 52 | editor.update( 53 | () => { 54 | const root = $getRoot(); 55 | const last = root.getLastDescendant(); 56 | // add ZeroWidthNode at the end of the editor 57 | if ($isDecoratorNode(last)) { 58 | // eslint-disable-next-line @typescript-eslint/no-deprecated 59 | $nodesOfType(ZeroWidthNode).forEach((node) => { 60 | node.remove(); 61 | }); // cleanup 62 | last.insertAfter($createZeroWidthNode(textContent)); 63 | } 64 | // add ZeroWidthNode before each line break 65 | // eslint-disable-next-line @typescript-eslint/no-deprecated 66 | $nodesOfType(LineBreakNode).forEach((node) => { 67 | const prev = node.getPreviousSibling(); 68 | if ($isDecoratorNode(prev)) { 69 | node.insertBefore($createZeroWidthNode(textContent)); 70 | } 71 | }); 72 | }, 73 | // merge with previous history entry to allow undoing 74 | { tag: "history-merge" }, 75 | ); 76 | }), 77 | editor.registerCommand( 78 | KEY_DOWN_COMMAND, 79 | (event) => { 80 | // prevent the unnecessary removal of the zero-width space, since this 81 | // would lead to the insertion of another zero-width space and thus break 82 | // undo with Ctrl+z 83 | if (event.ctrlKey || event.metaKey || event.altKey) { 84 | return false; 85 | } 86 | // remove the zero-width space if the user starts typing 87 | const selection = $getSelection(); 88 | if ($isRangeSelection(selection)) { 89 | const node = selection.anchor.getNode(); 90 | if ($isZeroWidthNode(node)) { 91 | node.remove(); 92 | } 93 | } 94 | return false; 95 | }, 96 | COMMAND_PRIORITY_HIGH, 97 | ), 98 | editor.registerCommand( 99 | SELECTION_CHANGE_COMMAND, 100 | () => { 101 | // select the previous node to avoid an error that occurs when the 102 | // user tries to insert a node directly after the zero-width space 103 | const selection = $getSelection(); 104 | if ($isRangeSelection(selection) && selection.isCollapsed()) { 105 | const node = selection.anchor.getNode(); 106 | if ($isZeroWidthNode(node)) { 107 | node.selectPrevious(); 108 | } 109 | } 110 | return false; 111 | }, 112 | COMMAND_PRIORITY_HIGH, 113 | ), 114 | ); 115 | }, [editor, textContent]); 116 | 117 | return null; 118 | } 119 | -------------------------------------------------------------------------------- /plugin/src/createMentionNode.ts: -------------------------------------------------------------------------------- 1 | import { LexicalEditor, LexicalNodeReplacement } from "lexical"; 2 | import { EditorConfig } from "lexical/LexicalEditor"; 3 | import React, { ElementType } from "react"; 4 | import { BeautifulMentionComponentProps } from "./BeautifulMentionsPluginProps"; 5 | import { 6 | BeautifulMentionNode, 7 | SerializedBeautifulMentionNode, 8 | } from "./MentionNode"; 9 | 10 | export type CustomBeautifulMentionNodeClass = ReturnType; 11 | 12 | export let CustomBeautifulMentionNode: CustomBeautifulMentionNodeClass; 13 | 14 | export function setCustomBeautifulMentionNode( 15 | BeautifulMentionNodeClass: CustomBeautifulMentionNodeClass, 16 | ) { 17 | CustomBeautifulMentionNode = BeautifulMentionNodeClass; 18 | } 19 | 20 | /** 21 | * Instead of using the default `BeautifulMentionNode` class, you can 22 | * extend it and use the mention component of your choice. 23 | */ 24 | export function createBeautifulMentionNode( 25 | mentionComponent: ElementType, 26 | ): [CustomBeautifulMentionNodeClass, LexicalNodeReplacement] { 27 | CustomBeautifulMentionNode = 28 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 29 | CustomBeautifulMentionNode || generateClass(mentionComponent); 30 | return [ 31 | CustomBeautifulMentionNode, 32 | { 33 | replace: BeautifulMentionNode, 34 | with: (node: BeautifulMentionNode) => { 35 | return new CustomBeautifulMentionNode( 36 | node.getTrigger(), 37 | node.getValue(), 38 | node.getData(), 39 | ); 40 | }, 41 | }, 42 | ]; 43 | } 44 | 45 | function generateClass( 46 | mentionComponent: ElementType, 47 | ) { 48 | return class CustomBeautifulMentionNode extends BeautifulMentionNode { 49 | static getType() { 50 | return "custom-beautifulMention"; 51 | } 52 | static clone(node: CustomBeautifulMentionNode) { 53 | return new CustomBeautifulMentionNode( 54 | node.__trigger, 55 | node.__value, 56 | node.__data, 57 | node.__key, 58 | ); 59 | } 60 | static importJSON(serializedNode: SerializedBeautifulMentionNode) { 61 | return new CustomBeautifulMentionNode( 62 | serializedNode.trigger, 63 | serializedNode.value, 64 | serializedNode.data, 65 | ); 66 | } 67 | exportJSON(): SerializedBeautifulMentionNode { 68 | const data = this.__data; 69 | return { 70 | trigger: this.__trigger, 71 | value: this.__value, 72 | ...(data ? { data } : {}), 73 | type: "custom-beautifulMention", 74 | version: 1, 75 | }; 76 | } 77 | component(): ElementType | null { 78 | return mentionComponent; 79 | } 80 | decorate(editor: LexicalEditor, config: EditorConfig): React.JSX.Element { 81 | return super.decorate(editor, config); 82 | } 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /plugin/src/environment.ts: -------------------------------------------------------------------------------- 1 | export const CAN_USE_DOM: boolean = 2 | typeof window !== "undefined" && 3 | // eslint-disable-next-line @typescript-eslint/no-deprecated 4 | typeof window.document.createElement !== "undefined"; 5 | 6 | export const IS_IOS: boolean = 7 | CAN_USE_DOM && 8 | /iPad|iPhone|iPod/.test(navigator.userAgent) && 9 | // @ts-expect-error window.MSStream 10 | !window.MSStream; 11 | 12 | export const IS_MOBILE = 13 | CAN_USE_DOM && window.matchMedia("(pointer: coarse)").matches; 14 | -------------------------------------------------------------------------------- /plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BeautifulMentionsPlugin"; 2 | export * from "./BeautifulMentionsPluginProps"; 3 | export * from "./MentionNode"; 4 | export * from "./PlaceholderNode"; 5 | export * from "./PlaceholderPlugin"; 6 | export * from "./ZeroWidthNode"; 7 | export * from "./ZeroWidthPlugin"; 8 | export * from "./createMentionNode"; 9 | export * from "./mention-converter"; 10 | export * from "./theme"; 11 | export * from "./useBeautifulMentions"; 12 | -------------------------------------------------------------------------------- /plugin/src/mention-commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $createTextNode, 3 | $isParagraphNode, 4 | $isTextNode, 5 | $setSelection, 6 | createCommand, 7 | LexicalCommand, 8 | LexicalNode, 9 | TextNode, 10 | } from "lexical"; 11 | import { BeautifulMentionsItemData } from "./BeautifulMentionsPluginProps"; 12 | import { 13 | $findBeautifulMentionNodes, 14 | $getSelectionInfo, 15 | $selectEnd, 16 | getNextSibling, 17 | getPreviousSibling, 18 | getTextContent, 19 | } from "./mention-utils"; 20 | import { 21 | $createBeautifulMentionNode, 22 | BeautifulMentionNode, 23 | } from "./MentionNode"; 24 | import { $isPlaceholderNode } from "./PlaceholderNode"; 25 | 26 | export interface InsertMention { 27 | /** 28 | * The trigger that was used to insert the mention. 29 | */ 30 | trigger: string; 31 | /** 32 | * The value to insert after the trigger. 33 | */ 34 | value: string; 35 | /** 36 | * Whether to focus the editor after inserting the mention. 37 | * @default true 38 | */ 39 | focus?: boolean; 40 | /** 41 | * The data to associate with the mention. 42 | */ 43 | data?: Record; 44 | } 45 | 46 | export interface RemoveMentions { 47 | /** 48 | * The trigger to search for when removing mentions. 49 | */ 50 | trigger: string; 51 | /** 52 | * An optional value to search for when removing mentions. 53 | */ 54 | value?: string; 55 | /** 56 | * Whether to focus the editor after removing the mention. 57 | * @default true 58 | */ 59 | focus?: boolean; 60 | } 61 | 62 | export interface RenameMentions { 63 | /** 64 | * The trigger to search for when renaming mentions. 65 | */ 66 | trigger: string; 67 | /** 68 | * The new value to replace the old value with. 69 | */ 70 | newValue: string; 71 | /** 72 | * An optional value to search for when renaming mentions. 73 | */ 74 | value?: string; 75 | /** 76 | * Whether to focus the editor after renaming the mention. 77 | * @default true 78 | */ 79 | focus?: boolean; 80 | } 81 | 82 | export interface HasMentions { 83 | /** 84 | * The trigger to search for when checking for mentions. 85 | */ 86 | trigger: string; 87 | /** 88 | * An optional value to search for when checking for mentions. 89 | */ 90 | value?: string; 91 | } 92 | 93 | export interface OpenMentionMenu { 94 | /** 95 | * The trigger to insert when opening the mention menu. 96 | */ 97 | trigger: string; 98 | } 99 | 100 | export const INSERT_MENTION_COMMAND: LexicalCommand = 101 | createCommand("INSERT_MENTION_COMMAND"); 102 | 103 | export const REMOVE_MENTIONS_COMMAND: LexicalCommand = 104 | createCommand("REMOVE_MENTIONS_COMMAND"); 105 | 106 | export const RENAME_MENTIONS_COMMAND: LexicalCommand = 107 | createCommand("RENAME_MENTIONS_COMMAND"); 108 | 109 | export const OPEN_MENTION_MENU_COMMAND: LexicalCommand = 110 | createCommand("OPEN_MENTION_MENU_COMMAND"); 111 | 112 | export function $insertTriggerAtSelection( 113 | triggers: string[], 114 | punctuation: string, 115 | trigger: string, 116 | autoSpace?: boolean, 117 | ) { 118 | return $insertMentionOrTrigger( 119 | triggers, 120 | punctuation, 121 | trigger, 122 | undefined, 123 | undefined, 124 | autoSpace, 125 | ); 126 | } 127 | 128 | export function $insertMentionAtSelection( 129 | triggers: string[], 130 | punctuation: string, 131 | trigger: string, 132 | value: string, 133 | data?: Record, 134 | autoSpace?: boolean, 135 | ) { 136 | return $insertMentionOrTrigger( 137 | triggers, 138 | punctuation, 139 | trigger, 140 | value, 141 | data, 142 | autoSpace, 143 | ); 144 | } 145 | 146 | function $insertMentionOrTrigger( 147 | triggers: string[], 148 | punctuation: string, 149 | trigger: string, 150 | value?: string, 151 | data?: Record, 152 | autoSpace?: boolean, 153 | ) { 154 | const selectionInfo = $getSelectionInfo(triggers, punctuation); 155 | if (!selectionInfo) { 156 | return false; 157 | } 158 | 159 | const { 160 | node, 161 | selection, 162 | wordCharBeforeCursor, 163 | wordCharAfterCursor, 164 | cursorAtStartOfNode, 165 | cursorAtEndOfNode, 166 | prevNode, 167 | nextNode, 168 | } = selectionInfo; 169 | 170 | // Insert a mention node or a text node with the trigger to open the mention menu. 171 | const mentionNode = value 172 | ? $createBeautifulMentionNode(trigger, value, data) 173 | : $createTextNode(trigger); 174 | 175 | const nodes: LexicalNode[] = []; 176 | // Insert a mention with a leading space 177 | if (!($isParagraphNode(node) && cursorAtStartOfNode) && !$isTextNode(node)) { 178 | if (autoSpace) { 179 | nodes.push($createTextNode(" ")); 180 | } 181 | nodes.push(mentionNode); 182 | selection.insertNodes(nodes); 183 | return true; 184 | } 185 | 186 | let spaceNode: TextNode | null = null; 187 | if ( 188 | autoSpace && 189 | (wordCharBeforeCursor || 190 | (cursorAtStartOfNode && prevNode !== null && !$isTextNode(prevNode))) 191 | ) { 192 | nodes.push($createTextNode(" ")); 193 | } 194 | nodes.push(mentionNode); 195 | if ( 196 | autoSpace && 197 | (wordCharAfterCursor || 198 | (cursorAtEndOfNode && nextNode !== null && !$isTextNode(nextNode))) 199 | ) { 200 | spaceNode = $createTextNode(" "); 201 | nodes.push(spaceNode); 202 | } 203 | 204 | selection.insertNodes(nodes); 205 | 206 | if (nodes.length > 1) { 207 | if ($isTextNode(mentionNode)) { 208 | mentionNode.select(); 209 | } else if (spaceNode) { 210 | spaceNode.selectPrevious(); 211 | } 212 | } 213 | 214 | return true; 215 | } 216 | 217 | export function $removeMention(trigger: string, value?: string, focus = true) { 218 | let removed = false; 219 | let prev: LexicalNode | null = null; 220 | let next: LexicalNode | null = null; 221 | const mentions = $findBeautifulMentionNodes(); 222 | for (const mention of mentions) { 223 | const sameTrigger = mention.getTrigger() === trigger; 224 | const sameValue = mention.getValue() === value; 225 | if (sameTrigger && (sameValue || !value)) { 226 | prev = getPreviousSibling(mention); 227 | next = getNextSibling(mention); 228 | mention.remove(); 229 | removed = true; 230 | // Prevent double spaces 231 | if ( 232 | $isTextNode(prev) && 233 | getTextContent(prev).endsWith(" ") && 234 | next && 235 | getTextContent(next).startsWith(" ") 236 | ) { 237 | prev.setTextContent(getTextContent(prev).slice(0, -1)); 238 | } 239 | // Remove trailing space 240 | if ( 241 | (next === null || $isPlaceholderNode(next)) && 242 | $isTextNode(prev) && 243 | getTextContent(prev).endsWith(" ") 244 | ) { 245 | prev.setTextContent(getTextContent(prev).trimEnd()); 246 | } 247 | } 248 | } 249 | if (removed && focus) { 250 | focusEditor(prev, next); 251 | } else if (!focus) { 252 | $setSelection(null); 253 | } 254 | return removed; 255 | } 256 | 257 | export function $renameMention( 258 | trigger: string, 259 | newValue: string, 260 | value?: string, 261 | focus = true, 262 | ) { 263 | const mentions = $findBeautifulMentionNodes(); 264 | let renamedMention: BeautifulMentionNode | null = null; 265 | for (const mention of mentions) { 266 | const sameTrigger = mention.getTrigger() === trigger; 267 | const sameValue = mention.getValue() === value; 268 | if (sameTrigger && (sameValue || !value)) { 269 | renamedMention = mention; 270 | mention.setValue(newValue); 271 | } 272 | } 273 | if (renamedMention && focus) { 274 | const prev = getPreviousSibling(renamedMention); 275 | const next = getNextSibling(renamedMention); 276 | focusEditor(prev, next); 277 | if (next && $isTextNode(next)) { 278 | next.select(0, 0); 279 | } else { 280 | $selectEnd(); 281 | } 282 | } else if (!focus) { 283 | $setSelection(null); 284 | } 285 | return renamedMention !== null; 286 | } 287 | 288 | function focusEditor(prev: LexicalNode | null, next: LexicalNode | null) { 289 | if (next && $isTextNode(next)) { 290 | next.select(0, 0); 291 | } else if (prev && $isTextNode(prev)) { 292 | prev.select(); 293 | } else { 294 | $selectEnd(); 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /plugin/src/mention-converter.spec.ts: -------------------------------------------------------------------------------- 1 | import { CodeNode } from "@lexical/code"; 2 | import { LinkNode } from "@lexical/link"; 3 | import { ListItemNode, ListNode } from "@lexical/list"; 4 | import { 5 | $convertFromMarkdownString, 6 | $convertToMarkdownString, 7 | TRANSFORMERS, 8 | } from "@lexical/markdown"; 9 | import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; 10 | import { HeadingNode, QuoteNode } from "@lexical/rich-text"; 11 | import { $nodesOfType, createEditor, ParagraphNode, TextNode } from "lexical"; 12 | import { describe, expect, it } from "vitest"; 13 | import { 14 | $transformTextToMentionNodes, 15 | convertToMentionEntries, 16 | } from "./mention-converter"; 17 | import { DEFAULT_PUNCTUATION } from "./mention-utils"; 18 | import { BeautifulMentionNode } from "./MentionNode"; 19 | import { PlaceholderNode } from "./PlaceholderNode"; 20 | 21 | describe("mention-converter", () => { 22 | it("should find mention entries in text", () => { 23 | const triggers = ["@", "due:", "#"]; 24 | const text = "Hey @john, the task is #urgent and due:tomorrow"; 25 | const entries = convertToMentionEntries( 26 | text, 27 | triggers, 28 | DEFAULT_PUNCTUATION, 29 | ); 30 | 31 | expect(entries.length).toBe(6); 32 | 33 | expect(entries[0].type).toBe("text"); 34 | expect(entries[0].value).toBe("Hey "); 35 | 36 | expect(entries[1].type).toBe("mention"); 37 | if (entries[1].type === "mention") { 38 | expect(entries[1].trigger).toBe("@"); 39 | expect(entries[1].value).toBe("john"); 40 | } 41 | 42 | expect(entries[2].type).toBe("text"); 43 | expect(entries[2].value).toBe(", the task is "); 44 | 45 | expect(entries[3].type).toBe("mention"); 46 | if (entries[3].type === "mention") { 47 | expect(entries[3].trigger).toBe("#"); 48 | expect(entries[3].value).toBe("urgent"); 49 | } 50 | 51 | expect(entries[4].type).toBe("text"); 52 | expect(entries[4].value).toBe(" and "); 53 | 54 | expect(entries[5].type).toBe("mention"); 55 | if (entries[5].type === "mention") { 56 | expect(entries[5].trigger).toBe("due:"); 57 | expect(entries[5].value).toBe("tomorrow"); 58 | } 59 | }); 60 | 61 | it("should find multiple mentions with the same trigger", () => { 62 | const triggers = ["@", "due:", "#"]; 63 | const text = "Hey @john and @jane."; 64 | const entries = convertToMentionEntries( 65 | text, 66 | triggers, 67 | DEFAULT_PUNCTUATION, 68 | ); 69 | 70 | expect(entries.length).toBe(5); 71 | 72 | expect(entries[0].type).toBe("text"); 73 | expect(entries[0].value).toBe("Hey "); 74 | 75 | expect(entries[1].type).toBe("mention"); 76 | if (entries[1].type === "mention") { 77 | expect(entries[1].trigger).toBe("@"); 78 | expect(entries[1].value).toBe("john"); 79 | } 80 | 81 | expect(entries[2].type).toBe("text"); 82 | expect(entries[2].value).toBe(" and "); 83 | 84 | expect(entries[3].type).toBe("mention"); 85 | if (entries[3].type === "mention") { 86 | expect(entries[3].trigger).toBe("@"); 87 | expect(entries[3].value).toBe("jane"); 88 | } 89 | }); 90 | 91 | it("should ignore triggers without a value", () => { 92 | const triggers = ["@", "#"]; 93 | const text = "Hey @ john and # jane."; 94 | const entries = convertToMentionEntries( 95 | text, 96 | triggers, 97 | DEFAULT_PUNCTUATION, 98 | ); 99 | expect(entries.length).toBe(1); 100 | expect(entries[0].type).toBe("text"); 101 | }); 102 | 103 | it("should ignore triggers in the middle of a word", () => { 104 | const triggers = ["@"]; 105 | const text = "test@example.com @hello"; 106 | const entries = convertToMentionEntries( 107 | text, 108 | triggers, 109 | DEFAULT_PUNCTUATION, 110 | ); 111 | expect(entries.length).toBe(2); 112 | expect(entries[0].type).toBe("text"); 113 | expect(entries[0].value).toBe("test@example.com "); 114 | expect(entries[1].type).toBe("mention"); 115 | if (entries[1].type === "mention") { 116 | expect(entries[1].trigger).toBe("@"); 117 | expect(entries[1].value).toBe("hello"); 118 | } 119 | }); 120 | 121 | it("should find mentions in brackets", () => { 122 | const triggers = ["@"]; 123 | const text = "Hey (@john)"; 124 | const entries = convertToMentionEntries( 125 | text, 126 | triggers, 127 | DEFAULT_PUNCTUATION, 128 | ); 129 | expect(entries.length).toBe(3); 130 | expect(entries[0].type).toBe("text"); 131 | expect(entries[0].value).toBe("Hey ("); 132 | expect(entries[1].type).toBe("mention"); 133 | if (entries[1].type === "mention") { 134 | expect(entries[1].trigger).toBe("@"); 135 | expect(entries[1].value).toBe("john"); 136 | } 137 | expect(entries[2].type).toBe("text"); 138 | expect(entries[2].value).toBe(")"); 139 | }); 140 | 141 | // Temp. disabling this test. For unknown reason, it fails after update to Vite 6.0.0 142 | it.skip("should transform mention string to mention nodes", () => { 143 | const editor = createEditor({ 144 | nodes: [ 145 | BeautifulMentionNode, 146 | PlaceholderNode, 147 | HorizontalRuleNode, 148 | CodeNode, 149 | HeadingNode, 150 | LinkNode, 151 | ListNode, 152 | ListItemNode, 153 | QuoteNode, 154 | ], 155 | onError(err) { 156 | throw err; 157 | }, 158 | }); 159 | editor.update(() => { 160 | $convertFromMarkdownString( 161 | "Hey @catherine, the **task** is #urgent", 162 | TRANSFORMERS, 163 | ); 164 | $transformTextToMentionNodes(["@", "#"]); 165 | 166 | // eslint-disable-next-line @typescript-eslint/no-deprecated 167 | const paragraph = $nodesOfType(ParagraphNode); 168 | const nodes = paragraph[0].getChildren(); 169 | 170 | expect(nodes[0]).toBeInstanceOf(TextNode); 171 | expect(nodes[0].getTextContent()).toBe("Hey "); 172 | expect(nodes[1]).toBeInstanceOf(BeautifulMentionNode); 173 | expect(nodes[1].getTextContent()).toBe("@catherine"); 174 | expect(nodes[2]).toBeInstanceOf(TextNode); 175 | expect(nodes[2].getTextContent()).toBe(", the "); 176 | expect(nodes[3]).toBeInstanceOf(TextNode); 177 | expect(nodes[3].getTextContent()).toBe("task"); 178 | expect(nodes[4]).toBeInstanceOf(TextNode); 179 | expect(nodes[4].getTextContent()).toBe(" is "); 180 | expect(nodes[5]).toBeInstanceOf(BeautifulMentionNode); 181 | expect(nodes[5].getTextContent()).toBe("#urgent"); 182 | 183 | const markdown = $convertToMarkdownString(TRANSFORMERS); 184 | expect(markdown).toBe("Hey @catherine, the **task** is #urgent"); 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /plugin/src/mention-converter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $createTextNode, 3 | $getRoot, 4 | $isElementNode, 5 | $isTextNode, 6 | LexicalNode, 7 | TextNode, 8 | } from "lexical"; 9 | import { $createBeautifulMentionNode } from "./MentionNode"; 10 | import { 11 | DEFAULT_PUNCTUATION, 12 | LENGTH_LIMIT, 13 | TRIGGERS, 14 | VALID_CHARS, 15 | } from "./mention-utils"; 16 | 17 | interface MentionEntry { 18 | type: "mention"; 19 | trigger: string; 20 | value: string; 21 | } 22 | 23 | interface TextEntry { 24 | type: "text"; 25 | value: string; 26 | } 27 | 28 | type Entry = MentionEntry | TextEntry; 29 | 30 | function findMentions(text: string, triggers: string[], punctuation: string) { 31 | const regex = new RegExp( 32 | "(?<=\\s|^|\\()" + 33 | TRIGGERS(triggers) + 34 | "((?:" + 35 | VALID_CHARS(triggers, punctuation) + 36 | "){1," + 37 | LENGTH_LIMIT + 38 | "})", 39 | "g", 40 | ); 41 | const matches: { value: string; index: number }[] = []; 42 | let match; 43 | regex.lastIndex = 0; 44 | while ((match = regex.exec(text)) !== null) { 45 | matches.push({ 46 | value: match[0], 47 | index: match.index, 48 | }); 49 | } 50 | return matches; 51 | } 52 | 53 | export function convertToMentionEntries( 54 | text: string, 55 | triggers: string[], 56 | punctuation: string, 57 | ): Entry[] { 58 | const matches = findMentions(text, triggers, punctuation); 59 | 60 | const result: Entry[] = []; 61 | let lastIndex = 0; 62 | 63 | matches.forEach(({ value, index }) => { 64 | // Add text before mention 65 | if (index > lastIndex) { 66 | const textBeforeMention = text.substring(lastIndex, index); 67 | result.push({ type: "text", value: textBeforeMention }); 68 | } 69 | // Add mention 70 | const triggerRegExp = triggers.find((trigger) => 71 | new RegExp(trigger).test(value), 72 | ); 73 | 74 | const match = triggerRegExp && new RegExp(triggerRegExp).exec(value); 75 | if (!match) { 76 | // should never happen since we only find mentions with the given triggers 77 | throw new Error("No trigger found for mention"); 78 | } 79 | const trigger = match[0]; 80 | 81 | result.push({ 82 | type: "mention", 83 | value: value.substring(trigger.length), 84 | trigger, 85 | }); 86 | // Update lastIndex 87 | lastIndex = index + value.length; 88 | }); 89 | 90 | // Add text after last mention 91 | if (lastIndex < text.length) { 92 | const textAfterMentions = text.substring(lastIndex); 93 | result.push({ type: "text", value: textAfterMentions }); 94 | } 95 | 96 | return result; 97 | } 98 | 99 | /** 100 | * Utility function that takes a string or a text nodes and converts it to a 101 | * list of mention and text nodes. 102 | * 103 | * 🚨 Only works for mentions without spaces. Ensure spaces are disabled 104 | * via the `allowSpaces` prop. 105 | */ 106 | export function $convertToMentionNodes( 107 | textOrNode: string | TextNode, 108 | triggers: string[], 109 | punctuation = DEFAULT_PUNCTUATION, 110 | ) { 111 | const text = 112 | typeof textOrNode === "string" ? textOrNode : textOrNode.getTextContent(); 113 | const entries = convertToMentionEntries(text, triggers, punctuation); 114 | const nodes: LexicalNode[] = []; 115 | for (const entry of entries) { 116 | if (entry.type === "text") { 117 | const textNode = $createTextNode(entry.value); 118 | if (typeof textOrNode !== "string") { 119 | textNode.setFormat(textOrNode.getFormat()); 120 | } 121 | nodes.push(textNode); 122 | } else { 123 | nodes.push($createBeautifulMentionNode(entry.trigger, entry.value)); 124 | } 125 | } 126 | return nodes; 127 | } 128 | 129 | /** 130 | * Transforms text nodes containing mention strings into mention nodes. 131 | * 132 | * 🚨 Only works for mentions without spaces. Ensure spaces are disabled 133 | * via the `allowSpaces` prop. 134 | */ 135 | export function $transformTextToMentionNodes( 136 | triggers: string[], 137 | punctuation = DEFAULT_PUNCTUATION, 138 | ) { 139 | const root = $getRoot(); 140 | const nodes = root.getChildren(); 141 | 142 | const traverseNodes = (nodes: LexicalNode[]) => { 143 | for (const node of nodes) { 144 | if ($isTextNode(node)) { 145 | const newNodes = $convertToMentionNodes(node, triggers, punctuation); 146 | if (newNodes.length > 1) { 147 | const parent = node.getParent(); 148 | const index = node.getIndexWithinParent(); 149 | parent?.splice(index, 1, newNodes); 150 | } 151 | } else if ($isElementNode(node)) { 152 | traverseNodes(node.getChildren()); 153 | } 154 | } 155 | }; 156 | 157 | traverseNodes(nodes); 158 | } 159 | -------------------------------------------------------------------------------- /plugin/src/mention-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $createRangeSelection, 3 | $getEditor, 4 | $getRoot, 5 | $getSelection, 6 | $isDecoratorNode, 7 | $isElementNode, 8 | $isRangeSelection, 9 | $isTextNode, 10 | $nodesOfType, 11 | $setSelection, 12 | ElementNode, 13 | LexicalEditor, 14 | LexicalNode, 15 | RangeSelection, 16 | RootNode, 17 | TextNode, 18 | } from "lexical"; 19 | import { BeautifulMentionsPluginProps } from "./BeautifulMentionsPluginProps"; 20 | import { BeautifulMentionNode } from "./MentionNode"; 21 | import { $isZeroWidthNode } from "./ZeroWidthNode"; 22 | import { CustomBeautifulMentionNode } from "./createMentionNode"; 23 | 24 | interface SelectionInfoBase { 25 | offset: number; 26 | type: "text" | "element"; 27 | textContent: string; 28 | selection: RangeSelection; 29 | prevNode: LexicalNode | null; 30 | nextNode: LexicalNode | null; 31 | parentNode: ElementNode | null; 32 | cursorAtStartOfNode: boolean; 33 | cursorAtEndOfNode: boolean; 34 | wordCharBeforeCursor: boolean; 35 | wordCharAfterCursor: boolean; 36 | spaceBeforeCursor: boolean; 37 | spaceAfterCursor: boolean; 38 | } 39 | 40 | interface TextNodeSelectionInfo extends SelectionInfoBase { 41 | isTextNode: true; 42 | node: TextNode; 43 | } 44 | 45 | interface LexicalNodeSelectionInfo extends SelectionInfoBase { 46 | isTextNode: false; 47 | node: LexicalNode; 48 | } 49 | 50 | type SelectionInfo = 51 | | TextNodeSelectionInfo 52 | | LexicalNodeSelectionInfo 53 | | undefined; 54 | 55 | export const DEFAULT_PUNCTUATION = 56 | "\\.,\\*\\?\\$\\|#{}\\(\\)\\^\\[\\]\\\\/!%'\"~=<>_:;"; 57 | 58 | // Makes it possible to use brackets before the trigger: (@mention) 59 | export const PRE_TRIGGER_CHARS = "\\("; 60 | 61 | // Strings that can trigger the mention menu. 62 | export const TRIGGERS = (triggers: string[]) => 63 | "(?:" + triggers.join("|") + ")"; 64 | 65 | // Chars we expect to see in a mention (non-space, non-punctuation). 66 | export const VALID_CHARS = (triggers: string[], punctuation: string) => { 67 | const lookahead = 68 | triggers.length === 0 ? "" : "(?!" + triggers.join("|") + ")"; 69 | return lookahead + "[^\\s" + punctuation + "]"; 70 | }; 71 | 72 | export const LENGTH_LIMIT = 75; 73 | 74 | export function isWordChar( 75 | char: string, 76 | triggers: string[], 77 | punctuation: string, 78 | ) { 79 | return new RegExp(VALID_CHARS(triggers, punctuation)).test(char); 80 | } 81 | 82 | export function $getSelectionInfo( 83 | triggers: string[], 84 | punctuation: string, 85 | ): SelectionInfo { 86 | const selection = $getSelection(); 87 | if (!selection || !$isRangeSelection(selection)) { 88 | return; 89 | } 90 | 91 | const anchor = selection.anchor; 92 | const focus = selection.focus; 93 | const [node] = selection.getNodes(); 94 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 95 | if (anchor.key !== focus.key || anchor.offset !== focus.offset || !node) { 96 | return; 97 | } 98 | 99 | const isTextNode = $isTextNode(node) && node.isSimpleText(); 100 | const type = anchor.type; 101 | const offset = anchor.offset; 102 | const textContent = getTextContent(node); 103 | const cursorAtStartOfNode = offset === 0; 104 | const cursorAtEndOfNode = textContent.length === offset; 105 | const charBeforeCursor = textContent.charAt(offset - 1); 106 | const charAfterCursor = textContent.charAt(offset); 107 | const wordCharBeforeCursor = isWordChar( 108 | charBeforeCursor, 109 | triggers, 110 | punctuation, 111 | ); 112 | const wordCharAfterCursor = isWordChar( 113 | charAfterCursor, 114 | triggers, 115 | punctuation, 116 | ); 117 | const spaceBeforeCursor = /\s/.test(charBeforeCursor); 118 | const spaceAfterCursor = /\s/.test(charAfterCursor); 119 | const prevNode = getPreviousSibling(node); 120 | const nextNode = getNextSibling(node); 121 | const parentNode = node.getParent(); 122 | 123 | const props = { 124 | node, 125 | type, 126 | offset, 127 | isTextNode, 128 | textContent, 129 | selection, 130 | prevNode, 131 | nextNode, 132 | parentNode, 133 | cursorAtStartOfNode, 134 | cursorAtEndOfNode, 135 | wordCharBeforeCursor, 136 | wordCharAfterCursor, 137 | spaceBeforeCursor, 138 | spaceAfterCursor, 139 | }; 140 | 141 | if (isTextNode) { 142 | return { 143 | ...props, 144 | isTextNode: true, 145 | node, 146 | }; 147 | } else { 148 | return { 149 | ...props, 150 | isTextNode: false, 151 | node, 152 | }; 153 | } 154 | } 155 | 156 | /** 157 | * TODO replace with Node#getPreviousSibling after ZeroWidthNode was removed. 158 | */ 159 | export function getNextSibling(node: LexicalNode) { 160 | let nextSibling = node.getNextSibling(); 161 | while (nextSibling !== null && $isZeroWidthNode(nextSibling)) { 162 | nextSibling = nextSibling.getNextSibling(); 163 | } 164 | return nextSibling; 165 | } 166 | 167 | /** 168 | * TODO replace with Node#getPreviousSibling after ZeroWidthNode was removed. 169 | */ 170 | export function getPreviousSibling(node: LexicalNode) { 171 | let previousSibling = node.getPreviousSibling(); 172 | while (previousSibling !== null && $isZeroWidthNode(previousSibling)) { 173 | previousSibling = previousSibling.getPreviousSibling(); 174 | } 175 | return previousSibling; 176 | } 177 | 178 | /** 179 | * TODO replace with Node#getTextContent after ZeroWidthNode was removed. 180 | */ 181 | export function getTextContent(node: LexicalNode) { 182 | if ($isZeroWidthNode(node)) { 183 | return ""; 184 | } 185 | return node.getTextContent(); 186 | } 187 | 188 | export function getCreatableProp( 189 | creatable: BeautifulMentionsPluginProps["creatable"], 190 | trigger: string | null, 191 | ) { 192 | if (typeof creatable === "string" || typeof creatable === "boolean") { 193 | return creatable; 194 | } 195 | if (trigger === null) { 196 | return false; 197 | } 198 | if (typeof creatable === "object") { 199 | return creatable[trigger]; 200 | } 201 | return false; 202 | } 203 | 204 | export function getMenuItemLimitProp( 205 | menuItemLimit: BeautifulMentionsPluginProps["menuItemLimit"], 206 | trigger: string | null, 207 | ) { 208 | if (typeof menuItemLimit === "number" || menuItemLimit === false) { 209 | return menuItemLimit; 210 | } 211 | if (typeof menuItemLimit === "undefined") { 212 | return 5; 213 | } 214 | if (trigger === null) { 215 | return false; 216 | } 217 | if (typeof menuItemLimit === "object") { 218 | return menuItemLimit[trigger]; 219 | } 220 | return 5; 221 | } 222 | 223 | function getLastNode(root: RootNode) { 224 | const descendant = root.getLastDescendant(); 225 | if ($isElementNode(descendant) || $isTextNode(descendant)) { 226 | return descendant; 227 | } 228 | if ($isDecoratorNode(descendant)) { 229 | return descendant.getParent(); 230 | } 231 | return root; 232 | } 233 | 234 | export function $selectEnd() { 235 | const root = $getRoot(); 236 | const lastNode = getLastNode(root); 237 | const key = lastNode?.getKey(); 238 | const offset = $isElementNode(lastNode) 239 | ? lastNode.getChildrenSize() 240 | : $isTextNode(lastNode) 241 | ? getTextContent(lastNode).length 242 | : 0; 243 | const type = $isElementNode(lastNode) ? "element" : "text"; 244 | if (key) { 245 | const newSelection = $createRangeSelection(); 246 | newSelection.anchor.set(key, offset, type); 247 | newSelection.focus.set(key, offset, type); 248 | $setSelection(newSelection); 249 | } 250 | } 251 | 252 | export function $findBeautifulMentionNodes(editor?: LexicalEditor) { 253 | editor = editor ?? $getEditor(); 254 | if ( 255 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 256 | CustomBeautifulMentionNode && 257 | editor.hasNodes([CustomBeautifulMentionNode]) 258 | ) { 259 | // eslint-disable-next-line @typescript-eslint/no-deprecated 260 | return $nodesOfType(CustomBeautifulMentionNode); 261 | } 262 | // eslint-disable-next-line @typescript-eslint/no-deprecated 263 | return $nodesOfType(BeautifulMentionNode); 264 | } 265 | -------------------------------------------------------------------------------- /plugin/src/theme.ts: -------------------------------------------------------------------------------- 1 | export interface BeautifulMentionsCssClassNames { 2 | trigger?: string; 3 | value?: string; 4 | container?: string; 5 | containerFocused?: string; 6 | } 7 | 8 | /** 9 | * The theme configuration for BeautifulMentions. Rules: 10 | * - The keys are regular expressions that match the triggers. 11 | * - The values are strings with CSS class names or 12 | * {@link BeautifulMentionsCssClassNames} objects. 13 | * - Append `Focused` to the key to address the focused state. 14 | * - If you need to apply different styles to trigger and value, 15 | * use an {@link BeautifulMentionsCssClassNames} object instead of a string. 16 | */ 17 | export type BeautifulMentionsTheme = Record< 18 | string, 19 | string | BeautifulMentionsCssClassNames 20 | >; 21 | -------------------------------------------------------------------------------- /plugin/src/useBeautifulMentions.ts: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { useCallback } from "react"; 3 | import { 4 | HasMentions, 5 | INSERT_MENTION_COMMAND, 6 | InsertMention, 7 | OPEN_MENTION_MENU_COMMAND, 8 | OpenMentionMenu, 9 | REMOVE_MENTIONS_COMMAND, 10 | RENAME_MENTIONS_COMMAND, 11 | RemoveMentions, 12 | RenameMentions, 13 | } from "./mention-commands"; 14 | import { $findBeautifulMentionNodes } from "./mention-utils"; 15 | 16 | /** 17 | * Hook that provides access to the BeautifulMentionsPlugin. It allows you to insert, 18 | * remove and rename mentions from outside the editor. 19 | */ 20 | export function useBeautifulMentions() { 21 | const [editor] = useLexicalComposerContext(); 22 | 23 | /** 24 | * Inserts a mention at the current selection. 25 | */ 26 | const insertMention = useCallback( 27 | (options: InsertMention) => 28 | editor.dispatchCommand(INSERT_MENTION_COMMAND, options), 29 | [editor], 30 | ); 31 | 32 | /** 33 | * Removes all mentions that match the given trigger and an optional value. 34 | */ 35 | const removeMentions = useCallback( 36 | (options: RemoveMentions) => 37 | editor.dispatchCommand(REMOVE_MENTIONS_COMMAND, options), 38 | [editor], 39 | ); 40 | 41 | /** 42 | * Renames all mentions that match the given trigger and an optional value. 43 | */ 44 | const renameMentions = useCallback( 45 | (options: RenameMentions) => 46 | editor.dispatchCommand(RENAME_MENTIONS_COMMAND, options), 47 | [editor], 48 | ); 49 | 50 | /** 51 | * Returns `true` if there are mentions that match the given trigger and an optional value. 52 | */ 53 | const hasMentions = useCallback( 54 | ({ value, trigger }: HasMentions) => { 55 | return editor.getEditorState().read(() => { 56 | const mentions = $findBeautifulMentionNodes(editor); 57 | if (value) { 58 | return mentions.some( 59 | (mention) => 60 | mention.getTrigger() === trigger && mention.getValue() === value, 61 | ); 62 | } 63 | return mentions.some((mention) => mention.getTrigger() === trigger); 64 | }); 65 | }, 66 | [editor], 67 | ); 68 | 69 | /** 70 | * Opens the mention menu at the current selection. 71 | */ 72 | const openMentionMenu = useCallback( 73 | (options: OpenMentionMenu) => 74 | editor.dispatchCommand(OPEN_MENTION_MENU_COMMAND, options), 75 | [editor], 76 | ); 77 | 78 | /** 79 | * Returns all mentions used in the editor. 80 | */ 81 | const getMentions = useCallback(() => { 82 | return editor.getEditorState().read(() => 83 | $findBeautifulMentionNodes(editor).map((node) => { 84 | const { trigger, value, data } = node.exportJSON(); 85 | return { trigger, value, data }; 86 | }), 87 | ); 88 | }, [editor]); 89 | 90 | return { 91 | getMentions, 92 | insertMention, 93 | removeMentions, 94 | renameMentions, 95 | hasMentions, 96 | openMentionMenu, 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /plugin/src/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounce(value: string | null, delay?: number) { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | // Update debounced value after delay 8 | const handler = setTimeout(() => { 9 | setDebouncedValue(value); 10 | }, delay); 11 | 12 | // Cancel the timeout if value changes (also on delay change or unmount) 13 | // This is how we prevent debounced value from updating if value is changed 14 | // within the delay period. Timeout gets cleared and restarted. 15 | return () => { 16 | clearTimeout(handler); 17 | }; 18 | }, [value, delay]); 19 | 20 | return debouncedValue; 21 | } 22 | -------------------------------------------------------------------------------- /plugin/src/useIsFocused.ts: -------------------------------------------------------------------------------- 1 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 | import { mergeRegister } from "@lexical/utils"; 3 | import { BLUR_COMMAND, COMMAND_PRIORITY_NORMAL, FOCUS_COMMAND } from "lexical"; 4 | import { useEffect, useLayoutEffect, useState } from "react"; 5 | import { CAN_USE_DOM } from "./environment"; 6 | 7 | const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM 8 | ? useLayoutEffect 9 | : useEffect; 10 | 11 | export const useIsFocused = () => { 12 | const [editor] = useLexicalComposerContext(); 13 | const [hasFocus, setHasFocus] = useState(() => 14 | CAN_USE_DOM ? editor.getRootElement() === document.activeElement : false, 15 | ); 16 | 17 | useLayoutEffectImpl(() => { 18 | return mergeRegister( 19 | editor.registerCommand( 20 | FOCUS_COMMAND, 21 | () => { 22 | setHasFocus(true); 23 | return false; 24 | }, 25 | COMMAND_PRIORITY_NORMAL, 26 | ), 27 | editor.registerCommand( 28 | BLUR_COMMAND, 29 | () => { 30 | setHasFocus(false); 31 | return false; 32 | }, 33 | COMMAND_PRIORITY_NORMAL, 34 | ), 35 | ); 36 | }, [editor]); 37 | 38 | return hasFocus; 39 | }; 40 | -------------------------------------------------------------------------------- /plugin/src/useMentionLookupService.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, waitFor } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import { useMentionLookupService } from "./useMentionLookupService"; 4 | 5 | const items = { 6 | "@": ["Jane"], 7 | "\\w+:": ["today", "tomorrow"], 8 | }; 9 | 10 | const queryFn = async (trigger: string, queryString?: string | null) => { 11 | await new Promise((resolve) => setTimeout(resolve, 100)); 12 | const mentions = Object.entries(items).find(([key]) => { 13 | return new RegExp(key).test(trigger); 14 | }); 15 | return mentions 16 | ? mentions[1].filter((m) => 17 | queryString ? m.toLowerCase().startsWith(queryString.toLowerCase()) : m, 18 | ) 19 | : []; 20 | }; 21 | 22 | describe("useMentionLookupService", () => { 23 | it("should return the full list of mentions when the search term is empty", async () => { 24 | const { result } = renderHook(() => 25 | useMentionLookupService({ 26 | queryString: "", 27 | trigger: "due:", 28 | items: items, 29 | }), 30 | ); 31 | await waitFor(() => { 32 | expect(result.current.results).toStrictEqual(["today", "tomorrow"]); 33 | }); 34 | }); 35 | 36 | it("should return the full list of mentions when the search term is null", async () => { 37 | const { result } = renderHook(() => 38 | useMentionLookupService({ 39 | queryString: null, 40 | trigger: "due:", 41 | items: items, 42 | }), 43 | ); 44 | 45 | await waitFor(() => { 46 | expect(result.current.results).toStrictEqual(["today", "tomorrow"]); 47 | }); 48 | }); 49 | 50 | it("should return a filtered mention list for predefined items and search term", async () => { 51 | const { result } = renderHook(() => 52 | useMentionLookupService({ 53 | queryString: "tomo", 54 | trigger: "due:", 55 | items: items, 56 | }), 57 | ); 58 | await waitFor(() => { 59 | expect(result.current.results).toStrictEqual(["tomorrow"]); 60 | }); 61 | }); 62 | 63 | it("should execute the mentions query function", async () => { 64 | type Params = Parameters; 65 | type Options = Params[0]; 66 | 67 | const options: Options = { 68 | queryString: null, 69 | trigger: null, 70 | onSearch: queryFn, 71 | searchDelay: 100, 72 | }; 73 | 74 | const { result, rerender } = renderHook( 75 | (opt: Options) => useMentionLookupService(opt), 76 | { 77 | initialProps: options, 78 | }, 79 | ); 80 | 81 | expect(result.current.loading).toBe(false); 82 | expect(result.current.query).toBeNull(); 83 | expect(result.current.results).toStrictEqual([]); 84 | 85 | rerender({ ...options, trigger: "due:", queryString: "tomor" }); 86 | 87 | await waitFor( 88 | () => { 89 | expect(result.current.loading).toBe(true); 90 | }, 91 | { timeout: 200 }, 92 | ); 93 | 94 | await waitFor(() => { 95 | expect(result.current.query).toStrictEqual("tomor"); 96 | expect(result.current.results).toStrictEqual(["tomorrow"]); 97 | expect(result.current.loading).toBe(false); 98 | }); 99 | }); 100 | 101 | it("should return an empty array when no matching trigger was found", async () => { 102 | const { result } = renderHook(() => 103 | useMentionLookupService({ 104 | queryString: "j", 105 | trigger: "#", 106 | items: items, 107 | }), 108 | ); 109 | 110 | await waitFor(() => { 111 | expect(result.current.results).toStrictEqual([]); 112 | }); 113 | }); 114 | 115 | it("should handle trigger change", async () => { 116 | const { result, rerender } = renderHook( 117 | ({ queryString, trigger }) => 118 | useMentionLookupService({ 119 | queryString: queryString, 120 | trigger: trigger, 121 | items: items, 122 | }), 123 | { 124 | initialProps: { queryString: "ja", trigger: "@" }, 125 | }, 126 | ); 127 | 128 | await waitFor(() => { 129 | expect(result.current.results).toStrictEqual(["Jane"]); 130 | }); 131 | 132 | rerender({ queryString: "ja", trigger: "due:" }); 133 | 134 | await waitFor(() => { 135 | expect(result.current.results).toEqual([]); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /plugin/src/useMentionLookupService.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useMemo, useState } from "react"; 2 | import { BeautifulMentionsItem } from "./BeautifulMentionsPluginProps"; 3 | import { useDebounce } from "./useDebounce"; 4 | 5 | interface MentionsLookupServiceOptions { 6 | queryString: string | null; 7 | trigger: string | null; 8 | searchDelay?: number; 9 | items?: Record; 10 | onSearch?: ( 11 | trigger: string, 12 | queryString?: string | null, 13 | ) => Promise; 14 | justSelectedAnOption?: RefObject; 15 | } 16 | 17 | export function useMentionLookupService(options: MentionsLookupServiceOptions) { 18 | const { 19 | queryString, 20 | trigger, 21 | searchDelay, 22 | items, 23 | onSearch, 24 | justSelectedAnOption, 25 | } = options; 26 | const debouncedQueryString = useDebounce(queryString, searchDelay); 27 | const [loading, setLoading] = useState(false); 28 | const [results, setResults] = useState([]); 29 | const [query, setQuery] = useState(null); 30 | 31 | // lookup in items (no search function) 32 | useEffect(() => { 33 | if (!items) { 34 | return; 35 | } 36 | if (trigger === null) { 37 | setResults([]); 38 | setQuery(null); 39 | return; 40 | } 41 | const mentions = Object.entries(items).find(([key]) => { 42 | return new RegExp(key).test(trigger); 43 | }); 44 | if (!mentions) { 45 | return; 46 | } 47 | const result = !queryString 48 | ? [...mentions[1]] 49 | : mentions[1].filter((item) => { 50 | const value = typeof item === "string" ? item : item.value; 51 | return value.toLowerCase().includes(queryString.toLowerCase()); 52 | }); 53 | setResults(result); 54 | setQuery(queryString); 55 | }, [items, trigger, queryString]); 56 | 57 | // lookup by calling onSearch 58 | useEffect(() => { 59 | if (!onSearch) { 60 | return; 61 | } 62 | if (trigger === null || debouncedQueryString === null) { 63 | setResults([]); 64 | setQuery(null); 65 | return; 66 | } 67 | setLoading(true); 68 | setQuery(debouncedQueryString); 69 | onSearch(trigger, justSelectedAnOption?.current ? "" : debouncedQueryString) 70 | .then((result) => { 71 | setResults(result); 72 | }) 73 | .finally(() => { 74 | setLoading(false); 75 | }); 76 | 77 | if (justSelectedAnOption?.current) { 78 | justSelectedAnOption.current = false; 79 | } 80 | }, [debouncedQueryString, onSearch, trigger, justSelectedAnOption]); 81 | 82 | return useMemo( 83 | () => ({ loading, results, query }), 84 | [loading, results, query], 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /plugin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["lib", "node_modules", "**/*.spec.ts", "**/*.spec.tsx"] 4 | } 5 | -------------------------------------------------------------------------------- /plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "lib": ["ES2015", "dom"], 5 | "module": "ESNext", 6 | "target": "es6", 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "isolatedModules": true, 10 | "moduleResolution": "node", 11 | "preserveWatchOutput": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | }, 15 | "include": ["./src"], 16 | "exclude": ["lib", "node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /plugin/vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: "happy-dom", 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /resources/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sodenn/lexical-beautiful-mentions/fd514fba7f7184180688376879ea3922966c2a59/resources/screenshot1.png -------------------------------------------------------------------------------- /resources/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sodenn/lexical-beautiful-mentions/fd514fba7f7184180688376879ea3922966c2a59/resources/screenshot2.png -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": ["**/.env.*local"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["lib/**", ".next/**", "!.next/cache/**"] 8 | }, 9 | "lint": {}, 10 | "fmt": {}, 11 | "test": {}, 12 | "e2e": { 13 | "cache": false 14 | }, 15 | "typecheck": {}, 16 | "hygiene": {}, 17 | "release": {}, 18 | "dev": { 19 | "cache": false, 20 | "persistent": true 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | # testing 2 | /test-results/ 3 | /playwright-report/ 4 | /playwright/.cache/ 5 | *-darwin.png 6 | 7 | # next.js 8 | .next/ 9 | out/ 10 | build -------------------------------------------------------------------------------- /www/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /www/Dockerfile.e2e: -------------------------------------------------------------------------------- 1 | # 1. Build image by running: 2 | # docker build -f Dockerfile.e2e -t playwright-docker . 3 | # 2. Start the dev server on host machine 4 | # 3. Update playwright snapshots by running: 5 | # docker run -it --rm -v "$(pwd)/tests:/tests" playwright-docker 6 | FROM mcr.microsoft.com/playwright:v1.52.0-jammy 7 | 8 | COPY playwright.config.ts ./playwright.config.ts 9 | # Remove webServer config from playwright config 10 | RUN sed -i '/webServer: {/,/},/d' ./playwright.config.ts 11 | 12 | RUN echo "{\n" \ 13 | " \"name\": \"e2e\"\n" \ 14 | "}" > package.json 15 | RUN npx playwright install 16 | RUN npm install -D @playwright/test 17 | 18 | ENV HOST=host.docker.internal 19 | 20 | CMD ["npx", "playwright", "test", "--reporter=line", "-g", "should dynamically position the menu", "--update-snapshots"] 21 | -------------------------------------------------------------------------------- /www/app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin 'tailwindcss-animate'; 4 | @plugin '@tailwindcss/forms'; 5 | 6 | @custom-variant dark (&:is(.dark *)); 7 | 8 | @theme { 9 | --color-border: hsl(var(--border)); 10 | --color-input: hsl(var(--input)); 11 | --color-ring: hsl(var(--ring)); 12 | --color-background: hsl(var(--background)); 13 | --color-foreground: hsl(var(--foreground)); 14 | 15 | --color-primary: hsl(var(--primary)); 16 | --color-primary-foreground: hsl(var(--primary-foreground)); 17 | 18 | --color-secondary: hsl(var(--secondary)); 19 | --color-secondary-foreground: hsl(var(--secondary-foreground)); 20 | 21 | --color-destructive: hsl(var(--destructive)); 22 | --color-destructive-foreground: hsl(var(--destructive-foreground)); 23 | 24 | --color-muted: hsl(var(--muted)); 25 | --color-muted-foreground: hsl(var(--muted-foreground)); 26 | 27 | --color-accent: hsl(var(--accent)); 28 | --color-accent-foreground: hsl(var(--accent-foreground)); 29 | 30 | --color-popover: hsl(var(--popover)); 31 | --color-popover-foreground: hsl(var(--popover-foreground)); 32 | 33 | --color-card: hsl(var(--card)); 34 | --color-card-foreground: hsl(var(--card-foreground)); 35 | 36 | --radius-lg: var(--radius); 37 | --radius-md: calc(var(--radius) - 2px); 38 | --radius-sm: calc(var(--radius) - 4px); 39 | 40 | --animate-accordion-down: accordion-down 0.2s ease-out; 41 | --animate-accordion-up: accordion-up 0.2s ease-out; 42 | 43 | @keyframes accordion-down { 44 | from { 45 | height: 0; 46 | } 47 | to { 48 | height: var(--radix-accordion-content-height); 49 | } 50 | } 51 | @keyframes accordion-up { 52 | from { 53 | height: var(--radix-accordion-content-height); 54 | } 55 | to { 56 | height: 0; 57 | } 58 | } 59 | } 60 | 61 | @utility container { 62 | margin-inline: auto; 63 | padding-inline: 2rem; 64 | @media (width >= --theme(--breakpoint-sm)) { 65 | max-width: none; 66 | } 67 | @media (width >= 1400px) { 68 | max-width: 1400px; 69 | } 70 | } 71 | 72 | /* 73 | The default border color has changed to `currentColor` in Tailwind CSS v4, 74 | so we've added these compatibility styles to make sure everything still 75 | looks the same as it did with Tailwind CSS v3. 76 | 77 | If we ever want to remove these styles, we need to add an explicit border 78 | color utility to any element that depends on these defaults. 79 | */ 80 | @layer base { 81 | *, 82 | ::after, 83 | ::before, 84 | ::backdrop, 85 | ::file-selector-button { 86 | border-color: var(--color-gray-200, currentColor); 87 | } 88 | } 89 | 90 | @layer base { 91 | :root { 92 | --background: 0 0% 100%; 93 | --foreground: 222.2 84% 4.9%; 94 | --card: 0 0% 100%; 95 | --card-foreground: 222.2 84% 4.9%; 96 | --popover: 0 0% 100%; 97 | --popover-foreground: 222.2 84% 4.9%; 98 | --primary: 221.2 83.2% 53.3%; 99 | --primary-foreground: 210 40% 98%; 100 | --secondary: 210 40% 96.1%; 101 | --secondary-foreground: 222.2 47.4% 11.2%; 102 | --muted: 210 40% 96.1%; 103 | --muted-foreground: 215.4 16.3% 46.9%; 104 | --accent: 210 40% 96.1%; 105 | --accent-foreground: 222.2 47.4% 11.2%; 106 | --destructive: 0 84.2% 60.2%; 107 | --destructive-foreground: 210 40% 98%; 108 | --border: 214.3 31.8% 91.4%; 109 | --input: 214.3 31.8% 91.4%; 110 | --ring: 221.2 83.2% 53.3%; 111 | --radius: 0.5rem; 112 | } 113 | 114 | .dark { 115 | --background: 222.2 84% 4.9%; 116 | --foreground: 210 40% 98%; 117 | --card: 222.2 84% 4.9%; 118 | --card-foreground: 210 40% 98%; 119 | --popover: 222.2 84% 4.9%; 120 | --popover-foreground: 210 40% 98%; 121 | --primary: 217.2 91.2% 59.8%; 122 | --primary-foreground: 222.2 47.4% 11.2%; 123 | --secondary: 217.2 32.6% 17.5%; 124 | --secondary-foreground: 210 40% 98%; 125 | --muted: 217.2 32.6% 17.5%; 126 | --muted-foreground: 215 20.2% 65.1%; 127 | --accent: 217.2 32.6% 17.5%; 128 | --accent-foreground: 210 40% 98%; 129 | --destructive: 0 62.8% 30.6%; 130 | --destructive-foreground: 210 40% 98%; 131 | --border: 217.2 32.6% 17.5%; 132 | --input: 217.2 32.6% 17.5%; 133 | --ring: 224.3 76.3% 48%; 134 | } 135 | } 136 | 137 | @layer base { 138 | * { 139 | @apply border-border; 140 | } 141 | body { 142 | @apply bg-background text-foreground; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /www/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/Navbar"; 2 | import { ThemeProvider } from "@/components/ThemeProvider"; 3 | import { TooltipProvider } from "@/components/ui/Tooltip"; 4 | import type { Metadata } from "next"; 5 | import { Inter } from "next/font/google"; 6 | import { ReactNode } from "react"; 7 | import "./globals.css"; 8 | 9 | export const metadata: Metadata = { 10 | title: "lexical-beautiful-mentions", 11 | description: "A mentions plugin for the lexical editor.", 12 | }; 13 | 14 | const inter = Inter({ subsets: ["latin"] }); 15 | 16 | export default function RootLayout({ children }: { children: ReactNode }) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 |
24 | {children} 25 |
26 |
27 |
28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /www/app/page.tsx: -------------------------------------------------------------------------------- 1 | import ConfigurationProvider from "@/components/ConfigurationProvider"; 2 | import Editor from "@/components/Editor"; 3 | import { Suspense } from "react"; 4 | 5 | export default function Home() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /www/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /www/components/Combobox.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { 3 | BeautifulMentionsComboboxItemProps, 4 | BeautifulMentionsComboboxProps, 5 | } from "lexical-beautiful-mentions"; 6 | import { forwardRef } from "react"; 7 | 8 | /** 9 | * Combobox component for the BeautifulMentionsPlugin. 10 | */ 11 | export const Combobox = forwardRef( 12 | ({ optionType, loading, ...other }, ref) => { 13 | if (loading) { 14 | return ( 15 |
19 |
Loading...
20 |
21 | ); 22 | } 23 | return ( 24 |
    33 | ); 34 | }, 35 | ); 36 | Combobox.displayName = "Combobox"; 37 | 38 | /** 39 | * ComboboxItem component for the BeautifulMentionsPlugin. 40 | */ 41 | export const ComboboxItem = forwardRef< 42 | HTMLLIElement, 43 | BeautifulMentionsComboboxItemProps 44 | >(({ selected, item, ...props }, ref) => ( 45 | <> 46 | {item.data.dividerTop && ( 47 |
    48 |
    49 |
    50 | )} 51 |
  • 59 | 60 | )); 61 | ComboboxItem.displayName = "ComboboxItem"; 62 | -------------------------------------------------------------------------------- /www/components/ConfigurationProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // prettier-ignore 2 | import { defaultInitialValue } from "@/lib/editor-config"; 3 | import useQueryParams, { QueryParam } from "@/lib/useQueryParams"; 4 | import { 5 | createContext, 6 | PropsWithChildren, 7 | useCallback, 8 | useContext, 9 | useMemo, 10 | useState, 11 | } from "react"; 12 | import sanitizeHtml from "sanitize-html"; 13 | 14 | const ConfigurationCtx = 15 | createContext>(undefined); 16 | 17 | const creatableMap = { 18 | "@": 'Add user "{{name}}"', 19 | "#": 'Add tag "{{name}}"', 20 | "due:": 'Add due date "{{name}}"', 21 | "rec:": 'Add recurrence "{{name}}"', 22 | }; 23 | 24 | function useConfigurationValue() { 25 | const { setQueryParams, hasQueryParams, getQueryParam } = useQueryParams(); 26 | const [asynchronous, _setAsynchronous] = useState( 27 | getQueryParam("async") === "true", 28 | ); 29 | const [allowSpaces, _setAllowSpaces] = useState( 30 | getQueryParam("aspace") === "true", 31 | ); 32 | const [autoSpace, _setAutoSpace] = useState( 33 | getQueryParam("ospace") === "true", 34 | ); 35 | const [creatable, _setCreatable] = useState(getQueryParam("new") === "true"); 36 | const [insertOnBlur, _setInsertOnBlur] = useState( 37 | getQueryParam("blur") === "true", 38 | ); 39 | const [emptyComponent, _setEmptyComponent] = useState( 40 | getQueryParam("empty") === "true", 41 | ); 42 | const [combobox, _setCombobox] = useState( 43 | getQueryParam("combobox") === "true", 44 | ); 45 | const [comboboxAdditionalItems, _setComboboxAdditionalItems] = useState( 46 | getQueryParam("cbai") === "true", 47 | ); 48 | const [mentionEnclosure, _setMentionEnclosure] = useState( 49 | getQueryParam("enclosure") === "true", 50 | ); 51 | const [showMentionsOnDelete, _setShowMentionsOnDelete] = useState( 52 | getQueryParam("mentions") === "true", 53 | ); 54 | const [customMentionNode, _setCustomMentionNode] = useState( 55 | getQueryParam("cstmn") === "true", 56 | ); 57 | const commandFocus = getQueryParam("cf") === "true"; 58 | const focusParam = getQueryParam("focus"); 59 | const valueParam = getQueryParam("value"); 60 | const hasValue = hasQueryParams("value"); 61 | const initialValue = 62 | sanitizeHtml(valueParam) || (hasValue ? "" : defaultInitialValue); 63 | const autoFocus: "rootStart" | "rootEnd" | "none" = 64 | focusParam === "start" 65 | ? "rootStart" 66 | : focusParam === "none" 67 | ? "none" 68 | : "rootEnd"; 69 | 70 | const setAsynchronous = useCallback( 71 | (asynchronous: boolean) => { 72 | _setAsynchronous(asynchronous); 73 | setQueryParams([{ name: "async", value: asynchronous.toString() }]); 74 | }, 75 | [setQueryParams], 76 | ); 77 | 78 | const setCombobox = useCallback( 79 | (combobox: boolean) => { 80 | _setCombobox(combobox); 81 | const newParams: QueryParam[] = [ 82 | { name: "combobox", value: combobox.toString() }, 83 | ]; 84 | if (combobox && insertOnBlur) { 85 | _setInsertOnBlur(false); 86 | newParams.push({ name: "blur", value: "false" }); 87 | } 88 | if (combobox && emptyComponent) { 89 | _setEmptyComponent(false); 90 | newParams.push({ name: "empty", value: "false" }); 91 | } 92 | if (!combobox && comboboxAdditionalItems) { 93 | _setComboboxAdditionalItems(false); 94 | newParams.push({ 95 | name: "cbai", 96 | value: "false", 97 | }); 98 | } 99 | setQueryParams(newParams); 100 | }, 101 | [insertOnBlur, emptyComponent, comboboxAdditionalItems, setQueryParams], 102 | ); 103 | 104 | const setComboboxAdditionalItems = useCallback( 105 | (comboboxAdditionalItems: boolean) => { 106 | _setComboboxAdditionalItems(comboboxAdditionalItems); 107 | setQueryParams([ 108 | { name: "cbai", value: comboboxAdditionalItems.toString() }, 109 | ]); 110 | }, 111 | [setQueryParams], 112 | ); 113 | 114 | const setMentionEnclosure = useCallback( 115 | (mentionEnclosure: boolean) => { 116 | _setMentionEnclosure(mentionEnclosure); 117 | setQueryParams([ 118 | { name: "enclosure", value: mentionEnclosure.toString() }, 119 | ]); 120 | }, 121 | [setQueryParams], 122 | ); 123 | 124 | const setShowMentionsOnDelete = useCallback( 125 | (showMentionsOnDelete: boolean) => { 126 | _setShowMentionsOnDelete(showMentionsOnDelete); 127 | setQueryParams([ 128 | { name: "mentions", value: showMentionsOnDelete.toString() }, 129 | ]); 130 | }, 131 | [setQueryParams], 132 | ); 133 | 134 | const setCustomMentionNode = useCallback( 135 | (customMentionNode: boolean) => { 136 | _setCustomMentionNode(customMentionNode); 137 | setQueryParams([{ name: "cstmn", value: customMentionNode.toString() }]); 138 | setTimeout(() => { 139 | window.location.reload(); 140 | }, 100); 141 | }, 142 | [setQueryParams], 143 | ); 144 | 145 | const setAllowSpaces = useCallback( 146 | (allowSpaces: boolean) => { 147 | _setAllowSpaces(allowSpaces); 148 | setQueryParams([{ name: "aspace", value: allowSpaces.toString() }]); 149 | }, 150 | [setQueryParams], 151 | ); 152 | 153 | const setAutoSpace = useCallback( 154 | (autoSpace: boolean) => { 155 | _setAutoSpace(autoSpace); 156 | setQueryParams([{ name: "ospace", value: autoSpace.toString() }]); 157 | }, 158 | [setQueryParams], 159 | ); 160 | 161 | const setCreatable = useCallback( 162 | (creatable: boolean) => { 163 | _setCreatable(creatable); 164 | setQueryParams([{ name: "new", value: creatable.toString() }]); 165 | }, 166 | [setQueryParams], 167 | ); 168 | 169 | const setInsertOnBlur = useCallback( 170 | (insertOnBlur: boolean) => { 171 | _setInsertOnBlur(insertOnBlur); 172 | setQueryParams([{ name: "blur", value: insertOnBlur.toString() }]); 173 | }, 174 | [setQueryParams], 175 | ); 176 | 177 | const setEmptyComponent = useCallback( 178 | (emptyComponent: boolean) => { 179 | _setEmptyComponent(emptyComponent); 180 | setQueryParams([{ name: "empty", value: emptyComponent.toString() }]); 181 | }, 182 | [setQueryParams], 183 | ); 184 | 185 | return useMemo( 186 | () => ({ 187 | initialValue, 188 | autoFocus, 189 | autoSpace, 190 | asynchronous, 191 | combobox, 192 | comboboxAdditionalItems, 193 | setComboboxAdditionalItems, 194 | mentionEnclosure: mentionEnclosure ? '"' : undefined, 195 | showMentionsOnDelete, 196 | customMentionNode, 197 | allowSpaces, 198 | creatable: creatable ? creatableMap : false, 199 | insertOnBlur, 200 | emptyComponent, 201 | setAsynchronous, 202 | setAllowSpaces, 203 | setAutoSpace, 204 | setCreatable, 205 | setInsertOnBlur, 206 | setEmptyComponent, 207 | setCombobox, 208 | setMentionEnclosure, 209 | setShowMentionsOnDelete, 210 | setCustomMentionNode, 211 | commandFocus, 212 | }), 213 | [ 214 | allowSpaces, 215 | autoSpace, 216 | asynchronous, 217 | autoFocus, 218 | combobox, 219 | comboboxAdditionalItems, 220 | commandFocus, 221 | creatable, 222 | customMentionNode, 223 | initialValue, 224 | insertOnBlur, 225 | emptyComponent, 226 | mentionEnclosure, 227 | setAllowSpaces, 228 | setAutoSpace, 229 | setAsynchronous, 230 | setCombobox, 231 | setComboboxAdditionalItems, 232 | setCreatable, 233 | setInsertOnBlur, 234 | setEmptyComponent, 235 | setMentionEnclosure, 236 | setShowMentionsOnDelete, 237 | showMentionsOnDelete, 238 | setCustomMentionNode, 239 | ], 240 | ); 241 | } 242 | 243 | const ConfigurationProvider = ({ children }: PropsWithChildren) => { 244 | const value = useConfigurationValue(); 245 | return ( 246 | 247 | {children} 248 | 249 | ); 250 | }; 251 | 252 | export function useConfiguration() { 253 | const context = useContext(ConfigurationCtx); 254 | if (context === undefined) { 255 | throw new Error( 256 | "useConfiguration must be used within a ConfigurationProvider", 257 | ); 258 | } 259 | return context; 260 | } 261 | 262 | export default ConfigurationProvider; 263 | -------------------------------------------------------------------------------- /www/components/CustomMentionComponent.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipTrigger, 5 | } from "@/components/ui/Tooltip"; 6 | import { BeautifulMentionComponentProps } from "lexical-beautiful-mentions"; 7 | import { forwardRef } from "react"; 8 | 9 | const CustomMentionComponent = forwardRef< 10 | HTMLSpanElement, 11 | BeautifulMentionComponentProps<{ id: string }> 12 | >(({ trigger, value, data, children, ...other }, ref) => { 13 | return ( 14 | 15 | 16 | 17 | {value} 18 | 19 | 20 | 21 |

    22 | Trigger: {trigger} 23 |

    24 |

    25 | Value: {value} 26 |

    27 | {data?.id && ( 28 |

    29 | ID: {data.id} 30 |

    31 | )} 32 |
    33 |
    34 | ); 35 | }); 36 | CustomMentionComponent.displayName = "CustomMentionComponent"; 37 | 38 | export default CustomMentionComponent; 39 | -------------------------------------------------------------------------------- /www/components/Editor.css: -------------------------------------------------------------------------------- 1 | ul::-webkit-scrollbar { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /www/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // prettier-ignore 2 | import { Combobox, ComboboxItem } from "@/components/Combobox"; 3 | import { useConfiguration } from "@/components/ConfigurationProvider"; 4 | import { Empty } from "@/components/Empty"; 5 | import { MentionsToolbarPlugin } from "@/components/MentionsToolbarPlugin"; 6 | import { Menu, MenuItem } from "@/components/Menu"; 7 | import { Placeholder } from "@/components/Placeholder"; 8 | import editorConfig from "@/lib/editor-config"; 9 | import { getDebugTextContent, useIsFocused } from "@/lib/editor-utils"; 10 | import { cn } from "@/lib/utils"; 11 | import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; 12 | import { LexicalComposer } from "@lexical/react/LexicalComposer"; 13 | import { ContentEditable } from "@lexical/react/LexicalContentEditable"; 14 | import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; 15 | import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; 16 | import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; 17 | import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; 18 | import { $getRoot, EditorState } from "lexical"; 19 | import { 20 | BeautifulMentionsComboboxItem, 21 | BeautifulMentionsItem, 22 | BeautifulMentionsPlugin, 23 | BeautifulMentionsPluginProps, 24 | } from "lexical-beautiful-mentions"; 25 | import { useCallback, useMemo, useRef, useState } from "react"; 26 | import "./Editor.css"; 27 | 28 | const mentionItems: Record = { 29 | "@": [ 30 | "Anton", 31 | "Boris", 32 | "Catherine", 33 | "Dmitri", 34 | "Elena", 35 | "Felix", 36 | { value: "Gina", id: "1", avatar: null }, 37 | { value: "Gina", id: "2", avatar: "https://example.com/avatars/1.jpg" }, 38 | ], 39 | "#": ["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"], 40 | "due:": ["Today", "Tomorrow", "01-01-2023"], 41 | "rec:": ["week", "month", "year"], 42 | "\\w+:": [], 43 | }; 44 | 45 | const queryMentions = async ( 46 | trigger: string, 47 | queryString: string, 48 | asynchronous: boolean, 49 | ) => { 50 | const items = mentionItems[trigger]; 51 | if (!items) { 52 | return []; 53 | } 54 | if (asynchronous) { 55 | await new Promise((resolve) => setTimeout(resolve, 500)); 56 | } 57 | return items.filter((item) => { 58 | const value = typeof item === "string" ? item : item.value; 59 | return value.toLowerCase().includes(queryString.toLowerCase()); 60 | }); 61 | }; 62 | 63 | export default function Editor() { 64 | const { initialValue, customMentionNode } = useConfiguration(); 65 | return ( 66 |
    67 | 74 | 75 | 76 |
    77 | ); 78 | } 79 | 80 | function Plugins() { 81 | const [value, setValue] = useState(); 82 | const { 83 | asynchronous, 84 | autoFocus, 85 | autoSpace, 86 | allowSpaces, 87 | creatable, 88 | insertOnBlur, 89 | combobox, 90 | mentionEnclosure, 91 | showMentionsOnDelete, 92 | emptyComponent, 93 | comboboxAdditionalItems: _comboboxAdditionalItems, 94 | } = useConfiguration(); 95 | const comboboxAnchor = useRef(null); 96 | const [menuOrComboboxOpen, setMenuOrComboboxOpen] = useState(false); 97 | const [comboboxItemSelected, setComboboxItemSelected] = useState(false); 98 | const focused = useIsFocused(); 99 | const triggers = useMemo( 100 | () => 101 | combobox 102 | ? Object.keys(mentionItems).filter((k) => k !== "\\w+:") 103 | : Object.keys(mentionItems), 104 | [combobox], 105 | ); 106 | const comboboxAdditionalItems = useMemo( 107 | () => 108 | _comboboxAdditionalItems 109 | ? [ 110 | { 111 | value: "additionalItem", 112 | displayValue: "Additional Item", 113 | data: { dividerTop: true }, 114 | }, 115 | ] 116 | : [], 117 | [_comboboxAdditionalItems], 118 | ); 119 | 120 | const handleChange = useCallback((editorState: EditorState) => { 121 | editorState.read(() => { 122 | const root = $getRoot(); 123 | const value = getDebugTextContent(root); 124 | setValue(value); 125 | }); 126 | }, []); 127 | 128 | const handleSearch = useCallback( 129 | (trigger: string, queryString: string) => 130 | queryMentions(trigger, queryString, asynchronous), 131 | [asynchronous], 132 | ); 133 | 134 | const handleMenuOrComboboxOpen = useCallback(() => { 135 | setMenuOrComboboxOpen(true); 136 | }, []); 137 | 138 | const handleMenuOrComboboxClose = useCallback(() => { 139 | setMenuOrComboboxOpen(false); 140 | }, []); 141 | 142 | const handleComboboxFocusChange = useCallback( 143 | (item: BeautifulMentionsComboboxItem | null) => { 144 | setComboboxItemSelected(item !== null); 145 | }, 146 | [], 147 | ); 148 | 149 | const handleComboboxItemSelect = useCallback( 150 | (item: BeautifulMentionsComboboxItem) => { 151 | if (item.itemType === "additional") { 152 | setMenuOrComboboxOpen(false); 153 | } 154 | }, 155 | [], 156 | ); 157 | 158 | const beautifulMentionsProps: BeautifulMentionsPluginProps = { 159 | mentionEnclosure, 160 | allowSpaces, 161 | autoSpace, 162 | creatable, 163 | showMentionsOnDelete, 164 | ...(asynchronous 165 | ? { 166 | onSearch: handleSearch, 167 | searchDelay: 250, 168 | triggers, 169 | } 170 | : { 171 | items: mentionItems, 172 | }), 173 | ...(combobox 174 | ? { 175 | combobox, 176 | triggers, 177 | comboboxOpen: menuOrComboboxOpen, 178 | comboboxAnchor: comboboxAnchor.current, 179 | comboboxAnchorClassName: 180 | "ring-2 ring-ring ring-offset-2 ring-offset-background rounded-md", 181 | comboboxComponent: Combobox, 182 | comboboxItemComponent: ComboboxItem, 183 | onComboboxOpen: handleMenuOrComboboxOpen, 184 | onComboboxClose: handleMenuOrComboboxClose, 185 | onComboboxFocusChange: handleComboboxFocusChange, 186 | comboboxAdditionalItems, 187 | onComboboxItemSelect: handleComboboxItemSelect, 188 | } 189 | : { 190 | menuComponent: Menu, 191 | menuItemComponent: MenuItem, 192 | emptyComponent: emptyComponent ? Empty : undefined, 193 | onMenuOpen: handleMenuOrComboboxOpen, 194 | onMenuClose: handleMenuOrComboboxClose, 195 | insertOnBlur, 196 | }), 197 | }; 198 | 199 | return ( 200 | <> 201 |
    210 | 223 | } 224 | placeholder={} 225 | ErrorBoundary={LexicalErrorBoundary} 226 | /> 227 | 228 | 229 | {autoFocus !== "none" && ( 230 | 231 | )} 232 | 233 |
    234 | 235 |
    236 | {value} 237 |
    238 |
    239 | {menuOrComboboxOpen.toString()} 240 |
    241 |
    242 | {comboboxItemSelected.toString()} 243 |
    244 | 245 | ); 246 | } 247 | -------------------------------------------------------------------------------- /www/components/Empty.tsx: -------------------------------------------------------------------------------- 1 | export function Empty() { 2 | return ( 3 |
    4 | No results found. 5 |
    6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /www/components/GithubButton.tsx: -------------------------------------------------------------------------------- 1 | import { buttonVariants } from "@/components/ui/Button"; 2 | import { cn } from "@/lib/utils"; 3 | import Link from "next/link"; 4 | import { SVGProps } from "react"; 5 | 6 | const GitHubIcon = (props: SVGProps) => ( 7 | 8 | 12 | 13 | ); 14 | 15 | export function GithubButton() { 16 | return ( 17 | 23 | 24 | GitHub 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /www/components/MentionsToolbarPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { useConfiguration } from "@/components/ConfigurationProvider"; 2 | import { Button } from "@/components/ui/Button"; 3 | import { Card } from "@/components/ui/Card"; 4 | import { Checkbox } from "@/components/ui/Checkbox"; 5 | import { Separator } from "@/components/ui/Separator"; 6 | import { CheckboxProps } from "@radix-ui/react-checkbox"; 7 | import { useBeautifulMentions } from "lexical-beautiful-mentions"; 8 | import { useCallback, useId } from "react"; 9 | 10 | function getRandomItem(array: T[]): T { 11 | const randomIndex = Math.floor(Math.random() * array.length); 12 | return array[randomIndex]; 13 | } 14 | 15 | interface ToolbarCheckboxProps { 16 | label: string; 17 | helperText: string; 18 | checked: boolean; 19 | onCheckedChange: CheckboxProps["onCheckedChange"]; 20 | } 21 | 22 | function ToolbarCheckbox({ 23 | label, 24 | helperText, 25 | checked, 26 | onCheckedChange, 27 | }: ToolbarCheckboxProps) { 28 | const id = useId(); 29 | return ( 30 |
    31 | 32 |
    33 | 42 |
    43 |
    44 | ); 45 | } 46 | 47 | export function MentionsToolbarPlugin() { 48 | const { 49 | openMentionMenu, 50 | renameMentions, 51 | removeMentions, 52 | insertMention, 53 | getMentions, 54 | } = useBeautifulMentions(); 55 | const { 56 | asynchronous, 57 | combobox, 58 | comboboxAdditionalItems, 59 | mentionEnclosure, 60 | showMentionsOnDelete, 61 | allowSpaces, 62 | autoSpace, 63 | creatable, 64 | insertOnBlur, 65 | emptyComponent, 66 | commandFocus, 67 | customMentionNode, 68 | setAsynchronous, 69 | setCombobox, 70 | setComboboxAdditionalItems, 71 | setMentionEnclosure, 72 | setAllowSpaces, 73 | setAutoSpace, 74 | setCreatable, 75 | setInsertOnBlur, 76 | setEmptyComponent, 77 | setShowMentionsOnDelete, 78 | setCustomMentionNode, 79 | } = useConfiguration(); 80 | 81 | const handleRemoveMentions = useCallback(() => { 82 | const mentions = getMentions(); 83 | if (!mentions.length) { 84 | return; 85 | } 86 | const randomMention = getRandomItem(mentions); 87 | removeMentions({ 88 | trigger: randomMention.trigger, 89 | value: randomMention.value, 90 | focus: commandFocus, 91 | }); 92 | }, [commandFocus, getMentions, removeMentions]); 93 | 94 | return ( 95 | 96 |
    97 | 104 | 117 | 124 | 137 |
    138 |
    139 |
    Flags
    140 | 146 | 152 | 158 | {!combobox && ( 159 | 165 | )} 166 | 172 | 178 | 179 | 185 | 191 | {combobox && ( 192 | 198 | )} 199 | 205 | {!combobox && ( 206 | 212 | )} 213 |
    214 |
    215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /www/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { 3 | BeautifulMentionsMenuItemProps, 4 | BeautifulMentionsMenuProps, 5 | } from "lexical-beautiful-mentions"; 6 | import { forwardRef } from "react"; 7 | 8 | /** 9 | * Menu component for the BeautifulMentionsPlugin. 10 | */ 11 | export function Menu({ loading, ...other }: BeautifulMentionsMenuProps) { 12 | if (loading) { 13 | return ( 14 |
    15 | Loading... 16 |
    17 | ); 18 | } 19 | return ( 20 |
      28 | ); 29 | } 30 | 31 | /** 32 | * MenuItem component for the BeautifulMentionsPlugin. 33 | */ 34 | export const MenuItem = forwardRef< 35 | HTMLLIElement, 36 | BeautifulMentionsMenuItemProps 37 | >(({ selected, item, itemValue, ...props }, ref) => ( 38 |
    • 46 | )); 47 | MenuItem.displayName = "MenuItem"; 48 | -------------------------------------------------------------------------------- /www/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/Button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/DropdownMenu"; 9 | import { Moon, Sun } from "lucide-react"; 10 | import { useTheme } from "next-themes"; 11 | 12 | export function ModeToggle() { 13 | const { setTheme } = useTheme(); 14 | return ( 15 | 16 | 17 | 22 | 23 | 24 | setTheme("light")}> 25 | Light 26 | 27 | setTheme("dark")}> 28 | Dark 29 | 30 | setTheme("system")}> 31 | System 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /www/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { GithubButton } from "@/components/GithubButton"; 2 | import { ModeToggle } from "@/components/ModeToggle"; 3 | 4 | export function Navbar() { 5 | return ( 6 |
      7 |
      8 |

      9 | lexical-beautiful-mentions 10 |

      11 |
      12 | 13 | 14 |
      15 |
      16 |
      17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /www/components/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Placeholder for the editor. 3 | */ 4 | export function Placeholder() { 5 | return ( 6 |
      7 | Enter some plain text... 8 |
      9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /www/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | ThemeProvider as NextThemesProvider, 4 | ThemeProviderProps, 5 | } from "next-themes"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /www/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import * as React from "react"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline", 20 | }, 21 | size: { 22 | default: "h-10 px-4 py-2", 23 | sm: "h-9 rounded-md px-3", 24 | lg: "h-11 rounded-md px-8", 25 | icon: "h-10 w-10", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default", 31 | }, 32 | }, 33 | ); 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | asChild?: boolean; 39 | } 40 | 41 | const Button = React.forwardRef( 42 | ({ className, variant, size, asChild = false, ...props }, ref) => { 43 | const Comp = asChild ? Slot : "button"; 44 | return ( 45 | 50 | ); 51 | }, 52 | ); 53 | Button.displayName = "Button"; 54 | 55 | export { Button, buttonVariants }; 56 | -------------------------------------------------------------------------------- /www/components/ui/Card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import * as React from "react"; 3 | 4 | const Card = React.forwardRef< 5 | HTMLDivElement, 6 | React.HTMLAttributes 7 | >(({ className, ...props }, ref) => ( 8 |
      16 | )); 17 | Card.displayName = "Card"; 18 | 19 | const CardHeader = React.forwardRef< 20 | HTMLDivElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 |
      28 | )); 29 | CardHeader.displayName = "CardHeader"; 30 | 31 | const CardTitle = React.forwardRef< 32 | HTMLParagraphElement, 33 | React.HTMLAttributes 34 | >(({ className, ...props }, ref) => ( 35 |

      43 | )); 44 | CardTitle.displayName = "CardTitle"; 45 | 46 | const CardDescription = React.forwardRef< 47 | HTMLParagraphElement, 48 | React.HTMLAttributes 49 | >(({ className, ...props }, ref) => ( 50 |

      55 | )); 56 | CardDescription.displayName = "CardDescription"; 57 | 58 | const CardContent = React.forwardRef< 59 | HTMLDivElement, 60 | React.HTMLAttributes 61 | >(({ className, ...props }, ref) => ( 62 |

      63 | )); 64 | CardContent.displayName = "CardContent"; 65 | 66 | const CardFooter = React.forwardRef< 67 | HTMLDivElement, 68 | React.HTMLAttributes 69 | >(({ className, ...props }, ref) => ( 70 |
      75 | )); 76 | CardFooter.displayName = "CardFooter"; 77 | 78 | export { 79 | Card, 80 | CardContent, 81 | CardDescription, 82 | CardFooter, 83 | CardHeader, 84 | CardTitle, 85 | }; 86 | -------------------------------------------------------------------------------- /www/components/ui/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { cn } from "@/lib/utils"; 3 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 4 | import { Check } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )); 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 27 | 28 | export { Checkbox }; 29 | -------------------------------------------------------------------------------- /www/components/ui/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { cn } from "@/lib/utils"; 3 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 4 | import { Check, ChevronRight, Circle } from "lucide-react"; 5 | import * as React from "react"; 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root; 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean; 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )); 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName; 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )); 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName; 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )); 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean; 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )); 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )); 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName; 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )); 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean; 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )); 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )); 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ); 179 | }; 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuCheckboxItem, 185 | DropdownMenuContent, 186 | DropdownMenuGroup, 187 | DropdownMenuItem, 188 | DropdownMenuLabel, 189 | DropdownMenuPortal, 190 | DropdownMenuRadioGroup, 191 | DropdownMenuRadioItem, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuTrigger, 198 | }; 199 | -------------------------------------------------------------------------------- /www/components/ui/Separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { cn } from "@/lib/utils"; 3 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 4 | import * as React from "react"; 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref, 13 | ) => ( 14 | 25 | ), 26 | ); 27 | Separator.displayName = SeparatorPrimitive.Root.displayName; 28 | 29 | export { Separator }; 30 | -------------------------------------------------------------------------------- /www/components/ui/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { cn } from "@/lib/utils"; 3 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 4 | import * as React from "react"; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = TooltipPrimitive.Root; 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger; 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )); 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 27 | 28 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; 29 | -------------------------------------------------------------------------------- /www/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from "@eslint/eslintrc"; 2 | import js from "@eslint/js"; 3 | import playwright from "eslint-plugin-playwright"; 4 | import path from "node:path"; 5 | import { fileURLToPath } from "node:url"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const compat = new FlatCompat({ 10 | baseDirectory: __dirname, 11 | recommendedConfig: js.configs.recommended, 12 | allConfig: js.configs.all, 13 | }); 14 | 15 | export default [ 16 | ...compat.extends("next"), 17 | { 18 | ...playwright.configs["flat/recommended"], 19 | files: ["tests/**"], 20 | }, 21 | { 22 | rules: { 23 | "@next/next/no-html-link-for-pages": "off", 24 | "import/no-anonymous-default-export": "off", 25 | }, 26 | }, 27 | ]; 28 | -------------------------------------------------------------------------------- /www/lib/editor-config.ts: -------------------------------------------------------------------------------- 1 | import CustomMentionComponent from "@/components/CustomMentionComponent"; 2 | import theme from "@/lib/editor-theme"; 3 | import { InitialConfigType } from "@lexical/react/LexicalComposer"; 4 | import { $createParagraphNode, $getRoot } from "lexical"; 5 | import { 6 | $convertToMentionNodes, 7 | BeautifulMentionNode, 8 | PlaceholderNode, 9 | createBeautifulMentionNode, 10 | } from "lexical-beautiful-mentions"; 11 | 12 | export const defaultInitialValue = 13 | "Hey @John, the task is #urgent and due:tomorrow"; 14 | 15 | function setEditorState(initialValue: string, triggers: string[]) { 16 | return () => { 17 | const root = $getRoot(); 18 | if (root.getFirstChild() === null) { 19 | const paragraph = $createParagraphNode(); 20 | paragraph.append(...$convertToMentionNodes(initialValue, triggers)); 21 | root.append(paragraph); 22 | } 23 | }; 24 | } 25 | 26 | const [CustomBeautifulMentionNode, replacement] = createBeautifulMentionNode( 27 | CustomMentionComponent, 28 | ); 29 | 30 | const editorConfig = ( 31 | triggers: string[], 32 | initialValue: string, 33 | customMentionNode: boolean, 34 | ): InitialConfigType => ({ 35 | namespace: "", 36 | theme, 37 | onError(error: any) { 38 | throw error; 39 | }, 40 | editorState: setEditorState(initialValue, triggers), 41 | nodes: [ 42 | ...(customMentionNode 43 | ? [CustomBeautifulMentionNode, replacement] 44 | : [BeautifulMentionNode]), 45 | PlaceholderNode, 46 | ], 47 | }); 48 | 49 | export default editorConfig; 50 | -------------------------------------------------------------------------------- /www/lib/editor-theme.ts: -------------------------------------------------------------------------------- 1 | import { BeautifulMentionsTheme } from "lexical-beautiful-mentions"; 2 | 3 | const mentionsStyle = 4 | "px-1 mx-2/3 mx-px align-baseline inline-block rounded break-words cursor-pointer leading-5"; 5 | const mentionsStyleFocused = "ring-2 ring-offset-1"; 6 | 7 | const beautifulMentionsTheme: BeautifulMentionsTheme = { 8 | "@": `${mentionsStyle} dark:bg-green-500 bg-green-600 text-accent`, 9 | "@Focused": `${mentionsStyleFocused} dark:ring-green-500 ring-green-600 ring-offset-background`, 10 | "#": `${mentionsStyle} dark:bg-blue-400 bg-blue-600 text-accent`, 11 | "#Focused": `${mentionsStyleFocused} dark:ring-blue-400 ring-blue-600 ring-offset-background`, 12 | "due:": `${mentionsStyle} dark:bg-yellow-400 bg-yellow-600 text-accent`, 13 | "due:Focused": `${mentionsStyleFocused} dark:ring-yellow-400 ring-yellow-600 ring-offset-background`, 14 | // 👇 use a configuration object if you need to apply different styles to trigger and value 15 | "rec:": { 16 | trigger: "text-blue-500", 17 | value: "text-orange-500", 18 | container: "mx-[2px] px-[4px] rounded border border-muted cursor-pointer", 19 | containerFocused: 20 | "mx-[2px] px-[4px] rounded border border-muted cursor-pointer", 21 | }, 22 | "\\w+:": `${mentionsStyle} dark:bg-gray-400 bg-gray-500 text-accent`, 23 | "\\w+:Focused": `${mentionsStyleFocused} dark:ring-gray-400 ring-gray-500 ring-offset-background`, 24 | }; 25 | 26 | const theme = { 27 | ltr: "text-left", 28 | rtl: "text-right", 29 | beautifulMentions: beautifulMentionsTheme, 30 | }; 31 | 32 | export default theme; 33 | -------------------------------------------------------------------------------- /www/lib/editor-utils.ts: -------------------------------------------------------------------------------- 1 | import { CAN_USE_DOM } from "@/lib/utils"; 2 | import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 3 | import { mergeRegister } from "@lexical/utils"; 4 | import { 5 | $isElementNode, 6 | BLUR_COMMAND, 7 | COMMAND_PRIORITY_NORMAL, 8 | FOCUS_COMMAND, 9 | LexicalNode, 10 | } from "lexical"; 11 | import { $isBeautifulMentionNode } from "lexical-beautiful-mentions"; 12 | import { useLayoutEffect, useState } from "react"; 13 | 14 | export function getDebugTextContent(node: LexicalNode): string { 15 | let result = ""; 16 | 17 | if ($isElementNode(node)) { 18 | const children = node.getChildren(); 19 | for (const child of children) { 20 | result += getDebugTextContent(child); 21 | } 22 | } else if ($isBeautifulMentionNode(node)) { 23 | result += "[" + node.getTextContent() + "]"; 24 | } else { 25 | result += node.getTextContent(); 26 | } 27 | 28 | return result; 29 | } 30 | 31 | export function useIsFocused() { 32 | const [editor] = useLexicalComposerContext(); 33 | const [hasFocus, setHasFocus] = useState(() => 34 | CAN_USE_DOM ? editor.getRootElement() === document.activeElement : false, 35 | ); 36 | 37 | useLayoutEffect(() => { 38 | return mergeRegister( 39 | editor.registerCommand( 40 | FOCUS_COMMAND, 41 | () => { 42 | setHasFocus(true); 43 | return false; 44 | }, 45 | COMMAND_PRIORITY_NORMAL, 46 | ), 47 | editor.registerCommand( 48 | BLUR_COMMAND, 49 | () => { 50 | setHasFocus(false); 51 | return false; 52 | }, 53 | COMMAND_PRIORITY_NORMAL, 54 | ), 55 | ); 56 | }, [editor]); 57 | 58 | return hasFocus; 59 | } 60 | -------------------------------------------------------------------------------- /www/lib/useQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 2 | import { useCallback } from "react"; 3 | 4 | interface QueryParams { 5 | value: string; // initial value 6 | focus: string; // autoFocus 7 | cf: string; // focus after insert 8 | async: string; // onSearch 9 | aspace: string; // allowSpaces 10 | ospace: string; // autoSpace 11 | new: string; // creatable 12 | blur: string; // insertOnBlur 13 | combobox: string; // combobox 14 | mentions: string; // showMentionsOnDelete 15 | enclosure: string; // mentionEnclosure 16 | cbai: string; // comboboxAdditionalItems 17 | cstmn: string; // custom mention node 18 | empty: string; // noResults 19 | } 20 | 21 | export interface QueryParam { 22 | name: keyof QueryParams; 23 | value?: string; 24 | } 25 | 26 | export default function useQueryParams() { 27 | const router = useRouter(); 28 | const pathname = usePathname(); 29 | const searchParams = useSearchParams(); 30 | 31 | const setQueryParams = useCallback( 32 | (params: QueryParam[]) => { 33 | const newSearchParams = new URLSearchParams( 34 | Object.fromEntries(searchParams), 35 | ); 36 | params.forEach(({ name, value }) => { 37 | if (value === "false") { 38 | newSearchParams.delete(name); 39 | } else { 40 | newSearchParams.set(name, value); 41 | } 42 | }); 43 | const search = newSearchParams.toString(); 44 | const query = search ? `?${search}` : ""; 45 | router.push(`${pathname}${query}`); 46 | }, 47 | [pathname, router, searchParams], 48 | ); 49 | 50 | const hasQueryParams = useCallback( 51 | (name: keyof QueryParams) => searchParams.has(name), 52 | [searchParams], 53 | ); 54 | 55 | const getQueryParam = useCallback( 56 | (name: keyof QueryParams) => searchParams.get(name), 57 | [searchParams], 58 | ); 59 | 60 | return { 61 | setQueryParams, 62 | hasQueryParams, 63 | getQueryParam, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /www/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export const CAN_USE_DOM: boolean = 9 | typeof window !== "undefined" && 10 | typeof window.document !== "undefined" && 11 | typeof window.document.createElement !== "undefined"; 12 | -------------------------------------------------------------------------------- /www/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /www/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | transpilePackages: ["lexical-beautiful-mentions"], 5 | productionBrowserSourceMaps: true, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "www", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "UNLICENSED", 6 | "scripts": { 7 | "dev": "next dev --port 3000", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "typecheck": "tsc --noEmit --incremental false", 12 | "e2e": "playwright test --reporter=line", 13 | "e2e:update": "playwright test --reporter=line -g \"should dynamically position the menu\" --update-snapshots", 14 | "e2e:ui": "playwright test --reporter=line --ui", 15 | "e2e:record": "playwright codegen --device \"iPhone 11\" http://localhost:3000" 16 | }, 17 | "dependencies": { 18 | "@lexical/react": "0.31.2", 19 | "@lexical/utils": "0.31.2", 20 | "@radix-ui/react-checkbox": "1.3.2", 21 | "@radix-ui/react-dropdown-menu": "2.1.15", 22 | "@radix-ui/react-separator": "1.1.7", 23 | "@radix-ui/react-slot": "1.2.3", 24 | "@radix-ui/react-tooltip": "1.2.7", 25 | "@tailwindcss/forms": "0.5.10", 26 | "class-variance-authority": "0.7.1", 27 | "clsx": "2.1.1", 28 | "lexical": "0.31.2", 29 | "lexical-beautiful-mentions": "*", 30 | "lucide-react": "0.511.0", 31 | "next": "15.3.3", 32 | "next-themes": "0.4.6", 33 | "react": "19.1.0", 34 | "react-dom": "19.1.0", 35 | "sanitize-html": "2.17.0", 36 | "tailwind-merge": "3.3.0", 37 | "tailwindcss": "4.1.8", 38 | "tailwindcss-animate": "1.0.7" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "7.27.4", 42 | "@playwright/test": "1.52.0", 43 | "@types/dompurify": "3.2.0", 44 | "@types/node": "22.15.28", 45 | "@types/react": "19.1.6", 46 | "@types/react-dom": "19.1.5", 47 | "@types/sanitize-html": "2.16.0", 48 | "autoprefixer": "10.4.21", 49 | "eslint-config-next": "15.3.3", 50 | "eslint-plugin-playwright": "2.2.0", 51 | "playwright": "1.52.0", 52 | "postcss": "8.5.4", 53 | "@tailwindcss/postcss": "4.1.8" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /www/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: "./tests", 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 1 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: process.env.CI ? "blob" : "list", 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | // baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: "retain-on-failure", 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: "chromium", 37 | use: { ...devices["Desktop Chrome"] }, 38 | }, 39 | // 40 | // { 41 | // name: 'firefox', 42 | // use: { ...devices['Desktop Firefox'] }, 43 | // }, 44 | // 45 | // { 46 | // name: 'webkit', 47 | // use: { ...devices['Desktop Safari'] }, 48 | // }, 49 | // 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: "Mobile Safari", 57 | // use: { ...devices["iPhone 12"] }, 58 | // testMatch: process.env.CI ? [] : ["**/*.spec.ts"], 59 | // }, 60 | 61 | /* Test against branded browsers. */ 62 | // { 63 | // name: 'Microsoft Edge', 64 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 65 | // }, 66 | // { 67 | // name: 'Google Chrome', 68 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 69 | // }, 70 | ], 71 | 72 | /* Run your local dev server before starting the tests */ 73 | webServer: { 74 | command: "npm run dev", 75 | url: "http://127.0.0.1:3000", 76 | reuseExistingServer: !process.env.CI, 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /www/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /www/tests/commands.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { testUtils } from "./test-utils"; 3 | 4 | test.describe("Open Suggestions", () => { 5 | test("should open the menu at the end of the editor", async ({ 6 | page, 7 | browserName, 8 | }) => { 9 | const utils = await testUtils( 10 | { page, browserName }, 11 | { 12 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 13 | }, 14 | ); 15 | await expect(utils.mentionsMenu).not.toBeVisible(); 16 | await page.getByText("Open Suggestions").click(); 17 | await expect(utils.mentionsMenu).toBeVisible(); 18 | await utils.hasText( 19 | "Hey [@John], the task is [#urgent] and [due:tomorrow] @", 20 | ); 21 | }); 22 | 23 | test("should remove a mention and then open the menu", async ({ 24 | page, 25 | browserName, 26 | }) => { 27 | const utils = await testUtils( 28 | { page, browserName }, 29 | { 30 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 31 | }, 32 | ); 33 | await page.getByText("Remove Mention").click(); 34 | await utils.countMentions(2); 35 | await expect(utils.mentionsMenu).not.toBeVisible(); 36 | await page.getByText("Open Suggestions").click(); 37 | await expect(utils.mentionsMenu).toBeVisible(); 38 | }); 39 | 40 | test("should open the menu even if the editor was never focused", async ({ 41 | page, 42 | browserName, 43 | }) => { 44 | const utils = await testUtils( 45 | { page, browserName }, 46 | { 47 | autofocus: "none", 48 | initialValue: "Hey", 49 | }, 50 | ); 51 | await expect(utils.mentionsMenu).not.toBeVisible(); 52 | await page.getByText("Open Suggestions").click(); 53 | await expect(utils.mentionsMenu).toBeVisible(); 54 | await utils.hasText("Hey @"); 55 | }); 56 | 57 | test("should open the menu at the start of the editor", async ({ 58 | page, 59 | browserName, 60 | }) => { 61 | const utils = await testUtils( 62 | { page, browserName }, 63 | { 64 | autofocus: "start", 65 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 66 | }, 67 | ); 68 | await page.getByText("Open Suggestions").click(); 69 | await expect(utils.mentionsMenu).toBeVisible(); 70 | await utils.hasText( 71 | "@ Hey [@John], the task is [#urgent] and [due:tomorrow]", 72 | ); 73 | }); 74 | 75 | test("should open the menu after a word", async ({ page, browserName }) => { 76 | const utils = await testUtils( 77 | { page, browserName }, 78 | { 79 | autofocus: "start", 80 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 81 | }, 82 | ); 83 | await utils.moveCursorForward(3); 84 | await page.getByText("Open Suggestions").click(); 85 | await expect(utils.mentionsMenu).toBeVisible(); 86 | await utils.hasText( 87 | "Hey @ [@John], the task is [#urgent] and [due:tomorrow]", 88 | ); 89 | }); 90 | 91 | test("should open the menu before a mention", async ({ 92 | page, 93 | browserName, 94 | }) => { 95 | const utils = await testUtils( 96 | { page, browserName }, 97 | { 98 | autofocus: "start", 99 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 100 | }, 101 | ); 102 | await utils.moveCursorForward(4); 103 | await page.getByText("Open Suggestions").click(); 104 | await expect(utils.mentionsMenu).toBeVisible(); 105 | await utils.hasText( 106 | "Hey @ [@John], the task is [#urgent] and [due:tomorrow]", 107 | ); 108 | }); 109 | }); 110 | 111 | test.describe("Rename mentions", () => { 112 | test("should rename an mention", async ({ page, browserName, isMobile }) => { 113 | const utils = await testUtils( 114 | { page, browserName }, 115 | { 116 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 117 | }, 118 | ); 119 | await page.getByText("Rename Mention").click(); 120 | await utils.hasText("Hey [@John], the task is [#urgent] and [due:today]"); 121 | if (!isMobile) { 122 | await expect(utils.editor).toBeFocused(); 123 | } 124 | }); 125 | 126 | test("should rename an mention even if the editor was never focused", async ({ 127 | page, 128 | browserName, 129 | isMobile, 130 | }) => { 131 | const utils = await testUtils( 132 | { page, browserName }, 133 | { 134 | autofocus: "none", 135 | initialValue: "due:tomorrow", 136 | }, 137 | ); 138 | await page.getByText("Rename Mention").click(); 139 | await utils.hasText("[due:today]"); 140 | if (!isMobile) { 141 | await expect(utils.editor).toBeFocused(); 142 | } 143 | }); 144 | }); 145 | 146 | test.describe("Remove mentions", () => { 147 | test("should remove a mention from the editor", async ({ 148 | page, 149 | browserName, 150 | isMobile, 151 | }) => { 152 | const utils = await testUtils( 153 | { page, browserName }, 154 | { 155 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 156 | }, 157 | ); 158 | await page.getByText("Remove Mention").click(); 159 | await utils.countMentions(2); 160 | if (!isMobile) { 161 | await expect(utils.editor).toBeFocused(); 162 | } 163 | }); 164 | 165 | test("should also remove trailing spaces when removing a mention ", async ({ 166 | page, 167 | browserName, 168 | }) => { 169 | const utils = await testUtils( 170 | { page, browserName }, 171 | { 172 | initialValue: "Hey @John", 173 | }, 174 | ); 175 | await page.getByText("Remove Mention").click(); 176 | await utils.countMentions(0); 177 | await utils.hasText("Hey"); 178 | }); 179 | 180 | test("should prevent double spaces when removing a mention", async ({ 181 | page, 182 | browserName, 183 | }) => { 184 | const utils = await testUtils( 185 | { page, browserName }, 186 | { 187 | initialValue: "The #task is important", 188 | }, 189 | ); 190 | await page.getByText("Remove Mention").click(); 191 | await utils.countMentions(0); 192 | await utils.hasText("The is important"); 193 | }); 194 | 195 | test("should remove a mention even if the editor was never focused", async ({ 196 | page, 197 | browserName, 198 | }) => { 199 | const utils = await testUtils( 200 | { page, browserName }, 201 | { 202 | autofocus: "none", 203 | initialValue: "This is due:tomorrow and urgent", 204 | }, 205 | ); 206 | await utils.countMentions(1); 207 | await page.getByText("Remove Mention").click(); 208 | await utils.countMentions(0); 209 | }); 210 | }); 211 | 212 | test.describe("Insert mention", () => { 213 | test("should insert a new mention", async ({ page, browserName }) => { 214 | const utils = await testUtils( 215 | { page, browserName }, 216 | { 217 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 218 | }, 219 | ); 220 | await page.getByText("Insert Mention").click(); 221 | await utils.hasText( 222 | "Hey [@John], the task is [#urgent] and [due:tomorrow] [#work]", 223 | ); 224 | await expect(utils.editor).toBeFocused(); 225 | }); 226 | 227 | test("should insert a new mention without focus the editor", async ({ 228 | page, 229 | browserName, 230 | }) => { 231 | const utils = await testUtils( 232 | { page, browserName }, 233 | { 234 | commandFocus: false, 235 | initialValue: "", 236 | }, 237 | ); 238 | await page.getByText("Insert Mention").click(); 239 | await utils.hasText("[#work]"); 240 | await expect(utils.editor).not.toBeFocused(); 241 | }); 242 | 243 | test("should insert a new mention even if the editor is not focused", async ({ 244 | page, 245 | browserName, 246 | }) => { 247 | const utils = await testUtils( 248 | { page, browserName }, 249 | { 250 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 251 | }, 252 | ); 253 | await page.getByText("Insert Mention").click(); 254 | await utils.hasText( 255 | "Hey [@John], the task is [#urgent] and [due:tomorrow] [#work]", 256 | ); 257 | }); 258 | 259 | test("should insert multiple mention one after the other", async ({ 260 | page, 261 | browserName, 262 | }) => { 263 | const utils = await testUtils( 264 | { page, browserName }, 265 | { 266 | initialValue: "", 267 | commandFocus: false, 268 | }, 269 | ); 270 | await page.getByText("Insert Mention").click(); 271 | await utils.sleep(100); 272 | await page.getByText("Insert Mention").click(); 273 | await utils.hasText("[#work] [#work]"); 274 | }); 275 | 276 | test("should insert a new mention even if the editor was never focused", async ({ 277 | page, 278 | browserName, 279 | }) => { 280 | const utils = await testUtils( 281 | { page, browserName }, 282 | { 283 | autofocus: "none", 284 | initialValue: "Do your", 285 | }, 286 | ); 287 | await page.getByText("Insert Mention").click(); 288 | await utils.hasText("Do your [#work]"); 289 | }); 290 | 291 | test("should insert a new mention while another mention is selected", async ({ 292 | page, 293 | browserName, 294 | }) => { 295 | const utils = await testUtils( 296 | { page, browserName }, 297 | { 298 | autofocus: "end", 299 | initialValue: "Hello @Boris", 300 | }, 301 | ); 302 | // add another mention 303 | await utils.editorType("@Catherine"); 304 | await utils.editor.press("Enter"); 305 | // remove it again 306 | await utils.editor.press("Backspace"); 307 | await utils.editor.press("Backspace"); 308 | // select the first mention 309 | await utils.moveCursorBackward(1); 310 | // insert a new mention 311 | await page.getByText("Insert Mention").click(); 312 | await utils.hasText("Hello [@Boris] [#work]"); 313 | }); 314 | }); 315 | -------------------------------------------------------------------------------- /www/tests/mention.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import * as os from "os"; 3 | import { testUtils } from "./test-utils"; 4 | 5 | test.describe("mentions handling", () => { 6 | test("should render a new mention with dashes", async ({ 7 | page, 8 | browserName, 9 | }) => { 10 | const utils = await testUtils( 11 | { page, browserName }, 12 | { 13 | initialValue: "due:2023-06-06", 14 | }, 15 | ); 16 | await utils.sleep(200); 17 | await utils.hasText("[due:2023-06-06]"); 18 | }); 19 | 20 | test("should insert a new mention that contains spaces", async ({ 21 | page, 22 | browserName, 23 | isMobile, 24 | }) => { 25 | const utils = await testUtils( 26 | { page, browserName }, 27 | { 28 | allowSpaces: true, 29 | creatable: true, 30 | mentionEnclosure: true, 31 | }, 32 | ); 33 | await utils.editorType("@John Doe"); 34 | isMobile 35 | ? await utils.mentionsMenu.getByText("John Doe").click() 36 | : await utils.editor.press("Tab"); 37 | await utils.hasText(`[@"John Doe"]`); 38 | }); 39 | 40 | test("should insert a selected mention by pressing space", async ({ 41 | page, 42 | browserName, 43 | isMobile, 44 | }) => { 45 | test.skip(!!isMobile, "desktop only"); 46 | const utils = await testUtils( 47 | { page, browserName }, 48 | { 49 | creatable: true, 50 | mentionEnclosure: true, 51 | }, 52 | ); 53 | await utils.editorType("@C"); 54 | await expect(utils.mentionsMenu.getByText("Catherine")).toBeVisible(); 55 | await expect(utils.mentionsMenu.getByText(`Add user "C"`)).toBeVisible(); 56 | await expect(utils.mentionsMenu).toHaveAttribute( 57 | "aria-activedescendant", 58 | "Catherine", 59 | ); 60 | await utils.editor.press("Space"); 61 | await utils.hasText(`[@Catherine] `); 62 | }); 63 | 64 | test("should insert a new mention by pressing space", async ({ 65 | page, 66 | browserName, 67 | isMobile, 68 | }) => { 69 | test.skip(!isMobile, "mobile only"); 70 | const utils = await testUtils( 71 | { page, browserName }, 72 | { 73 | creatable: true, 74 | mentionEnclosure: true, 75 | }, 76 | ); 77 | await utils.editorType("@C"); 78 | await expect(utils.mentionsMenu.getByText("Catherine")).toBeVisible(); 79 | await expect(utils.mentionsMenu.getByText(`Add user "C"`)).toBeVisible(); 80 | await expect(utils.mentionsMenu).toHaveAttribute( 81 | "aria-activedescendant", 82 | "", 83 | ); 84 | await utils.editor.press("Space"); 85 | await utils.hasText(`[@C] `); 86 | }); 87 | 88 | test("should prevent text deletion after inserting a mention if text comes directly after it", async ({ 89 | page, 90 | browserName, 91 | }) => { 92 | const utils = await testUtils( 93 | { page, browserName }, 94 | { 95 | autoSpace: false, 96 | initialValue: "Test", 97 | autofocus: "start", 98 | }, 99 | ); 100 | await utils.editorType("@C"); 101 | await utils.mentionsMenu.getByText("Catherine").click(); 102 | await utils.hasText(`[@Catherine]Test`); 103 | }); 104 | 105 | test("should remove a mention via undo command (Ctrl/Cmd + Z)", async ({ 106 | page, 107 | browserName, 108 | isMobile, 109 | }) => { 110 | test.skip(!!isMobile, "desktop only"); 111 | const utils = await testUtils({ page, browserName }); 112 | const undoCommand = process.platform === "darwin" ? "Meta+Z" : "Control+Z"; 113 | await utils.editorType("@Catherine"); 114 | await utils.editor.press("Enter"); 115 | await utils.hasText("[@Catherine]"); 116 | await utils.editor.press(undoCommand); 117 | await utils.hasText("@Catherine"); 118 | await utils.editor.press(undoCommand); 119 | await utils.hasText("@C"); 120 | await utils.editor.press(undoCommand); 121 | await utils.hasText("@"); 122 | await utils.editor.press(undoCommand); 123 | await utils.hasText(""); 124 | }); 125 | 126 | test("should use custom mention nodes", async ({ 127 | page, 128 | browserName, 129 | isMobile, 130 | }) => { 131 | test.skip(!!isMobile, "desktop only"); 132 | const utils = await testUtils( 133 | { page, browserName }, 134 | { 135 | initialValue: "@Catherine", 136 | customMentionNode: true, 137 | }, 138 | ); 139 | const mentionPosition = await page 140 | .locator(`[data-beautiful-mention="@Catherine"]`) 141 | .boundingBox(); 142 | await expect( 143 | page.locator(`[data-beautiful-mention="@Catherine"]`), 144 | ).toHaveAttribute("data-state", "closed"); 145 | await page.mouse.move(mentionPosition.x + 1, mentionPosition.y + 1); 146 | await expect( 147 | page.locator(`[data-beautiful-mention="@Catherine"]`), 148 | ).toHaveAttribute("data-state", "delayed-open"); 149 | await page.getByText("Remove Mention").click(); 150 | await utils.countMentions(0); 151 | }); 152 | 153 | test("should insert a mention in brackets", async ({ page, browserName }) => { 154 | const utils = await testUtils({ page, browserName }); 155 | await utils.editorType("(@Cath"); 156 | await utils.mentionsMenu.getByText("Catherine").click(); 157 | await utils.editorType(")"); 158 | await utils.hasText(`([@Catherine])`); 159 | }); 160 | 161 | // only works when running in headful mode 162 | // - https://github.com/kenthu/human-interest-verifier/issues/1 163 | // - https://github.com/microsoft/playwright/issues/11654 164 | test.skip("should copy mention to clipboard and paste it back in the editor", async ({ 165 | page, 166 | browserName, 167 | isMobile, 168 | }) => { 169 | test.skip(!!isMobile, "desktop only"); 170 | const utils = await testUtils( 171 | { page, browserName }, 172 | { 173 | initialValue: "@Catherine", 174 | customMentionNode: true, 175 | }, 176 | ); 177 | await page.click(`[data-beautiful-mention="@Catherine"]`); 178 | const isMac = os.platform() === "darwin"; 179 | const modifier = isMac ? "Meta" : "Control"; 180 | await page.keyboard.press(`${modifier}+KeyC`); 181 | await page.keyboard.press("ArrowRight"); 182 | await page.keyboard.press(`${modifier}+KeyV`); 183 | await utils.hasText("[@Catherine] [@Catherine]"); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /www/tests/menu.spec.ts-snapshots/Mention-Menu-should-dynamically-position-the-menu-1-Mobile-Safari-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sodenn/lexical-beautiful-mentions/fd514fba7f7184180688376879ea3922966c2a59/www/tests/menu.spec.ts-snapshots/Mention-Menu-should-dynamically-position-the-menu-1-Mobile-Safari-linux.png -------------------------------------------------------------------------------- /www/tests/menu.spec.ts-snapshots/Mention-Menu-should-dynamically-position-the-menu-1-chromium-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sodenn/lexical-beautiful-mentions/fd514fba7f7184180688376879ea3922966c2a59/www/tests/menu.spec.ts-snapshots/Mention-Menu-should-dynamically-position-the-menu-1-chromium-linux.png -------------------------------------------------------------------------------- /www/tests/space.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | import { testUtils } from "./test-utils"; 3 | 4 | test.describe("Space handling", () => { 5 | test("should delete a text with mentions", async ({ page, browserName }) => { 6 | const utils = await testUtils( 7 | { page, browserName }, 8 | { 9 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 10 | }, 11 | ); 12 | await utils.deleteText(utils.initialValue.length); 13 | await utils.hasText(""); 14 | }); 15 | 16 | test("should add a character at line start and before a mention", async ({ 17 | page, 18 | browserName, 19 | }) => { 20 | const utils = await testUtils( 21 | { page, browserName }, 22 | { 23 | autofocus: "start", 24 | initialValue: "@catherine", 25 | }, 26 | ); 27 | await utils.editor.press("x"); 28 | await utils.hasText("x [@catherine]"); 29 | }); 30 | 31 | test("should add a character after the last mention", async ({ 32 | page, 33 | browserName, 34 | }) => { 35 | const utils = await testUtils( 36 | { page, browserName }, 37 | { 38 | initialValue: "Hey @catherine", 39 | }, 40 | ); 41 | await utils.editor.press("x"); 42 | await utils.hasText("Hey [@catherine] x"); 43 | }); 44 | 45 | test("should add a character before a mention", async ({ 46 | page, 47 | browserName, 48 | }) => { 49 | const utils = await testUtils( 50 | { page, browserName }, 51 | { 52 | autofocus: "end", 53 | initialValue: "Hey @John", 54 | }, 55 | ); 56 | await utils.moveCursorBackward(2); 57 | await utils.editor.press("x"); 58 | await utils.hasText("Hey x [@John]"); 59 | }); 60 | 61 | test("should add a character after a mention", async ({ 62 | page, 63 | browserName, 64 | }) => { 65 | const utils = await testUtils( 66 | { page, browserName }, 67 | { 68 | autofocus: "start", 69 | initialValue: "@catherine is a nice person", 70 | }, 71 | ); 72 | await utils.moveCursorForward(2); 73 | await utils.editor.press("x"); 74 | await utils.hasText("[@catherine] x is a nice person"); 75 | }); 76 | 77 | test("should add a trigger character after a mention", async ({ 78 | page, 79 | browserName, 80 | }) => { 81 | const utils = await testUtils( 82 | { page, browserName }, 83 | { 84 | autofocus: "end", 85 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 86 | }, 87 | ); 88 | await utils.moveCursorBackward(7); 89 | await utils.editor.press("@"); 90 | await utils.hasText( 91 | "Hey [@John], the task is [#urgent] @ and [due:tomorrow]", 92 | ); 93 | }); 94 | 95 | test("should add a trigger character before text", async ({ 96 | page, 97 | browserName, 98 | }) => { 99 | const utils = await testUtils( 100 | { page, browserName }, 101 | { 102 | autofocus: "end", 103 | initialValue: "Hey @John, the task is #urgent and due:tomorrow", 104 | }, 105 | ); 106 | await utils.moveCursorBackward(6); 107 | await utils.editor.press("@"); 108 | await utils.hasText( 109 | "Hey [@John], the task is [#urgent] @ and [due:tomorrow]", 110 | ); 111 | }); 112 | 113 | test("should delete the first of two mention that are only linked with a single space", async ({ 114 | page, 115 | browserName, 116 | }) => { 117 | const utils = await testUtils( 118 | { page, browserName }, 119 | { 120 | autofocus: "end", 121 | initialValue: "#urgent #important #task", 122 | }, 123 | ); 124 | await utils.moveCursorBackward(2); 125 | await utils.deleteText(2); 126 | await utils.hasText("[#urgent] [#task]"); 127 | }); 128 | 129 | test("should not insert any space if the meta key is pressed", async ({ 130 | page, 131 | browserName, 132 | }) => { 133 | const utils = await testUtils( 134 | { page, browserName }, 135 | { 136 | autofocus: "end", 137 | initialValue: "#urgent #task", 138 | }, 139 | ); 140 | await utils.moveCursorBackward(2); 141 | await utils.editor.press("Meta+C"); 142 | await utils.hasText("[#urgent] [#task]"); 143 | }); 144 | 145 | test("should not insert a space between a word", async ({ 146 | page, 147 | browserName, 148 | }) => { 149 | const utils = await testUtils( 150 | { page, browserName }, 151 | { 152 | autofocus: "start", 153 | initialValue: "and", 154 | }, 155 | ); 156 | await utils.moveCursorForward(1); 157 | await utils.editor.press("x"); 158 | await utils.hasText("axnd"); 159 | }); 160 | 161 | test("should not add a space at the beginning of the editor", async ({ 162 | page, 163 | browserName, 164 | }) => { 165 | const utils = await testUtils( 166 | { page, browserName }, 167 | { 168 | autofocus: "end", 169 | }, 170 | ); 171 | await page.getByText("Insert Mention").click(); 172 | await utils.hasText("[#work]"); 173 | }); 174 | 175 | test("should not insert a space before a word", async ({ 176 | page, 177 | browserName, 178 | }) => { 179 | const utils = await testUtils( 180 | { page, browserName }, 181 | { 182 | autofocus: "start", 183 | initialValue: "and", 184 | }, 185 | ); 186 | await utils.editor.press("x"); 187 | await utils.hasText("xand"); 188 | }); 189 | 190 | test("should insert a new mention when pressing space", async ({ 191 | page, 192 | browserName, 193 | }) => { 194 | const utils = await testUtils( 195 | { page, browserName }, 196 | { 197 | creatable: true, 198 | autofocus: "start", 199 | }, 200 | ); 201 | await utils.editorType("#xxx"); 202 | await expect(utils.mentionsMenu.getByRole("menuitem")).toHaveCount(1); 203 | await expect(utils.mentionsMenu.getByText(`Add tag "xxx"`)).toBeVisible(); 204 | await utils.editor.press("Space"); 205 | await utils.hasText("[#xxx] "); 206 | }); 207 | 208 | test("should not add a space after a mention when `autoSpace=false`", async ({ 209 | page, 210 | browserName, 211 | }) => { 212 | const utils = await testUtils( 213 | { page, browserName }, 214 | { 215 | initialValue: "Hey @catherine", 216 | autoSpace: false, 217 | }, 218 | ); 219 | await utils.editor.press("x"); 220 | await utils.hasText("Hey [@catherine]x"); 221 | }); 222 | 223 | test("should add space after mention with multi-character trigger", async ({ 224 | page, 225 | browserName, 226 | }) => { 227 | const utils = await testUtils( 228 | { page, browserName }, 229 | { 230 | initialValue: "Hey", 231 | autofocus: "start", 232 | }, 233 | ); 234 | await utils.editorType("due:"); 235 | await utils.hasText("due: Hey"); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /www/tests/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { expect, Page } from "@playwright/test"; 2 | import { Locator } from "playwright"; 3 | 4 | type Autofocus = "none" | "start" | "end"; 5 | 6 | interface TestUtilsOptions { 7 | initialValue?: string; 8 | autofocus?: Autofocus; 9 | asynchronous?: boolean; 10 | allowSpaces?: boolean; 11 | autoSpace?: boolean; 12 | creatable?: boolean; 13 | insertOnBlur?: boolean; 14 | commandFocus?: boolean; 15 | combobox?: boolean; 16 | mentionEnclosure?: boolean; 17 | showMentionsOnDelete?: boolean; 18 | comboboxAdditionalItems?: boolean; 19 | customMentionNode?: boolean; 20 | emptyComponent?: boolean; 21 | } 22 | 23 | type PlaywrightArgs = { page: Page; browserName: string }; 24 | 25 | export async function testUtils( 26 | args: PlaywrightArgs, 27 | options: TestUtilsOptions = {}, 28 | ) { 29 | const { 30 | initialValue = "", 31 | autofocus = "end", 32 | asynchronous = false, 33 | allowSpaces = false, 34 | autoSpace = true, 35 | creatable = false, 36 | insertOnBlur = false, 37 | commandFocus = true, 38 | combobox = false, 39 | mentionEnclosure = false, 40 | showMentionsOnDelete = false, 41 | comboboxAdditionalItems = false, 42 | customMentionNode = false, 43 | emptyComponent = false, 44 | } = options; 45 | const utils = new TestUtils( 46 | args.page, 47 | args.browserName, 48 | initialValue, 49 | autofocus, 50 | asynchronous, 51 | allowSpaces, 52 | autoSpace, 53 | creatable, 54 | insertOnBlur, 55 | commandFocus, 56 | combobox, 57 | mentionEnclosure, 58 | showMentionsOnDelete, 59 | comboboxAdditionalItems, 60 | customMentionNode, 61 | emptyComponent, 62 | ); 63 | await utils.init(); 64 | return utils; 65 | } 66 | 67 | export class TestUtils { 68 | public initialValue: string; 69 | 70 | constructor( 71 | private page: PlaywrightArgs["page"], 72 | private browserName: PlaywrightArgs["browserName"], 73 | initialValue: string, 74 | private autofocus: Autofocus, 75 | private asynchronous: boolean, 76 | private allowSpaces: boolean, 77 | private autoSpace: boolean, 78 | private creatable: boolean, 79 | private insertOnBlur: boolean, 80 | private commandFocus: boolean, 81 | private _combobox: boolean, 82 | private mentionEnclosure: boolean, 83 | private showMentionsOnDelete: boolean, 84 | private comboboxAdditionalItems: boolean, 85 | private customMentionNode: boolean, 86 | private emptyComponent: boolean, 87 | ) { 88 | this.setInitialValue(initialValue); 89 | } 90 | 91 | async init() { 92 | await this.goto(); 93 | } 94 | 95 | async focusEnd() { 96 | await this.editor.focus(); 97 | await this.moveCaretToEnd(); 98 | } 99 | 100 | async moveCursorForward(n = 1) { 101 | await this.moveCaretToStart(); 102 | await this.pressKey("ArrowRight", n); 103 | } 104 | 105 | async moveCursorBackward(n = 1) { 106 | await this.moveCaretToEnd(); 107 | await this.pressKey("ArrowLeft", n); 108 | } 109 | 110 | async deleteText(n = 1) { 111 | await this.pressKey("Backspace", n); 112 | } 113 | 114 | async moveCaretToStart() { 115 | await this.page.getByRole("textbox").press("ArrowUp", { delay: 10 }); 116 | } 117 | 118 | async moveCaretToEnd() { 119 | await this.page.getByRole("textbox").press("ArrowDown", { delay: 10 }); 120 | } 121 | 122 | async hasText(text: string) { 123 | const plaintext = await this.getPlaintext(); 124 | expect(plaintext).toBe(text); 125 | } 126 | 127 | async getPlaintext() { 128 | await this.sleep(200); 129 | return await this.page.getByTestId("plaintext").innerText(); 130 | } 131 | 132 | async countMentions(count: number) { 133 | const plaintext = await this.getPlaintext(); 134 | const regex = /\[[^[\]]+]/g; 135 | const match = plaintext.match(regex); 136 | if (match) { 137 | expect(match.length).toBe(count); 138 | } else { 139 | expect(match).toBeNull(); 140 | } 141 | } 142 | 143 | get editor() { 144 | return this.page.getByRole("textbox"); 145 | } 146 | 147 | async editorType(text: string, delay = 50) { 148 | await this.editor.pressSequentially(text, { delay }); 149 | await this.sleep(200); 150 | } 151 | 152 | get mentionsMenu() { 153 | return this.page.getByRole("menu", { name: "Choose a mention" }); 154 | } 155 | 156 | get combobox() { 157 | return this.page.getByRole("menu", { name: "Choose trigger and value" }); 158 | } 159 | 160 | async isMenuOrComboboxOpen() { 161 | await this.sleep(200); 162 | const text = await this.page.getByTestId("menu-combobox-open").innerText(); 163 | return text === "true"; 164 | } 165 | 166 | async isComboboxItemSelected() { 167 | await this.sleep(200); 168 | const text = await this.page 169 | .getByTestId("combobox-item-selected") 170 | .innerText(); 171 | return text === "true"; 172 | } 173 | 174 | sleep(ms: number) { 175 | // eslint-disable-next-line playwright/no-wait-for-timeout 176 | return this.page.waitForTimeout(ms); 177 | } 178 | 179 | async expectNotVisibleFor( 180 | locator: Locator, 181 | duration: number, 182 | interval: number = 100, 183 | ): Promise { 184 | const startTime = Date.now(); 185 | while (Date.now() - startTime < duration) { 186 | const isVisible = await locator.isVisible(); 187 | if (isVisible) { 188 | throw new Error("Element became visible during the period"); 189 | } 190 | await new Promise((resolve) => setTimeout(resolve, interval)); 191 | } 192 | } 193 | 194 | private setInitialValue(initialValue: string) { 195 | this.initialValue = encodeURIComponent(initialValue); 196 | } 197 | 198 | private async goto() { 199 | const host = process.env.HOST || "localhost"; 200 | let url = `http://${host}:3000?focus=${this.autofocus}`; 201 | url += `&async=${this.asynchronous}`; 202 | url += `&combobox=${this._combobox}`; 203 | url += `&enclosure=${this.mentionEnclosure}`; 204 | url += `&mentions=${this.showMentionsOnDelete}`; 205 | url += `&aspace=${this.allowSpaces}`; 206 | url += `&ospace=${this.autoSpace}`; 207 | url += `&new=${this.creatable}`; 208 | url += `&blur=${this.insertOnBlur}`; 209 | url += `&cf=${this.commandFocus}`; 210 | url += `&value=${this.initialValue}`; 211 | url += `&cbai=${this.comboboxAdditionalItems}`; 212 | url += `&cstmn=${this.customMentionNode}`; 213 | url += `&empty=${this.emptyComponent}`; 214 | await this.page.goto(url); 215 | await this.sleep(100); 216 | } 217 | 218 | private async pressKey(key: string, n = 1) { 219 | for (let i = 0; i < n; i++) { 220 | await this.page.getByRole("textbox").press(key, { delay: 10 }); 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /www/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "plugins": [{ "name": "next" }], 8 | "jsx": "preserve", 9 | "allowJs": true, 10 | "declaration": false, 11 | "declarationMap": false, 12 | "incremental": true, 13 | "noEmit": true, 14 | "resolveJsonModule": true, 15 | "strict": false, 16 | "composite": false, 17 | "esModuleInterop": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "inlineSources": false, 20 | "isolatedModules": true, 21 | "noUnusedLocals": false, 22 | "noUnusedParameters": false, 23 | "preserveWatchOutput": true, 24 | "skipLibCheck": true, 25 | "paths": { 26 | "@/*": ["./*"] 27 | } 28 | }, 29 | "include": [ 30 | "next-env.d.ts", 31 | "**/*.ts", 32 | "**/*.tsx", 33 | ".next/types/**/*.ts" 34 | ], 35 | "exclude": [ 36 | "node_modules", 37 | "**/*.test.ts" 38 | ] 39 | } 40 | --------------------------------------------------------------------------------