├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── apps └── tiptap-extensions-demo │ ├── .eslintrc.json │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.mjs │ ├── src │ └── app │ │ ├── context │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ ├── tailwind.config.ts │ └── tsconfig.json ├── package.json ├── packages ├── eslint-config │ ├── README.md │ ├── library.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── image-tiptap │ ├── .changeset │ │ ├── README.md │ │ ├── config.json │ │ ├── five-drinks-remain.md │ │ └── wet-tools-notice.md │ ├── .gitignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── image-aligner.tsx │ │ │ └── image-resize.tsx │ │ ├── constants.ts │ │ ├── extensions │ │ │ └── image-extension.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── slash-tiptap │ ├── .changeset │ │ ├── README.md │ │ ├── config.json │ │ ├── popular-gifts-warn.md │ │ ├── popular-shirts-trade.md │ │ ├── wet-llamas-run.md │ │ └── young-peaches-exist.md │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── command-outlet.tsx │ │ │ ├── command.tsx │ │ │ ├── index.tsx │ │ │ ├── item.tsx │ │ │ ├── root.tsx │ │ │ ├── slash-provider.tsx │ │ │ └── tunnel-instance.tsx │ │ ├── extensions │ │ │ ├── helper.ts │ │ │ ├── index.ts │ │ │ ├── render-items.ts │ │ │ ├── slash-extension.ts │ │ │ └── suggestions.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── constants.ts │ │ │ ├── store.ts │ │ │ ├── tunnel.tsx │ │ │ └── use-isomorphic-effect.ts │ ├── tsconfig.json │ └── tsup.config.ts └── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | build: 11 | name: Build and Check Exports 12 | timeout-minutes: 15 13 | runs-on: ubuntu-latest 14 | # To use Remote Caching, uncomment the next lines and follow the steps below. 15 | # env: 16 | # TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 17 | # TURBO_TEAM: ${{ vars.TURBO_TEAM }} 18 | # TURBO_REMOTE_ONLY: true 19 | 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 2 25 | - name: pnpm-setup 26 | uses: pnpm/action-setup@v2 27 | 28 | - name: Setup Node.js environment 29 | uses: actions/setup-node@v4 30 | 31 | - name: Install Dependencies 32 | run: pnpm install 33 | 34 | - name: Build And Check Exports 35 | run: pnpm run ci 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshtalks/tiptap-plugins/a3a4b54b02605c52c208f245b1e8e581899a029b/.npmrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Tiptap React Extensions 3 | 4 | Tiptap React extensions and headless components for image nodes and a slash command for React.js 5 | 6 | ## @harshtalks/image-tiptap 7 | 8 | It extends @tiptap/react image extension to support image resizing and alignment. 9 | Existing third party/unofficial plugins are not flexible. 10 | This package contains - 11 | 1. UI headless components to render alignment menu in a bubble menu. 12 | 2. Image extension extended from tiptap-extension-image to support resizing and alignment out of the box 13 | 14 | It supports both useEditor hook and EditorProvider from tiptap. 15 | 16 | 17 | ## Installation 18 | 19 | Install the package using pnpm 20 | 21 | ```bash 22 | pnpm add @harshtalks/image-tiptap 23 | ``` 24 | 25 | You can use npm, bun, or yarn etc. 26 | 27 | ## Usage 28 | 29 | ```tsx 30 | import { useEditor, EditorContent } from "@tiptap/react"; 31 | import StarterKit from "@tiptap/starter-kit"; 32 | import { ImageExtension, ImageAligner } from "@harshtalks/image-tiptap"; 33 | import "./globals.css"; 34 | import { useCallback } from "react"; 35 | 36 | export default function Home() { 37 | const editor = useEditor({ 38 | extensions: [StarterKit, ImageExtension], 39 | content: 40 | "

This is a basic example of implementing images. Drag to re-order.

", 41 | }); 42 | 43 | const addImage = useCallback(() => { 44 | const url = window.prompt("URL"); 45 | 46 | if (url) { 47 | editor?.chain().focus().setImage({ src: url }).run(); 48 | } 49 | }, [editor]); 50 | 51 | if (!editor) { 52 | return null; 53 | } 54 | 55 | return ( 56 |
57 | 60 | 61 |
62 | 63 | 64 | 65 | Left Align 66 | 67 | Center Align 68 | 69 | 70 | Right Align 71 | 72 | 73 | 74 | 75 |
76 | 77 | 78 |
79 | ); 80 | } 81 | 82 | ``` 83 | 84 | # Tiptap Slash Command Extension 85 | 86 | Simple tiptap extension for React to add notion like slash command to your project. It provides a flexible extension built on top of tiptap suggestion extension, and headless UI components built on cmdk package. 87 | 88 | - Works with both useEditor hook and EditorProvider 89 | - Type Safe 90 | - Headless UI on top of cmdk 91 | - Flexible and easy to use 92 | 93 | Notes: 94 | 1. Make sure to wrap your entire editor in a `SlashCmdProvider` component. 95 | 2. for keyboard navigation, provide `enableKeyboardNavigationc` in `editorProps` handleDOMEvents. 96 | 97 | ## Installation 98 | 99 | Installing the package using pnpm 100 | 101 | ```bash 102 | pnpm add @harshtalks/slash-tiptap 103 | ``` 104 | 105 | ## Usage: 106 | 107 | 1. Define suggestions. Add all the commaands you want in the slash menu. 108 | ```ts 109 | import { enableKeyboardNavigation } from "@harshtalks/slash-tiptap"; 110 | 111 | const suggestions = createSuggestionsItems([ 112 | { 113 | title: "text", 114 | searchTerms: ["paragraph"], 115 | command: ({ editor, range }) => { 116 | editor 117 | .chain() 118 | .focus() 119 | .deleteRange(range) 120 | .toggleNode("paragraph", "paragraph") 121 | .run(); 122 | }, 123 | }, 124 | { 125 | title: "Bullet List", 126 | searchTerms: ["unordered", "point"], 127 | command: ({ editor, range }) => { 128 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 129 | }, 130 | }, 131 | { 132 | title: "Ordered List", 133 | searchTerms: ["ordered", "point", "numbers"], 134 | command: ({ editor, range }) => { 135 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 136 | }, 137 | }, 138 | ]); 139 | ``` 140 | 141 | 2. Create an editor instance 142 | ```tsx 143 | import { 144 | Slash, 145 | enableKeyboardNavigation, 146 | } from "@harshtalks/slash-tiptap"; 147 | 148 | 149 | const editor = useEditor({ 150 | extensions: [ 151 | StarterKit, 152 | ImageExtension, 153 | Slash.configure({ 154 | suggestion: { 155 | items: () => suggestions, 156 | }, 157 | }), 158 | Placeholder.configure({ 159 | // Use a placeholder: 160 | placeholder: "Press / to see available commands", 161 | // Use different placeholders depending on the node type: 162 | // placeholder: ({ node }) => { 163 | // if (node.type.name === 'heading') { 164 | // return 'What’s the title?' 165 | // } 166 | 167 | // return 'Can you add some further context?' 168 | // }, 169 | }), 170 | ], 171 | editorProps: { 172 | handleDOMEvents: { 173 | keydown: (_, v) => enableKeyboardNavigation(v), 174 | }, 175 | attributes: { 176 | class: 177 | "prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none", 178 | }, 179 | }, 180 | content: ` 181 |

This is a basic example of usage. Press / to see available commands. Click on Image to resize and align.

182 | 183 | `, 184 | }); 185 | ``` 186 | 187 | 3. Wrap your editor in `SlashCmdProvider` component and add `SlashCmd` component to your editor. 188 | ```tsx 189 | import { 190 | SlashCmd 191 | } from "@harshtalks/slash-tiptap"; 192 | 193 | export const Editor = () => { 194 | return ( 195 | 196 | 197 | 198 | 199 | No commands available 200 | 201 | {suggestions.map((item) => { 202 | return ( 203 | { 206 | item.command(val); 207 | }} 208 | key={item.title} 209 | > 210 |

{item.title}

211 |
212 | ); 213 | })} 214 |
215 |
216 |
217 |
218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # tiptap-extensions-demo 2 | 3 | ## 0.1.16 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies 8 | - @harshtalks/image-tiptap@1.4.0 9 | 10 | ## 0.1.15 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies 15 | - Updated dependencies 16 | - Updated dependencies 17 | - Updated dependencies 18 | - @harshtalks/image-tiptap@1.3.0 19 | - @harshtalks/slash-tiptap@1.3.0 20 | 21 | ## 0.1.14 22 | 23 | ### Patch Changes 24 | 25 | - Updated dependencies 26 | - Updated dependencies 27 | - Updated dependencies 28 | - @harshtalks/image-tiptap@1.2.0 29 | - @harshtalks/slash-tiptap@1.2.0 30 | 31 | ## 0.1.13 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies 36 | - Updated dependencies 37 | - @harshtalks/image-tiptap@1.1.0 38 | - @harshtalks/slash-tiptap@1.1.0 39 | 40 | ## 0.1.12 41 | 42 | ### Patch Changes 43 | 44 | - Updated dependencies 45 | - @harshtalks/tiptap-image@1.0.1 46 | 47 | ## 0.1.11 48 | 49 | ### Patch Changes 50 | 51 | - Updated dependencies 52 | - @harshtalks/tiptap-image@0.0.1 53 | 54 | ## 0.1.10 55 | 56 | ### Patch Changes 57 | 58 | - Updated dependencies 59 | - @harshtalks/tiptap-image@0.0.2 60 | - @harshtalks/tiptap-slash@0.0.2 61 | 62 | ## 0.1.9 63 | 64 | ### Patch Changes 65 | 66 | - Updated dependencies 67 | - @harshtalks/tiptap-image@0.0.1 68 | - @harshtalks/tiptap-slash@0.0.1 69 | 70 | ## 0.1.8 71 | 72 | ### Patch Changes 73 | 74 | - Updated dependencies 75 | - @harshtalks/tiptap-image@1.0.1 76 | - @harshtalks/tiptap-slash@1.0.1 77 | 78 | ## 0.1.7 79 | 80 | ### Patch Changes 81 | 82 | - Updated dependencies 83 | - Updated dependencies 84 | - Updated dependencies 85 | - @harshtalks/tiptap-image@0.5.0 86 | - @harshtalks/tiptap-slash@0.4.0 87 | 88 | ## 0.1.6 89 | 90 | ### Patch Changes 91 | 92 | - Updated dependencies 93 | - Updated dependencies 94 | - @harshtalks/tiptap-image@0.4.0 95 | - @harshtalks/tiptap-slash@0.3.0 96 | 97 | ## 0.1.5 98 | 99 | ### Patch Changes 100 | 101 | - Updated dependencies 102 | - @harshtalks/tiptap-slash@0.2.0 103 | 104 | ## 0.1.4 105 | 106 | ### Patch Changes 107 | 108 | - Updated dependencies 109 | - Updated dependencies 110 | - @harshtalks/tiptap-image@0.3.0 111 | - @harshtalks/tiptap-slash@0.1.0 112 | 113 | ## 0.1.3 114 | 115 | ### Patch Changes 116 | 117 | - Updated dependencies 118 | - Updated dependencies 119 | - @harshtalks/tiptap-image@0.2.0 120 | - @harshtalks/tiptap-slash@0.2.0 121 | 122 | ## 0.1.2 123 | 124 | ### Patch Changes 125 | 126 | - Updated dependencies 127 | - Updated dependencies 128 | - @harshtalks/tiptap-image@0.1.0 129 | - @harshtalks/tiptap-slash@0.1.0 130 | 131 | ## 0.1.1 132 | 133 | ### Patch Changes 134 | 135 | - Updated dependencies 136 | - Updated dependencies 137 | - @harshtalks/tiptap-image@3.0.0 138 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiptap-extensions-demo", 3 | "version": "0.1.16", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@harshtalks/image-tiptap": "workspace:*", 13 | "@harshtalks/slash-tiptap": "workspace:*", 14 | "@tailwindcss/typography": "^0.5.15", 15 | "@tiptap/extension-placeholder": "^2.7.2", 16 | "@tiptap/pm": "^2.7.2", 17 | "@tiptap/react": "^2.7.2", 18 | "@tiptap/starter-kit": "^2.7.2", 19 | "next": "14.2.13", 20 | "react": "^18", 21 | "react-dom": "^18" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20", 25 | "@types/react": "^18", 26 | "@types/react-dom": "^18", 27 | "eslint": "^8", 28 | "eslint-config-next": "14.2.13", 29 | "postcss": "^8", 30 | "tailwindcss": "^3.4.1", 31 | "typescript": "^5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/context/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import StarterKit from "@tiptap/starter-kit"; 4 | import { ImageExtension, ImageAligner } from "@harshtalks/image-tiptap"; 5 | import Placeholder from "@tiptap/extension-placeholder"; 6 | import { 7 | Slash, 8 | SlashCmd, 9 | SlashCmdProvider, 10 | createSuggestionsItems, 11 | enableKeyboardNavigation, 12 | } from "@harshtalks/slash-tiptap"; 13 | import { EditorProvider } from "@tiptap/react"; 14 | import Link from "next/link"; 15 | 16 | const suggestions = createSuggestionsItems([ 17 | { 18 | title: "text", 19 | searchTerms: ["paragraph"], 20 | command: ({ editor, range }) => { 21 | editor 22 | .chain() 23 | .focus() 24 | .deleteRange(range) 25 | .toggleNode("paragraph", "paragraph") 26 | .run(); 27 | }, 28 | }, 29 | { 30 | title: "Bullet List", 31 | searchTerms: ["unordered", "point"], 32 | command: ({ editor, range }) => { 33 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 34 | }, 35 | }, 36 | { 37 | title: "Ordered List", 38 | searchTerms: ["ordered", "point", "numbers"], 39 | command: ({ editor, range }) => { 40 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 41 | }, 42 | }, 43 | ]); 44 | 45 | export default function Home() { 46 | return ( 47 |
48 |
49 |

Slash Command Example

50 |

With editor provider context

51 | 52 | 58 | 59 |
60 | 61 | suggestions, 68 | }, 69 | }), 70 | Placeholder.configure({ 71 | // Use a placeholder: 72 | placeholder: "Press / to see available commands", 73 | // Use different placeholders depending on the node type: 74 | // placeholder: ({ node }) => { 75 | // if (node.type.name === 'heading') { 76 | // return 'What’s the title?' 77 | // } 78 | 79 | // return 'Can you add some further context?' 80 | // }, 81 | }), 82 | ]} 83 | content={` 84 |

This is a basic example of usage. Press / to see available commands. Click on Image to resize and align.

85 | 86 | `} 87 | editorProps={{ 88 | handleDOMEvents: { 89 | keydown: (_, v) => enableKeyboardNavigation(v), 90 | }, 91 | attributes: { 92 | class: 93 | "prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none", 94 | }, 95 | }} 96 | > 97 | 98 | 99 | No commands available 100 | 101 | {suggestions.map((item) => { 102 | return ( 103 | { 106 | item.command(val); 107 | }} 108 | className="flex w-full items-center space-x-2 cursor-pointer rounded-md p-2 text-left text-sm hover:bg-gray-200 aria-selected:bg-gray-200" 109 | key={item.title} 110 | > 111 |
112 |

{item.title}

113 |
114 |
115 | ); 116 | })} 117 |
118 |
119 |
120 | 121 | 122 | 123 | 127 | Left 128 | 129 | 133 | Center 134 | 135 | 139 | Right 140 | 141 | 142 | 143 | 144 |
145 |
146 |
147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshtalks/tiptap-plugins/a3a4b54b02605c52c208f245b1e8e581899a029b/apps/tiptap-extensions-demo/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshtalks/tiptap-plugins/a3a4b54b02605c52c208f245b1e8e581899a029b/apps/tiptap-extensions-demo/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harshtalks/tiptap-plugins/a3a4b54b02605c52c208f245b1e8e581899a029b/apps/tiptap-extensions-demo/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | --muted: 0 0% 96.1%; 10 | --muted-foreground: 0 0% 45.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --border: 0 0% 89.8%; 16 | --input: 0 0% 89.8%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --accent: 0 0% 96.1%; 22 | --accent-foreground: 0 0% 9%; 23 | --destructive: 0 84.2% 60.2%; 24 | --destructive-foreground: 0 0% 98%; 25 | --ring: 0 0% 3.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 0 0% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --muted: 0 0% 14.9%; 33 | --muted-foreground: 0 0% 63.9%; 34 | --popover: 0 0% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --card: 0 0% 3.9%; 37 | --card-foreground: 0 0% 98%; 38 | --border: 0 0% 14.9%; 39 | --input: 0 0% 14.9%; 40 | --primary: 0 0% 98%; 41 | --primary-foreground: 0 0% 9%; 42 | --secondary: 0 0% 14.9%; 43 | --secondary-foreground: 0 0% 98%; 44 | --accent: 0 0% 14.9%; 45 | --accent-foreground: 0 0% 98%; 46 | --destructive: 0 62.8% 30.6%; 47 | --destructive-foreground: 0 0% 98%; 48 | --ring: 0 0% 83.1%; 49 | } 50 | } 51 | 52 | /* hiding the codeium logo */ 53 | a[href="https://codeium.com?referrer=codeium-editor"] 54 | { 55 | display: none; 56 | } 57 | 58 | .tiptap :first-child { 59 | margin-top: 0; 60 | } 61 | 62 | .tiptap p.is-empty::before { 63 | color: #adb5bd; 64 | content: attr(data-placeholder); 65 | float: left; 66 | height: 0; 67 | pointer-events: none; 68 | } 69 | 70 | .ProseMirror:focus { 71 | outline: none; 72 | } 73 | 74 | /* Table */ 75 | 76 | .tiptap table { 77 | border-collapse: collapse; 78 | margin: 0; 79 | overflow: hidden; 80 | table-layout: fixed; 81 | width: 100%; 82 | margin-right: 10rem; 83 | } 84 | .tiptap table td, 85 | .tiptap table th { 86 | border: 1px solid #d4d4d8; 87 | box-sizing: border-box; 88 | min-width: 1em; 89 | padding: 6px 8px; 90 | position: relative; 91 | vertical-align: top; 92 | } 93 | .tiptap table td > *, 94 | .tiptap table th > * { 95 | margin-bottom: 0; 96 | } 97 | .tiptap table th { 98 | font-weight: normal; 99 | text-align: left; 100 | } 101 | 102 | .tiptap table .selectedCell:after { 103 | content: ""; 104 | left: 0; 105 | right: 0; 106 | top: 0; 107 | bottom: 0; 108 | pointer-events: none; 109 | position: absolute; 110 | z-index: 2; 111 | } 112 | 113 | .tiptap table .column-resize-handle { 114 | background-color: #3490dc; 115 | bottom: -2px; 116 | pointer-events: none; 117 | position: absolute; 118 | right: -2px; 119 | top: 0; 120 | width: 4px; 121 | } 122 | 123 | .tiptap .tableWrapper { 124 | margin: 1.5rem 0; 125 | overflow-x: auto; 126 | } 127 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | 5 | const geistSans = localFont({ 6 | src: "./fonts/GeistVF.woff", 7 | variable: "--font-geist-sans", 8 | weight: "100 900", 9 | }); 10 | const geistMono = localFont({ 11 | src: "./fonts/GeistMonoVF.woff", 12 | variable: "--font-geist-mono", 13 | weight: "100 900", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "Create Next App", 18 | description: "Generated by create next app", 19 | }; 20 | 21 | export default function RootLayout({ 22 | children, 23 | }: Readonly<{ 24 | children: React.ReactNode; 25 | }>) { 26 | return ( 27 | 28 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { EditorContent, useEditor } from "@tiptap/react"; 4 | import StarterKit from "@tiptap/starter-kit"; 5 | import { ImageExtension, ImageAligner } from "@harshtalks/image-tiptap"; 6 | import Placeholder from "@tiptap/extension-placeholder"; 7 | import { 8 | Slash, 9 | SlashCmd, 10 | SlashCmdProvider, 11 | createSuggestionsItems, 12 | enableKeyboardNavigation, 13 | } from "@harshtalks/slash-tiptap"; 14 | import Link from "next/link"; 15 | 16 | const suggestions = createSuggestionsItems([ 17 | { 18 | title: "text", 19 | searchTerms: ["paragraph"], 20 | command: ({ editor, range }) => { 21 | editor 22 | .chain() 23 | .focus() 24 | .deleteRange(range) 25 | .toggleNode("paragraph", "paragraph") 26 | .run(); 27 | }, 28 | }, 29 | { 30 | title: "Bullet List", 31 | searchTerms: ["unordered", "point"], 32 | command: ({ editor, range }) => { 33 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 34 | }, 35 | }, 36 | { 37 | title: "Ordered List", 38 | searchTerms: ["ordered", "point", "numbers"], 39 | command: ({ editor, range }) => { 40 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 41 | }, 42 | }, 43 | ]); 44 | 45 | export default function Home() { 46 | const editor = useEditor({ 47 | extensions: [ 48 | StarterKit, 49 | ImageExtension, 50 | Slash.configure({ 51 | suggestion: { 52 | items: () => suggestions, 53 | }, 54 | }), 55 | Placeholder.configure({ 56 | // Use a placeholder: 57 | placeholder: "Press / to see available commands", 58 | // Use different placeholders depending on the node type: 59 | // placeholder: ({ node }) => { 60 | // if (node.type.name === 'heading') { 61 | // return 'What’s the title?' 62 | // } 63 | 64 | // return 'Can you add some further context?' 65 | // }, 66 | }), 67 | ], 68 | editorProps: { 69 | handleDOMEvents: { 70 | keydown: (_, v) => enableKeyboardNavigation(v), 71 | }, 72 | attributes: { 73 | class: 74 | "prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none", 75 | }, 76 | }, 77 | content: ` 78 |

This is a basic example of usage. Press / to see available commands. Click on Image to resize and align.

79 | 80 | `, 81 | }); 82 | 83 | if (!editor) { 84 | return null; 85 | } 86 | 87 | return ( 88 |
89 |
90 |

Slash Command Example

91 |

With useEditor hook

92 | 93 | 99 | 100 |
101 | 102 | 103 | 104 | 105 | No commands available 106 | 107 | {suggestions.map((item) => { 108 | return ( 109 | { 112 | item.command(val); 113 | }} 114 | className="flex w-full items-center space-x-2 cursor-pointer rounded-md p-2 text-left text-sm hover:bg-gray-200 aria-selected:bg-gray-200" 115 | key={item.title} 116 | > 117 |
118 |

{item.title}

119 |
120 |
121 | ); 122 | })} 123 |
124 |
125 |
126 | 127 | 128 | 129 | 133 | Left 134 | 135 | 139 | Center 140 | 141 | 145 | Right 146 | 147 | 148 | 149 | 150 |
151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [require("@tailwindcss/typography")], 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /apps/tiptap-extensions-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiptap-extensions", 3 | "private": true, 4 | "scripts": { 5 | "build": "turbo build", 6 | "dev": "turbo dev", 7 | "lint": "turbo lint", 8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 9 | "check-exports": "turbo check-exports", 10 | "ci": "turbo ci", 11 | "local-release": "turbo local-release", 12 | "prepublishOnly": "turbo prepublishOnly" 13 | }, 14 | "devDependencies": { 15 | "prettier": "^3.2.5", 16 | "turbo": "^2.1.2", 17 | "typescript": "^5.4.5" 18 | }, 19 | "packageManager": "pnpm@8.15.6", 20 | "engines": { 21 | "node": ">=18" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: ["eslint:recommended", "prettier", "turbo"], 8 | plugins: ["only-warn"], 9 | globals: { 10 | React: true, 11 | JSX: true, 12 | }, 13 | env: { 14 | node: true, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | ".*.js", 26 | "node_modules/", 27 | "dist/", 28 | ], 29 | overrides: [ 30 | { 31 | files: ["*.js?(x)", "*.ts?(x)"], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: [ 8 | "eslint:recommended", 9 | "prettier", 10 | require.resolve("@vercel/style-guide/eslint/next"), 11 | "turbo", 12 | ], 13 | globals: { 14 | React: true, 15 | JSX: true, 16 | }, 17 | env: { 18 | node: true, 19 | browser: true, 20 | }, 21 | plugins: ["only-warn"], 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | ], 34 | overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }], 35 | }; 36 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@harshtalks/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "library.js", 7 | "next.js", 8 | "react-internal.js" 9 | ], 10 | "devDependencies": { 11 | "@vercel/style-guide": "^5.2.0", 12 | "eslint-config-turbo": "^2.0.0", 13 | "eslint-config-prettier": "^9.1.0", 14 | "eslint-plugin-only-warn": "^1.1.0", 15 | "@typescript-eslint/parser": "^7.1.0", 16 | "@typescript-eslint/eslint-plugin": "^7.1.0", 17 | "typescript": "^5.3.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/eslint-config/react-internal.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * internal (bundled by their consumer) libraries 8 | * that utilize React. 9 | */ 10 | 11 | /** @type {import("eslint").Linter.Config} */ 12 | module.exports = { 13 | extends: ["eslint:recommended", "prettier", "turbo"], 14 | plugins: ["only-warn"], 15 | globals: { 16 | React: true, 17 | JSX: true, 18 | }, 19 | env: { 20 | browser: true, 21 | }, 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | "dist/", 34 | ], 35 | overrides: [ 36 | // Force ESLint to detect .tsx files 37 | { files: ["*.js?(x)", "*.ts?(x)"] }, 38 | ], 39 | }; 40 | -------------------------------------------------------------------------------- /packages/image-tiptap/.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 | -------------------------------------------------------------------------------- /packages/image-tiptap/.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/image-tiptap/.changeset/five-drinks-remain.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": minor 3 | --- 4 | 5 | margin top issue for centered aligned images 6 | -------------------------------------------------------------------------------- /packages/image-tiptap/.changeset/wet-tools-notice.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": patch 3 | --- 4 | 5 | added margin 0 to left and right alignment 6 | -------------------------------------------------------------------------------- /packages/image-tiptap/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/image-tiptap/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @harshtalks/image-tiptap 2 | 3 | ## 1.4.0 4 | 5 | ### Minor Changes 6 | 7 | - margin top issue for centered aligned images 8 | 9 | ## 1.3.0 10 | 11 | ### Minor Changes 12 | 13 | - updated readme 14 | 15 | ### Patch Changes 16 | 17 | - updated readme to include React.js 18 | - publishing first two packages now 19 | - readme example 20 | 21 | ## 1.2.0 22 | 23 | ### Minor Changes 24 | 25 | - updated readme 26 | 27 | ### Patch Changes 28 | 29 | - publishing first two packages now 30 | - readme example 31 | 32 | ## 1.1.0 33 | 34 | ### Minor Changes 35 | 36 | - updated readme 37 | 38 | ### Patch Changes 39 | 40 | - publishing first two packages now 41 | -------------------------------------------------------------------------------- /packages/image-tiptap/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/image-tiptap/README.md: -------------------------------------------------------------------------------- 1 | # Image Extension with Resize and Alignment for React.js 2 | 3 | It extends tiptap image extension to support image resizing and alignment in React.js. 4 | Existing third party/unofficial plugins are not flexible. 5 | This package contains - 6 | 1. UI headless components to render alignment menu in a bubble menu. 7 | 2. Image extension extended from tiptap-extension-image to support resizing and alignment out of the box 8 | 9 | It supports both useEditor hook and EditorProvider from tiptap. 10 | 11 | 12 | ## Installation 13 | 14 | Installing the package using pnpm 15 | 16 | ```bash 17 | pnpm add @harshtalks/image-tiptap 18 | ``` 19 | 20 | You can use npm, bun, or yarn etc. 21 | 22 | ## Usage 23 | 24 | ```tsx 25 | import { useEditor, EditorContent } from "@tiptap/react"; 26 | import StarterKit from "@tiptap/starter-kit"; 27 | import { ImageExtension, ImageAligner } from "@harshtalks/image-tiptap"; 28 | import "./globals.css"; 29 | import { useCallback } from "react"; 30 | 31 | export default function Home() { 32 | const editor = useEditor({ 33 | extensions: [StarterKit, ImageExtension], 34 | content: 35 | "

This is a basic example of implementing images. Drag to re-order.

", 36 | }); 37 | 38 | const addImage = useCallback(() => { 39 | const url = window.prompt("URL"); 40 | 41 | if (url) { 42 | editor?.chain().focus().setImage({ src: url }).run(); 43 | } 44 | }, [editor]); 45 | 46 | if (!editor) { 47 | return null; 48 | } 49 | 50 | return ( 51 |
52 | 55 | 56 |
57 | 58 | 59 | 60 | Left Align 61 | 62 | Center Align 63 | 64 | 65 | Right Align 66 | 67 | 68 | 69 | 70 |
71 | 72 | 73 |
74 | ); 75 | } 76 | 77 | ``` 78 | -------------------------------------------------------------------------------- /packages/image-tiptap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@harshtalks/image-tiptap", 3 | "description": "A Tiptap extension for React.js to add image resize and alignment options to the image extension.", 4 | "version": "1.4.0", 5 | "keywords": [ 6 | "tiptap", 7 | "image", 8 | "extension", 9 | "image-resize", 10 | "tiptap image", 11 | "align image" 12 | ], 13 | "author": "Harsh Pareek (https://hrshwrites.vercel.app)", 14 | "type": "module", 15 | "scripts": { 16 | "build": "tsup", 17 | "lint": "tsc", 18 | "ci": "pnpm run build && pnpm run check-exports && pnpm run lint", 19 | "check-exports": "attw --pack .", 20 | "prepublishOnly": "pnpm run ci", 21 | "local-release": "changeset version && changeset publish" 22 | }, 23 | "devDependencies": { 24 | "@arethetypeswrong/cli": "^0.16.4", 25 | "@changesets/cli": "^2.27.8", 26 | "@harshtalks/typescript-config": "workspace:*", 27 | "@types/node": "^22.6.1", 28 | "@types/react": "^18.3.8", 29 | "tsup": "^8.3.0", 30 | "typescript": "latest" 31 | }, 32 | "dependencies": { 33 | "@radix-ui/react-slot": "^1.1.0", 34 | "@tiptap/extension-image": "^2.7.2", 35 | "@tiptap/pm": "^2.7.2", 36 | "@tiptap/react": "^2.7.2", 37 | "react": "^18.3.1", 38 | "react-moveable": "^0.56.0" 39 | }, 40 | "license": "MIT", 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/harshtalks/tiptap-plugins.git", 44 | "directory": "packages/image-tiptap" 45 | }, 46 | "files": [ 47 | "dist" 48 | ], 49 | "exports": { 50 | ".": { 51 | "import": "./dist/index.js", 52 | "require": "./dist/index.cjs" 53 | }, 54 | "./package.json": "./package.json" 55 | }, 56 | "main": "./dist/index.js" 57 | } 58 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/components/image-aligner.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BubbleMenu, 3 | type BubbleMenuProps, 4 | Editor, 5 | useCurrentEditor, 6 | } from "@tiptap/react"; 7 | import * as React from "react"; 8 | import type { Alignment } from "../types"; 9 | import { 10 | DATA_ALIGNMENT_KEY, 11 | IMAGE_NODE, 12 | PROSE_ACTIVE_NODE, 13 | } from "../constants"; 14 | import { Slot } from "@radix-ui/react-slot"; 15 | import ImageResizer, { type ImageResizerProps } from "./image-resize"; 16 | 17 | declare module "@tiptap/react" { 18 | interface Commands { 19 | imageResize: { 20 | setImage: (options: { 21 | height?: number; 22 | width?: number; 23 | src: string; 24 | alt?: string; 25 | title?: string; 26 | [DATA_ALIGNMENT_KEY]: Alignment; 27 | }) => ReturnType; 28 | }; 29 | } 30 | } 31 | 32 | // It contains all the types needed to implement the ImageAligner component 33 | export namespace ImageAligner { 34 | export type RootProps = ImageResizerProps & { 35 | children?: React.ReactNode; 36 | editor?: Editor | null; 37 | }; 38 | 39 | export type AlignMenuProps = Omit; 40 | 41 | export type AlignerContext = Editor | null; 42 | 43 | export type ItemsProps = React.ComponentPropsWithoutRef<"div">; 44 | export type ItemsRef = React.ElementRef<"div">; 45 | export type ItemsDisplayName = "ImageAligner.Items"; 46 | 47 | export type ItemProps = React.ComponentPropsWithoutRef<"button"> & { 48 | alignment: Alignment; 49 | asChild?: boolean; 50 | }; 51 | export type ItemRef = React.ElementRef<"button">; 52 | export type ItemDisplayName = "ImageAligner.Item"; 53 | } 54 | 55 | // ImageAligner component implementation 56 | // We start with a context that contains our editor instance. 57 | // It can come from the Root component or the useCurrentEditor hook. 58 | // We use this context to render the BubbleMenu component. 59 | const alignerContext = React.createContext(null); 60 | 61 | // NOTE: The AlignMenu should appear on top of the Resizer, 62 | // For workaround, I have a Root component that wraps the Aligner and Resizer. Aligner is an optional prop. 63 | // If Aligner is not provided, it will just render the resizer. 64 | // If Aligner is provided, we can use AlignMenu, Items, and Item components to render the alignment options. 65 | const Root = ({ 66 | children, 67 | editor: propEditor, 68 | ...resizerProps 69 | }: ImageAligner.RootProps) => { 70 | const { editor: editorContext } = useCurrentEditor() || null; 71 | const editor = propEditor || editorContext; 72 | 73 | return ( 74 | 75 | {/* BubbleMenu should appear on top */} 76 | {children} 77 | {/* Image Resizer should come here */} 78 | 79 | 80 | ); 81 | }; 82 | 83 | // AlignMenu component implementation - wraps the individual alignment options with BubbleMenu 84 | const AlignMenu = ({ 85 | shouldShow, 86 | children, 87 | ...props 88 | }: ImageAligner.AlignMenuProps) => { 89 | const editor = React.useContext(alignerContext); 90 | 91 | if (!editor) { 92 | return null; 93 | } 94 | 95 | return ( 96 | { 100 | const { editor } = args; 101 | 102 | // don't show bubble menu if: 103 | // - the editor is not active with image node 104 | return editor.isActive("image") && (shouldShow?.(args) || true); 105 | }} 106 | > 107 | {children} 108 | 109 | ); 110 | }; 111 | 112 | // Items component implementation - wraps the individual alignment options 113 | const Items = React.forwardRef( 114 | (props, ref) => { 115 | return
; 116 | }, 117 | ); 118 | 119 | Items.displayName = "ImageAligner.Items" as ImageAligner.ItemsDisplayName; 120 | 121 | // Item component implementation - individual alignment option - button by default. 122 | const Item = React.forwardRef( 123 | ({ onClick, alignment, asChild, ...props }, ref) => { 124 | const editor = React.useContext(alignerContext); 125 | 126 | const Component = asChild ? Slot : "button"; 127 | 128 | if (!editor) { 129 | return null; 130 | } 131 | 132 | const getImageInfo = () => { 133 | if (editor.isActive(IMAGE_NODE)) { 134 | const imageNodeInfo = 135 | document.querySelector(PROSE_ACTIVE_NODE); 136 | 137 | return imageNodeInfo; 138 | } 139 | 140 | return null; 141 | }; 142 | 143 | const isItemActive = () => { 144 | const imageNodeInfo = getImageInfo(); 145 | if (imageNodeInfo) { 146 | const imageAlignment = imageNodeInfo.getAttribute(DATA_ALIGNMENT_KEY); 147 | 148 | if (imageAlignment === alignment) { 149 | return true; 150 | } else { 151 | return false; 152 | } 153 | } 154 | return false; 155 | }; 156 | 157 | const updateImageAlignment = () => { 158 | const imageNodeInfo = getImageInfo(); 159 | if (imageNodeInfo) { 160 | const selection = editor.state.selection; 161 | const setImage = editor.commands.setImage; 162 | 163 | setImage({ 164 | src: imageNodeInfo.src, 165 | ...(imageNodeInfo.alt && { alt: imageNodeInfo.alt }), 166 | ...(imageNodeInfo.title && { title: imageNodeInfo.title }), 167 | ...(imageNodeInfo.width && { width: imageNodeInfo.width }), 168 | ...(imageNodeInfo.height && { height: imageNodeInfo.height }), 169 | [DATA_ALIGNMENT_KEY]: alignment, 170 | }); 171 | 172 | editor.commands.setNodeSelection(selection.from); 173 | } 174 | }; 175 | 176 | return ( 177 | { 181 | // update image alignment 182 | updateImageAlignment(); 183 | if (onClick) { 184 | onClick(e); 185 | } 186 | }} 187 | data-isActive={isItemActive()} 188 | data-alignment={alignment} 189 | /> 190 | ); 191 | }, 192 | ); 193 | 194 | Item.displayName = "ImageAligner.Item" as ImageAligner.ItemDisplayName; 195 | 196 | // ImageAligner component 197 | const ImageAligner = { 198 | Root, 199 | AlignMenu, 200 | Items, 201 | Item, 202 | }; 203 | 204 | export default ImageAligner; 205 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/components/image-resize.tsx: -------------------------------------------------------------------------------- 1 | import { Editor, useCurrentEditor, type SingleCommands } from "@tiptap/react"; 2 | import Moveable, { type MoveableProps } from "react-moveable"; 3 | import { 4 | DATA_ALIGNMENT_KEY, 5 | IMAGE_NODE, 6 | PROSE_ACTIVE_NODE, 7 | } from "../constants"; 8 | import type { Alignment } from "../types"; 9 | import * as React from "react"; 10 | 11 | declare module "@tiptap/react" { 12 | interface Commands { 13 | imageResize: { 14 | setImage: (options: { 15 | height?: number; 16 | width?: number; 17 | src: string; 18 | alt?: string; 19 | title?: string; 20 | [DATA_ALIGNMENT_KEY]: Alignment; 21 | }) => ReturnType; 22 | }; 23 | } 24 | } 25 | 26 | export interface ImageResizerProps 27 | extends Omit { 28 | editor?: Editor | null; 29 | } 30 | 31 | const ImageResizer = ({ 32 | editor, 33 | onResize, 34 | onResizeEnd, 35 | onScale, 36 | ...moveableProps 37 | }: ImageResizerProps) => { 38 | // no editor, no resizer 39 | if (!editor) { 40 | // early exit 41 | return null; 42 | } 43 | 44 | // show only when the active node is an image 45 | if (!editor.isActive(IMAGE_NODE)) { 46 | return null; 47 | } 48 | 49 | const updateImageSize = () => { 50 | // get the image node 51 | const imageNodeInfo = 52 | document.querySelector(PROSE_ACTIVE_NODE); 53 | 54 | if (imageNodeInfo) { 55 | const selection = editor.state.selection; 56 | const setImage = editor.commands.setImage as SingleCommands["setImage"]; 57 | 58 | const width = Number(imageNodeInfo.style.width.replace("px", "")); 59 | const height = Number(imageNodeInfo.style.height.replace("px", "")); 60 | 61 | setImage({ 62 | src: imageNodeInfo.src, 63 | [DATA_ALIGNMENT_KEY]: imageNodeInfo.getAttribute( 64 | DATA_ALIGNMENT_KEY, 65 | ) as Alignment, 66 | ...(imageNodeInfo.alt && { alt: imageNodeInfo.alt }), 67 | ...(imageNodeInfo.title && { title: imageNodeInfo.title }), 68 | width, 69 | height, 70 | }); 71 | 72 | editor.commands.setNodeSelection(selection.from); 73 | } 74 | }; 75 | 76 | return ( 77 | (PROSE_ACTIVE_NODE)} 79 | container={null} 80 | origin={false} 81 | /* Resize event edges */ 82 | edge={false} 83 | throttleDrag={0} 84 | /* When resize or scale, keeps a ratio of the width, height. */ 85 | keepRatio={true} 86 | /* resizable*/ 87 | /* Only one of resizable, scalable, warpable can be used. */ 88 | resizable={true} 89 | throttleResize={0} 90 | onResize={(e) => { 91 | const { 92 | target, 93 | width, 94 | height, 95 | // dist, 96 | delta, 97 | } = e; 98 | if (delta[0]) target.style.width = `${width}px`; 99 | if (delta[1]) target.style.height = `${height}px`; 100 | 101 | onResize?.(e); 102 | }} 103 | // { target, isDrag, clientX, clientY }: any 104 | onResizeEnd={(e) => { 105 | updateImageSize(); 106 | onResizeEnd?.(e); 107 | }} 108 | /* scalable */ 109 | /* Only one of resizable, scalable, warpable can be used. */ 110 | scalable={true} 111 | throttleScale={0} 112 | /* Set the direction of resizable */ 113 | renderDirections={["w", "e", "s", "n"]} 114 | onScale={(e) => { 115 | const { 116 | target, 117 | // scale, 118 | // dist, 119 | // delta, 120 | transform, 121 | } = e; 122 | target.style.transform = transform; 123 | onScale?.(e); 124 | }} 125 | {...moveableProps} 126 | /> 127 | ); 128 | }; 129 | 130 | export default ImageResizer; 131 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/constants.ts: -------------------------------------------------------------------------------- 1 | // this key is used to store the alignment of the data in the table 2 | export const DATA_ALIGNMENT_KEY = "data-alignment"; 3 | export const IMAGE_NODE = "image"; 4 | export const PROSE_ACTIVE_NODE = ".ProseMirror-selectednode"; 5 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/extensions/image-extension.ts: -------------------------------------------------------------------------------- 1 | import ImageExtensionBase from "@tiptap/extension-image"; 2 | import { DATA_ALIGNMENT_KEY, IMAGE_NODE } from "../constants"; 3 | import type { Alignment } from "../types"; 4 | import { alignmentVariants } from "../utils"; 5 | 6 | const ImageExtension = ImageExtensionBase.extend({ 7 | // Adding name to the extension 8 | name: IMAGE_NODE, 9 | // we will extend the base image extension to include few custom attirbutes 10 | // this custom attributes will appear in the DOM for the given image. 11 | addAttributes() { 12 | return { 13 | ...this.parent?.(), 14 | // Adding custom attributes 15 | // Custom Attribute for height 16 | height: { 17 | default: null, 18 | }, 19 | // Custom Attribute for width 20 | width: { 21 | default: null, 22 | }, 23 | // this will be used to determine current alignment of the image 24 | [DATA_ALIGNMENT_KEY]: { 25 | default: "center" as Alignment, 26 | // use current alignment to set the style attribute 27 | renderHTML: (attributes) => ({ 28 | [DATA_ALIGNMENT_KEY]: attributes[DATA_ALIGNMENT_KEY], 29 | style: alignmentVariants[attributes[DATA_ALIGNMENT_KEY] as Alignment], 30 | }), 31 | }, 32 | }; 33 | }, 34 | }); 35 | 36 | export default ImageExtension; 37 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/index.ts: -------------------------------------------------------------------------------- 1 | import ImageAligner from "./components/image-aligner"; 2 | import ImageExtension from "./extensions/image-extension"; 3 | 4 | /** 5 | ImageExtension is a class that extends the Editor class with image-related functionalities. 6 | ImageAligner is a component that aligns images to the left, center, or right, as well as resizing them. 7 | */ 8 | export { ImageAligner, ImageExtension }; 9 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/types.ts: -------------------------------------------------------------------------------- 1 | // alignments 2 | export type Alignment = "left" | "center" | "right"; 3 | -------------------------------------------------------------------------------- /packages/image-tiptap/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Alignment } from "./types"; 2 | 3 | // This will be used to calculate the alignment of the data in the table 4 | export const alignmentVariants: Record = { 5 | center: `margin: 0 auto;`, 6 | left: `margin-right: auto; margin-top: 0;`, 7 | right: `margin-left: auto; margin-top: 0;`, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/image-tiptap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "jsx": "react", 9 | "declarationMap": false, 10 | 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noImplicitOverride": true, 15 | 16 | "module": "Preserve", 17 | "noEmit": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/image-tiptap/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entryPoints: ["src/index.ts"], 5 | format: ["cjs", "esm"], 6 | dts: true, 7 | outDir: "dist", 8 | clean: true, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.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 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": true, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.changeset/popular-gifts-warn.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": patch 3 | "@harshtalks/slash-tiptap": patch 4 | --- 5 | 6 | updated readme to include React.js 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.changeset/popular-shirts-trade.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": patch 3 | "@harshtalks/slash-tiptap": patch 4 | --- 5 | 6 | publishing first two packages now 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.changeset/wet-llamas-run.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": patch 3 | "@harshtalks/slash-tiptap": patch 4 | --- 5 | 6 | readme example 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/.changeset/young-peaches-exist.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@harshtalks/image-tiptap": minor 3 | "@harshtalks/slash-tiptap": minor 4 | --- 5 | 6 | updated readme 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @harshtalks/slash-tiptap 2 | 3 | ## 1.3.0 4 | 5 | ### Minor Changes 6 | 7 | - updated readme 8 | 9 | ### Patch Changes 10 | 11 | - updated readme to include React.js 12 | - publishing first two packages now 13 | - readme example 14 | 15 | ## 1.2.0 16 | 17 | ### Minor Changes 18 | 19 | - updated readme 20 | 21 | ### Patch Changes 22 | 23 | - publishing first two packages now 24 | - readme example 25 | 26 | ## 1.1.0 27 | 28 | ### Minor Changes 29 | 30 | - updated readme 31 | 32 | ### Patch Changes 33 | 34 | - publishing first two packages now 35 | -------------------------------------------------------------------------------- /packages/slash-tiptap/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/slash-tiptap/README.md: -------------------------------------------------------------------------------- 1 | # Tiptap Slash Command Extension for React.js 2 | 3 | Simple tiptap extension for React to add notion like slash command to your project. It provides a flexible extension built on top of tiptap suggestion extension, and headless UI components built on cmdk package. 4 | 5 | - Works with both useEditor hook and EditorProvider 6 | - Type Safe 7 | - Headless UI on top of cmdk 8 | - Flexible and easy to use 9 | 10 | Notes: 11 | 1. Make sure to wrap your entire editor in a `SlashCmdProvider` component. 12 | 2. for keyboard navigation, provide `enableKeyboardNavigationc` in `editorProps` handleDOMEvents. 13 | 14 | ## Installation 15 | 16 | Installing the package using pnpm 17 | 18 | ```bash 19 | pnpm add @harshtalks/slash-tiptap 20 | ``` 21 | 22 | ## Usage: 23 | 24 | 25 | 1. Define suggestions. Add all the commands you want in the slash menu. 26 | ```ts 27 | import { enableKeyboardNavigation } from "@harshtalks/slash-tiptap"; 28 | 29 | const suggestions = createSuggestionsItems([ 30 | { 31 | title: "text", 32 | searchTerms: ["paragraph"], 33 | command: ({ editor, range }) => { 34 | editor 35 | .chain() 36 | .focus() 37 | .deleteRange(range) 38 | .toggleNode("paragraph", "paragraph") 39 | .run(); 40 | }, 41 | }, 42 | { 43 | title: "Bullet List", 44 | searchTerms: ["unordered", "point"], 45 | command: ({ editor, range }) => { 46 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 47 | }, 48 | }, 49 | { 50 | title: "Ordered List", 51 | searchTerms: ["ordered", "point", "numbers"], 52 | command: ({ editor, range }) => { 53 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 54 | }, 55 | }, 56 | ]); 57 | ``` 58 | 59 | 2. Create an editor instance 60 | ```tsx 61 | import { 62 | Slash, 63 | enableKeyboardNavigation, 64 | } from "@harshtalks/slash-tiptap"; 65 | 66 | 67 | const editor = useEditor({ 68 | extensions: [ 69 | StarterKit, 70 | ImageExtension, 71 | Slash.configure({ 72 | suggestion: { 73 | items: () => suggestions, 74 | }, 75 | }), 76 | Placeholder.configure({ 77 | // Use a placeholder: 78 | placeholder: "Press / to see available commands", 79 | // Use different placeholders depending on the node type: 80 | // placeholder: ({ node }) => { 81 | // if (node.type.name === 'heading') { 82 | // return 'What’s the title?' 83 | // } 84 | 85 | // return 'Can you add some further context?' 86 | // }, 87 | }), 88 | ], 89 | editorProps: { 90 | handleDOMEvents: { 91 | keydown: (_, v) => enableKeyboardNavigation(v), 92 | }, 93 | attributes: { 94 | class: 95 | "prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none", 96 | }, 97 | }, 98 | content: ` 99 |

This is a basic example of usage. Press / to see available commands. Click on Image to resize and align.

100 | 101 | `, 102 | }); 103 | ``` 104 | 105 | 3. Wrap your editor in `SlashCmdProvider` component and add `SlashCmd` component to your editor. 106 | ```tsx 107 | import { 108 | SlashCmd 109 | } from "@harshtalks/slash-tiptap"; 110 | 111 | 112 | export const Editor = () => { 113 | return ( 114 | 115 | 116 | 117 | 118 | No commands available 119 | 120 | {suggestions.map((item) => { 121 | return ( 122 | { 125 | item.command(val); 126 | }} 127 | key={item.title} 128 | > 129 |

{item.title}

130 |
131 | ); 132 | })} 133 |
134 |
135 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /packages/slash-tiptap/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@harshtalks/slash-tiptap", 3 | "description": "notion like slash command for tiptap React.js", 4 | "version": "1.3.0", 5 | "keywords": [ 6 | "tiptap", 7 | "slash command", 8 | "cmdk", 9 | "tiptap slash" 10 | ], 11 | "author": "Harsh Pareek (https://hrshwrites.vercel.app)", 12 | "type": "module", 13 | "devDependencies": { 14 | "@arethetypeswrong/cli": "^0.16.4", 15 | "@changesets/cli": "^2.27.8", 16 | "@harshtalks/typescript-config": "workspace:*", 17 | "@types/node": "^22.6.1", 18 | "@types/react": "^18.3.8", 19 | "tsup": "^8.3.0", 20 | "typescript": "latest" 21 | }, 22 | "dependencies": { 23 | "@tiptap/pm": "^2.7.2", 24 | "@tiptap/react": "^2.7.2", 25 | "@tiptap/suggestion": "^2.7.2", 26 | "@xstate/store": "^2.6.0", 27 | "cmdk": "^1.0.0", 28 | "react": "^18.3.1", 29 | "tippy.js": "^6.3.7" 30 | }, 31 | "scripts": { 32 | "build": "tsup", 33 | "lint": "tsc", 34 | "ci": "pnpm run build && pnpm run check-exports && pnpm run lint", 35 | "check-exports": "attw --pack .", 36 | "prepublishOnly": "pnpm run ci", 37 | "local-release": "changeset version && changeset publish" 38 | }, 39 | "license": "MIT", 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/harshtalks/tiptap-plugins.git", 43 | "directory": "packages/slash-tiptap" 44 | }, 45 | "files": [ 46 | "dist" 47 | ], 48 | "exports": { 49 | ".": { 50 | "import": "./dist/index.js", 51 | "require": "./dist/index.cjs" 52 | }, 53 | "./package.json": "./package.json" 54 | }, 55 | "main": "./dist/index.js" 56 | } 57 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/command-outlet.tsx: -------------------------------------------------------------------------------- 1 | import slashStore from "../utils/store"; 2 | import React from "react"; 3 | import { SLASH_EXTENSION_DOM_ID, navigationKeys } from "../utils/constants"; 4 | import SlashCmdTunnelInstanceContext from "./tunnel-instance"; 5 | import type { TipTapRange } from "../types"; 6 | 7 | interface SlashCommandOutProps { 8 | readonly query: string; 9 | readonly range: TipTapRange; 10 | } 11 | 12 | const CommandTunnelOutlet = (props: SlashCommandOutProps) => { 13 | React.useEffect(() => { 14 | slashStore.send({ 15 | type: "setQuery", 16 | query: props.query, 17 | }); 18 | }, [props.query]); 19 | 20 | React.useEffect(() => { 21 | slashStore.send({ 22 | type: "setRange", 23 | range: props.range, 24 | }); 25 | }, [props.range]); 26 | 27 | React.useEffect(() => { 28 | const abortController = new AbortController(); 29 | 30 | document.addEventListener( 31 | "keydown", 32 | (event) => { 33 | if (navigationKeys.includes(event.key)) { 34 | // prevent default behavior of the key 35 | event.preventDefault(); 36 | 37 | const slashCommandRef = document.getElementById( 38 | SLASH_EXTENSION_DOM_ID, 39 | ); 40 | 41 | if (slashCommandRef) { 42 | // dispatch the keydown event to the slash command 43 | slashCommandRef.dispatchEvent( 44 | new KeyboardEvent("keydown", { 45 | key: event.key, 46 | cancelable: true, 47 | bubbles: true, 48 | }), 49 | ); 50 | 51 | return false; 52 | } 53 | } 54 | }, 55 | { 56 | signal: abortController.signal, 57 | }, 58 | ); 59 | 60 | return () => { 61 | abortController.abort(); 62 | }; 63 | }, []); 64 | 65 | return ( 66 | 67 | {(tunnelInstance) => { 68 | if (!tunnelInstance) { 69 | throw new Error( 70 | "Command component must be used within a . Make sure your instance of editor and command are wrapped in the provider", 71 | ); 72 | } 73 | 74 | return ; 75 | }} 76 | 77 | ); 78 | }; 79 | 80 | export default CommandTunnelOutlet; 81 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/command.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "@xstate/store/react"; 2 | import { Command as Cmd } from "cmdk"; 3 | import React, { type ComponentPropsWithRef } from "react"; 4 | import slashStore from "../utils/store"; 5 | import SlashCmdTunnelInstanceContext from "./tunnel-instance"; 6 | import { SLASH_EXTENSION_DOM_ID } from "../utils/constants"; 7 | 8 | const Command = React.forwardRef< 9 | React.ElementRef<"div">, 10 | Omit, "id" | "onKeyDown"> 11 | >((props, ref) => { 12 | const { children, className, ...restProps } = props; 13 | const { query } = useSelector(slashStore, (state) => state.context); 14 | 15 | const onChange = (query: string) => { 16 | slashStore.send({ 17 | type: "setQuery", 18 | query: query, 19 | }); 20 | }; 21 | 22 | return ( 23 | 24 | {(tunnel) => { 25 | if (!tunnel) { 26 | throw new Error( 27 | "Command component must be used within a . Make sure your instance of editor and command are wrapped in the provider", 28 | ); 29 | } 30 | 31 | return ( 32 | 33 | e.stopPropagation()} 37 | className={className} 38 | id={SLASH_EXTENSION_DOM_ID} 39 | > 40 | 45 | {children} 46 | 47 | 48 | ); 49 | }} 50 | 51 | ); 52 | }); 53 | 54 | Command.displayName = "SlashCommand"; 55 | 56 | export default Command; 57 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import Command from "./command"; 2 | import Item from "./item"; 3 | import SlashCmdRoot from "./root"; 4 | import { Command as Cmd } from "cmdk"; 5 | 6 | const SlashCommand = { 7 | Root: SlashCmdRoot, 8 | Cmd: Command, 9 | List: Cmd.List, 10 | Item: Item, 11 | Empty: Cmd.Empty, 12 | Loading: Cmd.Loading, 13 | Separator: Cmd.Separator, 14 | Group: Cmd.Group, 15 | }; 16 | 17 | export default SlashCommand; 18 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/item.tsx: -------------------------------------------------------------------------------- 1 | import { Editor, useCurrentEditor } from "@tiptap/react"; 2 | import type { TipTapRange } from "../types"; 3 | import React from "react"; 4 | import { CommandItem } from "cmdk"; 5 | import { useSelector } from "@xstate/store/react"; 6 | import slashStore from "../utils/store"; 7 | 8 | export interface SlashItemProps { 9 | readonly onCommand: ({ 10 | editor, 11 | range, 12 | }: { 13 | editor: Editor; 14 | range: TipTapRange; 15 | }) => void; 16 | } 17 | 18 | const Item = React.forwardRef< 19 | React.ElementRef<"div">, 20 | Omit, "onSelect"> & 21 | SlashItemProps 22 | >((props, ref) => { 23 | const { onCommand, className, children, ...restProps } = props; 24 | 25 | const { range, localEditor } = useSelector( 26 | slashStore, 27 | (state) => state.context, 28 | ); 29 | 30 | if (!localEditor) { 31 | throw new Error( 32 | "Editor is required, Please provide editor to the Cmd.Root or use within EditorProvider.", 33 | ); 34 | } 35 | 36 | if (!range) { 37 | return null; 38 | } 39 | 40 | return ( 41 | onCommand({ editor: localEditor, range: range })} 44 | ref={ref} 45 | className={className} 46 | > 47 | {children} 48 | 49 | ); 50 | }); 51 | 52 | Item.displayName = "SlashItem"; 53 | 54 | export default Item; 55 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/root.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentEditor, type Editor } from "@tiptap/react"; 2 | import React, { useEffect } from "react"; 3 | import slashStore from "../utils/store"; 4 | 5 | type SlashCmdRootProps = { 6 | children: React.ReactNode; 7 | editor?: Editor | null; 8 | }; 9 | 10 | // This component is used to set the current editor in the store, 11 | // if user doesn't pass the editor prop, it will try to get the editor from the context 12 | // and set it in the store 13 | // This is required because the slash commands are not part of the editor. 14 | // We need to throw error if the editor is not set and yet accessed in the children components to Slash CMDK 15 | 16 | const SlashCmdRoot = (props: SlashCmdRootProps) => { 17 | const { children, editor: propEditor } = props; 18 | const { editor: contextEditor } = useCurrentEditor() || {}; 19 | 20 | const editor = propEditor || contextEditor; 21 | 22 | useEffect(() => { 23 | if (editor) { 24 | slashStore.send({ 25 | type: "setLocalEditor", 26 | localEditor: editor, 27 | }); 28 | } 29 | }, [editor]); 30 | 31 | return <>{children}; 32 | }; 33 | 34 | export default SlashCmdRoot; 35 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/slash-provider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import tunnel from "../utils/tunnel"; 3 | import SlashCmdTunnelInstanceContext from "./tunnel-instance"; 4 | 5 | type SlashCmdProviderProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | /** 10 | * We need to wrap our entire editor in this provider, so that tippy can use our tunneled instance to communicate with the editor. 11 | */ 12 | 13 | const SlashCmdProvider = (props: SlashCmdProviderProps) => { 14 | const tunnelInstance = React.useRef(tunnel()).current; 15 | 16 | return ( 17 | 18 | {props.children} 19 | 20 | ); 21 | }; 22 | 23 | export default SlashCmdProvider; 24 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/components/tunnel-instance.tsx: -------------------------------------------------------------------------------- 1 | import tunnel from "../utils/tunnel"; 2 | import React from "react"; 3 | 4 | type TunnelInstace = ReturnType; 5 | 6 | const SlashCmdTunnelInstanceContext = React.createContext( 7 | null, 8 | ); 9 | 10 | export default SlashCmdTunnelInstanceContext; 11 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/extensions/helper.ts: -------------------------------------------------------------------------------- 1 | import { SLASH_EXTENSION_DOM_ID, navigationKeys } from "../utils/constants"; 2 | 3 | const enableKeyboardNavigation = (event: KeyboardEvent) => { 4 | if (navigationKeys.includes(event.key)) { 5 | const slashCommand = document.getElementById(SLASH_EXTENSION_DOM_ID); 6 | if (slashCommand) { 7 | return true; 8 | } 9 | } 10 | }; 11 | 12 | export { enableKeyboardNavigation }; 13 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/extensions/index.ts: -------------------------------------------------------------------------------- 1 | import createSuggestionsItems from "./suggestions"; 2 | import Slash from "./slash-extension"; 3 | import renderItems from "./render-items"; 4 | import { enableKeyboardNavigation } from "./helper"; 5 | 6 | export { Slash, createSuggestionsItems, renderItems, enableKeyboardNavigation }; 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/extensions/render-items.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Editor, ReactRenderer } from "@tiptap/react"; 3 | 4 | import tippy, { 5 | type GetReferenceClientRect, 6 | type Instance, 7 | type Props, 8 | } from "tippy.js"; 9 | import CommandTunnelOutlet from "../components/command-outlet"; 10 | import type { SuggestionOptions } from "@tiptap/suggestion"; 11 | 12 | const renderItems: SuggestionOptions["render"] = ( 13 | elementRef?: React.RefObject | null, 14 | ) => { 15 | let component: ReactRenderer | null = null; 16 | let popup: Instance[] | null = null; 17 | 18 | return { 19 | onStart: (props) => { 20 | const { editor, clientRect } = props; 21 | 22 | component = new ReactRenderer(CommandTunnelOutlet, { 23 | editor: editor, 24 | props, 25 | }); 26 | 27 | const { selection } = editor.state; 28 | const parentNode = selection.$from.node(selection.$from.depth); 29 | const blockType = parentNode.type.name; 30 | 31 | if (blockType === "codeBlock") { 32 | return false; 33 | } 34 | 35 | // @ts-ignore 36 | popup = tippy("body", { 37 | getReferenceClientRect: props.clientRect, 38 | appendTo: () => (elementRef ? elementRef.current : document.body), 39 | content: component.element, 40 | showOnCreate: true, 41 | interactive: true, 42 | trigger: "manual", 43 | placement: "bottom-start", 44 | }); 45 | }, 46 | 47 | onUpdate: (props) => { 48 | component?.updateProps(props); 49 | 50 | popup?.[0]?.setProps({ 51 | // @ts-ignore 52 | getReferenceClientRect: props.clientRect, 53 | }); 54 | }, 55 | 56 | onKeyDown: (props) => { 57 | if (props.event.key === "Escape") { 58 | popup?.[0]?.hide(); 59 | 60 | return true; 61 | } 62 | 63 | // @ts-ignore 64 | return component?.ref?.onKeyDown(props); 65 | }, 66 | 67 | onExit: () => { 68 | popup?.[0]?.destroy(); 69 | component?.destroy(); 70 | }, 71 | }; 72 | }; 73 | 74 | export default renderItems; 75 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/extensions/slash-extension.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/react"; 2 | import { SLASH_EXTENSION_NAME } from "../utils/constants"; 3 | import Suggestion from "@tiptap/suggestion"; 4 | import renderItems from "./render-items"; 5 | import type { SlashOptions } from "../types"; 6 | 7 | const slashExtenstionSuggestion = Extension.create({ 8 | name: SLASH_EXTENSION_NAME, 9 | addOptions() { 10 | return { 11 | suggestion: { 12 | char: "/", 13 | command: ({ editor, range, props }) => { 14 | props.command({ editor, range }); 15 | }, 16 | render: renderItems, 17 | }, 18 | }; 19 | }, 20 | addProseMirrorPlugins() { 21 | return [ 22 | Suggestion({ 23 | editor: this.editor, 24 | ...this.options.suggestion, 25 | }), 26 | ]; 27 | }, 28 | }); 29 | 30 | export default slashExtenstionSuggestion; 31 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/extensions/suggestions.ts: -------------------------------------------------------------------------------- 1 | import { type SlashSuggestion } from "../types"; 2 | 3 | const createSuggestionsItems = < 4 | T extends Record = Record, 5 | >( 6 | items: SlashSuggestion[], 7 | ) => { 8 | return items; 9 | }; 10 | 11 | export default createSuggestionsItems; 12 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/index.ts: -------------------------------------------------------------------------------- 1 | // Entry Point 2 | import SlashCmd from "./components"; 3 | import SlashCmdProvider from "./components/slash-provider"; 4 | export * from "./extensions"; 5 | 6 | export { SlashCmd, SlashCmdProvider }; 7 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/types.ts: -------------------------------------------------------------------------------- 1 | // SlashSuggestions 2 | // 3 | 4 | import type { Editor, Range as TRange } from "@tiptap/react"; 5 | import type { SuggestionOptions } from "@tiptap/suggestion"; 6 | import { type ReactNode } from "react"; 7 | 8 | export type TipTapRange = TRange; 9 | 10 | export type SlashSuggestionBase = { 11 | title: string; 12 | command: (props: { editor: Editor; range: TipTapRange }) => void; 13 | searchTerms?: string[]; 14 | }; 15 | 16 | export type SlashSuggestion< 17 | T extends Record = Record, 18 | > = SlashSuggestionBase & T; 19 | 20 | export type SlashSuggestionRecommended = SlashSuggestion<{ 21 | description: string; 22 | icon: ReactNode; 23 | }>; 24 | 25 | export type SlashOptions< 26 | TSuggestion extends Record = Record, 27 | > = { 28 | suggestion: Omit>, "editor">; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const SLASH_EXTENSION_NAME = "slash-menu"; 2 | export const SLASH_EXTENSION_DOM_ID = "slash-extenbsion-dom-id"; 3 | export const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; 4 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/utils/store.ts: -------------------------------------------------------------------------------- 1 | // store to manage query, and range of the query etc. 2 | 3 | import { createStore } from "@xstate/store"; 4 | import { type TipTapRange } from "../types"; 5 | import type { Editor } from "@tiptap/react"; 6 | 7 | const slashStore = createStore( 8 | { 9 | query: "", 10 | range: null as TipTapRange | null, 11 | localEditor: null as Editor | null, 12 | }, 13 | { 14 | setQuery: (_, event: { query: string }) => { 15 | return { 16 | query: event.query, 17 | }; 18 | }, 19 | setRange: (_, event: { range: TipTapRange }) => { 20 | return { 21 | range: event.range, 22 | }; 23 | }, 24 | setLocalEditor: (_, event: { localEditor: Editor }) => { 25 | return { 26 | localEditor: event.localEditor, 27 | }; 28 | }, 29 | }, 30 | ); 31 | 32 | export default slashStore; 33 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/utils/tunnel.tsx: -------------------------------------------------------------------------------- 1 | import React, { type ReactNode } from "react"; 2 | import { createStore } from "@xstate/store"; 3 | import { useSelector } from "@xstate/store/react"; 4 | 5 | import useIsoMorphicEffect from "./use-isomorphic-effect"; 6 | 7 | type InletProps = { 8 | children: ReactNode; 9 | }; 10 | 11 | const tunnel = () => { 12 | const tunnelStore = createStore( 13 | { 14 | currentChildren: [] as Array, 15 | }, 16 | { 17 | addChildren: (context, event: { value: ReactNode }) => { 18 | return { 19 | currentChildren: [...context.currentChildren, event.value], 20 | }; 21 | }, 22 | removeChildren: (context, event: { value: ReactNode }) => { 23 | return { 24 | currentChildren: context.currentChildren.filter( 25 | (child) => child !== event.value, 26 | ), 27 | }; 28 | }, 29 | }, 30 | ); 31 | 32 | return { 33 | Inlet: (props: InletProps) => { 34 | const { children } = props; 35 | 36 | useIsoMorphicEffect(() => { 37 | tunnelStore.send({ type: "addChildren", value: children }); 38 | 39 | return () => { 40 | tunnelStore.send({ type: "removeChildren", value: children }); 41 | }; 42 | }, [props.children]); 43 | 44 | return null; 45 | }, 46 | Outlet: () => { 47 | const children = useSelector( 48 | tunnelStore, 49 | (state) => state.context.currentChildren, 50 | ); 51 | 52 | return <>{children}; 53 | }, 54 | }; 55 | }; 56 | 57 | export default tunnel; 58 | -------------------------------------------------------------------------------- /packages/slash-tiptap/src/utils/use-isomorphic-effect.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | // A React helper hook for scheduling a layout effect with a 4 | // fallback to a regular effect for environments where layout effects should not be use 5 | const useIsoMorphicEffect = 6 | typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; 7 | 8 | export default useIsoMorphicEffect; 9 | -------------------------------------------------------------------------------- /packages/slash-tiptap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Base Options: */ 4 | "esModuleInterop": true, 5 | "skipLibCheck": true, 6 | "target": "es2022", 7 | "verbatimModuleSyntax": true, 8 | "jsx": "react", 9 | "declarationMap": false, 10 | 11 | /* Strictness */ 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noImplicitOverride": true, 15 | 16 | "module": "Preserve", 17 | "noEmit": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/slash-tiptap/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entryPoints: ["src/index.ts"], 5 | format: ["cjs", "esm"], 6 | dts: true, 7 | outDir: "dist", 8 | clean: true, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "esModuleInterop": true, 6 | "skipLibCheck": true, 7 | "target": "es2022", 8 | "verbatimModuleSyntax": true, 9 | "jsx": "react", 10 | "declarationMap": false, 11 | 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noImplicitOverride": true, 15 | 16 | "module": "Preserve", 17 | "noEmit": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/typescript-config/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "allowJs": true, 10 | "jsx": "preserve", 11 | "noEmit": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@harshtalks/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 8 | "outputs": [".next/**", "!.next/cache/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"] 12 | }, 13 | "dev": { 14 | "cache": false, 15 | "persistent": true 16 | }, 17 | "check-exports": {}, 18 | "ci": {}, 19 | "prepublishOnly": {}, 20 | "local-release": { 21 | "dependsOn": ["^prepublishOnly"] 22 | } 23 | } 24 | } 25 | --------------------------------------------------------------------------------