├── .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 |
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 |
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 |
--------------------------------------------------------------------------------